Skip to content

Commit 4a61452

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

File tree

7 files changed

+293
-65
lines changed

7 files changed

+293
-65
lines changed

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,11 @@ public sealed class GrpcJsonSettings
3232
/// Default value is false.
3333
/// </summary>
3434
public bool WriteIndented { get; set; }
35+
36+
/// <summary>
37+
/// Gets or sets a value that indicates whether enum type name prefix on values should be stripped when
38+
/// reading and writing enum values.
39+
/// Default value is false.
40+
/// </summary>
41+
public bool StripEnumPrefix { get; set; }
3542
}

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: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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+
// Effectively a cache of mapping from enum values to the original name as specified in the proto file,
14+
// fetched by reflection.
15+
private static readonly ConcurrentDictionary<Type, EnumMapping> _enumMappings = new ConcurrentDictionary<Type, EnumMapping>();
16+
17+
internal static EnumValueDescriptor? GetEnumFieldReadValue(EnumDescriptor enumDescriptor, string value, GrpcJsonSettings settings)
18+
{
19+
string resolvedName;
20+
if (settings.StripEnumPrefix)
21+
{
22+
var nameMapping = GetEnumMapping(enumDescriptor);
23+
if (!nameMapping.StripEnumPrefixMapping.TryGetValue(value, out var n))
24+
{
25+
return null;
26+
}
27+
28+
resolvedName = n;
29+
}
30+
else
31+
{
32+
resolvedName = value;
33+
}
34+
35+
var valueDescriptor = enumDescriptor.FindValueByName(resolvedName);
36+
return valueDescriptor;
37+
}
38+
39+
internal static string? GetEnumFieldWriteName(EnumDescriptor enumDescriptor, object value, GrpcJsonSettings settings)
40+
{
41+
var enumMapping = GetEnumMapping(enumDescriptor);
42+
43+
// If this returns false, name will be null, which is what we want.
44+
if (!enumMapping.WriteMapping.TryGetValue(value, out var mapping))
45+
{
46+
return null;
47+
}
48+
49+
return settings.StripEnumPrefix ? mapping.StripEnumPrefixName : mapping.OriginalName;
50+
}
51+
52+
private static EnumMapping GetEnumMapping(EnumDescriptor enumDescriptor)
53+
{
54+
var enumType = enumDescriptor.ClrType;
55+
56+
EnumMapping? enumMapping;
57+
lock (_enumMappings)
58+
{
59+
if (!_enumMappings.TryGetValue(enumType, out enumMapping))
60+
{
61+
_enumMappings[enumType] = enumMapping = GetEnumMappings(enumDescriptor.Name, enumType);
62+
}
63+
}
64+
65+
return enumMapping;
66+
}
67+
68+
private static EnumMapping GetEnumMappings(string enumName, Type enumType)
69+
{
70+
var enumFields = enumType.GetTypeInfo().DeclaredFields
71+
.Where(f => f.IsStatic)
72+
.Where(f => f.GetCustomAttributes<OriginalNameAttribute>()
73+
.FirstOrDefault()?.PreferredAlias ?? true)
74+
.ToList();
75+
76+
var writeMapping = enumFields.ToDictionary(
77+
f => f.GetValue(null)!,
78+
f =>
79+
{
80+
// If the attribute hasn't been applied, fall back to the name of the field.
81+
var fieldName = f.GetCustomAttributes<OriginalNameAttribute>().FirstOrDefault()?.Name ?? f.Name;
82+
83+
return new NameMapping
84+
{
85+
OriginalName = fieldName,
86+
StripEnumPrefixName = GetEnumValueName(enumName, fieldName)
87+
};
88+
});
89+
90+
var stripEnumPrefixMapping = writeMapping.Values.ToDictionary(
91+
m => m.StripEnumPrefixName,
92+
m => m.OriginalName);
93+
94+
return new EnumMapping { WriteMapping = writeMapping, StripEnumPrefixMapping = stripEnumPrefixMapping };
95+
}
96+
97+
private static string TryRemovePrefix(string prefix, string value)
98+
{
99+
var normalizedPrefix = new string(prefix.Where(c => c != '_').Select(char.ToLowerInvariant).ToArray());
100+
101+
var prefixIndex = 0;
102+
var valueIndex = 0;
103+
104+
while (prefixIndex < normalizedPrefix.Length && valueIndex < value.Length)
105+
{
106+
if (value[valueIndex] == '_')
107+
{
108+
valueIndex++;
109+
continue;
110+
}
111+
112+
if (char.ToLowerInvariant(value[valueIndex]) != normalizedPrefix[prefixIndex])
113+
{
114+
return value;
115+
}
116+
117+
prefixIndex++;
118+
valueIndex++;
119+
}
120+
121+
if (prefixIndex < normalizedPrefix.Length)
122+
{
123+
return value;
124+
}
125+
126+
while (valueIndex < value.Length && value[valueIndex] == '_')
127+
{
128+
valueIndex++;
129+
}
130+
131+
return valueIndex == value.Length ? value : value.Substring(valueIndex);
132+
}
133+
134+
private static string GetEnumValueName(string enumName, string valueName)
135+
{
136+
var result = TryRemovePrefix(enumName, valueName);
137+
return char.IsDigit(result[0]) ? $"_{result}" : result;
138+
}
139+
140+
private sealed class EnumMapping
141+
{
142+
public required Dictionary<object, NameMapping> WriteMapping { get; init; }
143+
public required Dictionary<string, string> StripEnumPrefixMapping { get; init; }
144+
}
145+
146+
private sealed class NameMapping
147+
{
148+
public required string OriginalName { get; init; }
149+
public required string StripEnumPrefixName { get; init; }
150+
}
151+
}

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_StripPrefix_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 { StripEnumPrefix = true });
258+
Assert.Equal(expectedValue, result.SingleEnum);
243259
}
244260

245261
[Fact]

0 commit comments

Comments
 (0)