diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/GrpcJsonSettings.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/GrpcJsonSettings.cs
index b18519c76e0a..6a4ab85c5ee5 100644
--- a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/GrpcJsonSettings.cs
+++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/GrpcJsonSettings.cs
@@ -48,4 +48,42 @@ public sealed class GrpcJsonSettings
///
///
public bool PropertyNameCaseInsensitive { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the enum type name prefix should be removed when reading and writing enum values.
+ /// The default value is .
+ ///
+ ///
+ ///
+ /// In Protocol Buffers, enum value names are globally scoped, so they are often prefixed with the enum type name
+ /// to avoid name collisions. For example, the Status enum might define values like STATUS_UNKNOWN
+ /// and STATUS_OK.
+ ///
+ ///
+ /// enum Status {
+ /// STATUS_UNKNOWN = 0;
+ /// STATUS_OK = 1;
+ /// }
+ ///
+ ///
+ /// When is set to :
+ ///
+ ///
+ ///
+ /// The STATUS prefix is removed from enum values. The enum values above will be read and written as UNKNOWN and OK instead of STATUS_UNKNOWN and STATUS_OK.
+ ///
+ ///
+ /// Original prefixed values are used as a fallback when reading JSON. For example, STATUS_OK and OK map to the STATUS_OK enum value.
+ ///
+ ///
+ ///
+ /// The Protobuf JSON specification requires enum values in JSON to match enum fields exactly.
+ /// Enabling this option may reduce interoperability, as removing enum prefix might not be supported
+ /// by other JSON transcoding implementations.
+ ///
+ ///
+ /// For more information, see .
+ ///
+ ///
+ public bool RemoveEnumPrefix { get; set; }
}
diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/EnumConverter.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/EnumConverter.cs
index c2779e931d83..6f761424d571 100644
--- a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/EnumConverter.cs
+++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/EnumConverter.cs
@@ -5,7 +5,6 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
using Google.Protobuf.Reflection;
-using Grpc.Shared;
using Type = System.Type;
namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json;
@@ -21,18 +20,12 @@ public EnumConverter(JsonContext context) : base(context)
switch (reader.TokenType)
{
case JsonTokenType.String:
- var enumDescriptor = (EnumDescriptor?)Context.DescriptorRegistry.FindDescriptorByType(typeToConvert);
- if (enumDescriptor == null)
- {
- throw new InvalidOperationException($"Unable to resolve descriptor for {typeToConvert}.");
- }
+ var enumDescriptor = (EnumDescriptor?)Context.DescriptorRegistry.FindDescriptorByType(typeToConvert)
+ ?? throw new InvalidOperationException($"Unable to resolve descriptor for {typeToConvert}.");
var value = reader.GetString()!;
- var valueDescriptor = enumDescriptor.FindValueByName(value);
- if (valueDescriptor == null)
- {
- throw new InvalidOperationException(@$"Error converting value ""{value}"" to enum type {typeToConvert}.");
- }
+ var valueDescriptor = JsonNamingHelpers.GetEnumFieldReadValue(enumDescriptor, value, Context.Settings)
+ ?? throw new InvalidOperationException(@$"Error converting value ""{value}"" to enum type {typeToConvert}.");
return ConvertFromInteger(valueDescriptor.Number);
case JsonTokenType.Number:
@@ -52,7 +45,10 @@ public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOpt
}
else
{
- var name = Legacy.OriginalEnumValueHelper.GetOriginalName(value);
+ var enumDescriptor = (EnumDescriptor?)Context.DescriptorRegistry.FindDescriptorByType(value.GetType())
+ ?? throw new InvalidOperationException($"Unable to resolve descriptor for {value.GetType()}.");
+
+ var name = JsonNamingHelpers.GetEnumFieldWriteName(enumDescriptor, value, Context.Settings);
if (name != null)
{
writer.WriteStringValue(name);
diff --git a/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/EnumNameHelpers.cs b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/EnumNameHelpers.cs
new file mode 100644
index 000000000000..986524d4ec74
--- /dev/null
+++ b/src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/EnumNameHelpers.cs
@@ -0,0 +1,164 @@
+// 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.Concurrent;
+using System.Linq;
+using System.Reflection;
+using Google.Protobuf.Reflection;
+
+namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json;
+
+internal static class JsonNamingHelpers
+{
+ private static readonly ConcurrentDictionary _enumMappings = new ConcurrentDictionary();
+
+ internal static EnumValueDescriptor? GetEnumFieldReadValue(EnumDescriptor enumDescriptor, string value, GrpcJsonSettings settings)
+ {
+ string resolvedName;
+ if (settings.RemoveEnumPrefix)
+ {
+ var nameMapping = GetEnumMapping(enumDescriptor);
+ if (!nameMapping.RemoveEnumPrefixMapping.TryGetValue(value, out var n))
+ {
+ return null;
+ }
+
+ resolvedName = n;
+ }
+ else
+ {
+ resolvedName = value;
+ }
+
+ return enumDescriptor.FindValueByName(resolvedName);
+ }
+
+ internal static string? GetEnumFieldWriteName(EnumDescriptor enumDescriptor, object value, GrpcJsonSettings settings)
+ {
+ var enumMapping = GetEnumMapping(enumDescriptor);
+
+ // If this returns false, name will be null, which is what we want.
+ if (!enumMapping.WriteMapping.TryGetValue(value, out var mapping))
+ {
+ return null;
+ }
+
+ return settings.RemoveEnumPrefix ? mapping.RemoveEnumPrefixName : mapping.OriginalName;
+ }
+
+ private static EnumMapping GetEnumMapping(EnumDescriptor enumDescriptor)
+ {
+ return _enumMappings.GetOrAdd(
+ enumDescriptor.ClrType,
+ static (t, descriptor) => GetEnumMapping(descriptor.Name, t),
+ enumDescriptor);
+ }
+
+ private static EnumMapping GetEnumMapping(string enumName, Type enumType)
+ {
+ var nameMappings = enumType.GetTypeInfo().DeclaredFields
+ .Where(f => f.IsStatic)
+ .Where(f => f.GetCustomAttributes().FirstOrDefault()?.PreferredAlias ?? true)
+ .Select(f =>
+ {
+ // If the attribute hasn't been applied, fall back to the name of the field.
+ var fieldName = f.GetCustomAttributes().FirstOrDefault()?.Name ?? f.Name;
+
+ return new NameMapping
+ {
+ Value = f.GetValue(null)!,
+ OriginalName = fieldName,
+ RemoveEnumPrefixName = GetEnumValueName(enumName, fieldName)
+ };
+ })
+ .ToList();
+
+ var writeMapping = nameMappings.ToDictionary(m => m.Value, m => m);
+
+ // Add original names as fallback when mapping enum values with removed prefixes.
+ // There are added to the dictionary first so they are overridden by the mappings with removed prefixes.
+ var removeEnumPrefixMapping = nameMappings.ToDictionary(m => m.OriginalName, m => m.OriginalName);
+
+ // Protobuf codegen prevents collision of enum names when the prefix is removed.
+ // For example, the following enum will fail to build because both fields would resolve to "OK":
+ //
+ // enum Status {
+ // STATUS_OK = 0;
+ // OK = 1;
+ // }
+ //
+ // Tooling error message:
+ // ----------------------
+ // Enum name OK has the same name as STATUS_OK if you ignore case and strip out the enum name prefix (if any).
+ // (If you are using allow_alias, please assign the same number to each enum value name.)
+ //
+ // Just in case it does happen, map to the first value rather than error.
+ foreach (var item in nameMappings.GroupBy(m => m.RemoveEnumPrefixName).Select(g => KeyValuePair.Create(g.Key, g.First().OriginalName)))
+ {
+ removeEnumPrefixMapping[item.Key] = item.Value;
+ }
+
+ return new EnumMapping { WriteMapping = writeMapping, RemoveEnumPrefixMapping = removeEnumPrefixMapping };
+ }
+
+ // Remove the prefix from the specified value. Ignore case and underscores in the comparison.
+ private static string TryRemovePrefix(string prefix, string value)
+ {
+ var normalizedPrefix = prefix.Replace("_", string.Empty, StringComparison.Ordinal).ToLowerInvariant();
+
+ var prefixIndex = 0;
+ var valueIndex = 0;
+
+ while (prefixIndex < normalizedPrefix.Length && valueIndex < value.Length)
+ {
+ if (value[valueIndex] == '_')
+ {
+ valueIndex++;
+ continue;
+ }
+
+ if (char.ToLowerInvariant(value[valueIndex]) != normalizedPrefix[prefixIndex])
+ {
+ return value;
+ }
+
+ prefixIndex++;
+ valueIndex++;
+ }
+
+ if (prefixIndex < normalizedPrefix.Length)
+ {
+ return value;
+ }
+
+ while (valueIndex < value.Length && value[valueIndex] == '_')
+ {
+ valueIndex++;
+ }
+
+ return valueIndex == value.Length ? value : value.Substring(valueIndex);
+ }
+
+ private static string GetEnumValueName(string enumName, string valueName)
+ {
+ var result = TryRemovePrefix(enumName, valueName);
+
+ // Prefix name starting with a digit with an underscore to ensure it is a valid identifier.
+ return result.Length > 0 && char.IsDigit(result[0])
+ ? $"_{result}"
+ : result;
+ }
+
+ private sealed class EnumMapping
+ {
+ public required Dictionary