Skip to content

Commit 1c61dd2

Browse files
feat: add [ServiceName] attribute
Closes #1029 Allow service interfaces with the same short name in different namespaces to coexist on the same server by specifying a custom gRPC service name. - Add ServiceNameAttribute to MagicOnion.Abstractions for overriding the default interface-name-based gRPC routing path - Add ServiceNameHelper to resolve the service name from the attribute at runtime (dynamic client and server) - Update MethodCollector to resolve the service name from the attribute at source-generation time (static client) - Add ServiceName property to IMagicOnionServiceInfo interface - Validate against null/whitespace in ServiceNameAttribute constructor - Add unit, server, and integration tests for both unary and hub scenarios
1 parent 8f576c2 commit 1c61dd2

File tree

17 files changed

+650
-18
lines changed

17 files changed

+650
-18
lines changed

src/MagicOnion.Abstractions/PublicAPI.Unshipped.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@ MagicOnion.GenerateDefineDebugAttribute
33
MagicOnion.GenerateDefineDebugAttribute.GenerateDefineDebugAttribute() -> void
44
MagicOnion.GenerateIfDirectiveAttribute
55
MagicOnion.GenerateIfDirectiveAttribute.GenerateIfDirectiveAttribute(string! condition) -> void
6+
MagicOnion.ServiceNameAttribute
7+
MagicOnion.ServiceNameAttribute.Name.get -> string!
8+
MagicOnion.ServiceNameAttribute.ServiceNameAttribute(string! name) -> void
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
namespace MagicOnion;
2+
3+
/// <summary>
4+
/// Specifies a custom gRPC service name for the MagicOnion service or StreamingHub interface.
5+
/// When applied, this name is used instead of the default interface type name for gRPC routing.
6+
/// This allows multiple service interfaces with the same short name but different namespaces
7+
/// to coexist on the same server.
8+
/// </summary>
9+
/// <remarks>
10+
/// The attribute must be applied consistently on the shared interface that is referenced
11+
/// by both client and server. The specified name becomes part of the gRPC method path
12+
/// (e.g., <c>/Custom.ServiceName/MethodName</c>).
13+
/// </remarks>
14+
/// <example>
15+
/// <code>
16+
/// [ServiceName("MyNamespace.IMyService")]
17+
/// public interface IMyService : IService&lt;IMyService&gt;
18+
/// {
19+
/// UnaryResult&lt;string&gt; HelloAsync();
20+
/// }
21+
/// </code>
22+
/// </example>
23+
[AttributeUsage(AttributeTargets.Interface, AllowMultiple = false, Inherited = true)]
24+
public sealed class ServiceNameAttribute : Attribute
25+
{
26+
/// <summary>
27+
/// Gets the custom service name used for gRPC routing.
28+
/// </summary>
29+
public string Name { get; }
30+
31+
/// <summary>
32+
/// Initializes a new instance of <see cref="ServiceNameAttribute"/> with the specified service name.
33+
/// </summary>
34+
/// <param name="name">The custom service name to use for gRPC routing.</param>
35+
public ServiceNameAttribute(string name)
36+
{
37+
if (string.IsNullOrWhiteSpace(name))
38+
throw new ArgumentException("Service name cannot be null or whitespace.", nameof(name));
39+
Name = name;
40+
}
41+
}

src/MagicOnion.Client.SourceGenerator/CodeAnalysis/IMagicOnionServiceInfo.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ namespace MagicOnion.Client.SourceGenerator.CodeAnalysis;
33
public interface IMagicOnionServiceInfo
44
{
55
MagicOnionTypeInfo ServiceType { get; }
6+
string ServiceName { get; }
67
}

src/MagicOnion.Client.SourceGenerator/CodeAnalysis/MagicOnionServiceInfo.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ namespace MagicOnion.Client.SourceGenerator.CodeAnalysis;
66
public class MagicOnionServiceInfo : IMagicOnionServiceInfo
77
{
88
public MagicOnionTypeInfo ServiceType { get; }
9+
public string ServiceName { get; }
910
public IReadOnlyList<MagicOnionServiceMethodInfo> Methods { get; }
1011

11-
public MagicOnionServiceInfo(MagicOnionTypeInfo serviceType, IReadOnlyList<MagicOnionServiceMethodInfo> methods)
12+
public MagicOnionServiceInfo(MagicOnionTypeInfo serviceType, string serviceName, IReadOnlyList<MagicOnionServiceMethodInfo> methods)
1213
{
1314
ServiceType = serviceType;
15+
ServiceName = serviceName;
1416
Methods = methods;
1517
}
1618

src/MagicOnion.Client.SourceGenerator/CodeAnalysis/MagicOnionStreamingHubInfo.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ namespace MagicOnion.Client.SourceGenerator.CodeAnalysis;
66
public class MagicOnionStreamingHubInfo : IMagicOnionServiceInfo
77
{
88
public MagicOnionTypeInfo ServiceType { get; }
9+
public string ServiceName { get; }
910
public IReadOnlyList<MagicOnionHubMethodInfo> Methods { get; }
1011
public MagicOnionStreamingHubReceiverInfo Receiver { get; }
1112

12-
public MagicOnionStreamingHubInfo(MagicOnionTypeInfo serviceType, IReadOnlyList<MagicOnionHubMethodInfo> methods, MagicOnionStreamingHubReceiverInfo receiver)
13+
public MagicOnionStreamingHubInfo(MagicOnionTypeInfo serviceType, string serviceName, IReadOnlyList<MagicOnionHubMethodInfo> methods, MagicOnionStreamingHubReceiverInfo receiver)
1314
{
1415
ServiceType = serviceType;
16+
ServiceName = serviceName;
1517
Methods = methods;
1618
Receiver = receiver;
1719
}

src/MagicOnion.Client.SourceGenerator/CodeAnalysis/MethodCollector.cs

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ static IReadOnlyList<MagicOnionStreamingHubInfo> GetStreamingHubs(MethodCollecto
8787

8888
var receiver = new MagicOnionStreamingHubInfo.MagicOnionStreamingHubReceiverInfo(receiverType, receiverMethods);
8989

90-
return new MagicOnionStreamingHubInfo(serviceType, methods, receiver);
90+
return new MagicOnionStreamingHubInfo(serviceType, GetServiceNameFromSymbol(x, serviceType), methods, receiver);
9191
})
9292
.Where(x => x is not null)
9393
.Cast<MagicOnionStreamingHubInfo>()
@@ -111,6 +111,16 @@ static int GetHubMethodIdFromMethodSymbol(IMethodSymbol methodSymbol)
111111
static bool HasIgnoreAttribute(ISymbol symbol)
112112
=> symbol.GetAttributes().FindAttributeShortName("IgnoreAttribute") is not null;
113113

114+
static string GetServiceNameFromSymbol(INamedTypeSymbol interfaceSymbol, MagicOnionTypeInfo serviceType)
115+
{
116+
var serviceNameAttr = interfaceSymbol.GetAttributes().FindAttributeShortName("ServiceNameAttribute");
117+
if (serviceNameAttr is not null && serviceNameAttr.ConstructorArguments.Length > 0 && serviceNameAttr.ConstructorArguments[0].Value is string name)
118+
{
119+
return name;
120+
}
121+
return serviceType.Name;
122+
}
123+
114124
static bool TryCreateHubMethodInfoFromMethodSymbol(MethodCollectorContext ctx, MagicOnionTypeInfo interfaceType, IMethodSymbol methodSymbol, [NotNullWhen(true)] out MagicOnionStreamingHubInfo.MagicOnionHubMethodInfo? methodInfo, out Diagnostic? diagnostic)
115125
{
116126
var hubId = GetHubMethodIdFromMethodSymbol(methodSymbol);
@@ -200,12 +210,13 @@ static IReadOnlyList<MagicOnionServiceInfo> GetServices(MethodCollectorContext c
200210
return null;
201211
}
202212

213+
var serviceName = GetServiceNameFromSymbol(x, serviceType);
203214
var methods = new List<MagicOnionServiceInfo.MagicOnionServiceMethodInfo>();
204215
var hasError = false;
205216
foreach (var methodSymbol in x.GetMembers().OfType<IMethodSymbol>())
206217
{
207218
if (HasIgnoreAttribute(methodSymbol)) continue;
208-
if (TryCreateServiceMethodInfoFromMethodSymbol(ctx, serviceType, methodSymbol, out var methodInfo, out var diagnostic))
219+
if (TryCreateServiceMethodInfoFromMethodSymbol(ctx, serviceType, serviceName, methodSymbol, out var methodInfo, out var diagnostic))
209220
{
210221
methods.Add(methodInfo);
211222
}
@@ -225,15 +236,15 @@ static IReadOnlyList<MagicOnionServiceInfo> GetServices(MethodCollectorContext c
225236
return null;
226237
}
227238

228-
return new MagicOnionServiceInfo(serviceType, methods);
239+
return new MagicOnionServiceInfo(serviceType, serviceName, methods);
229240
})
230241
.Where(x => x is not null)
231242
.Cast<MagicOnionServiceInfo>()
232243
.OrderBy(x => x.ServiceType.FullName)
233244
.ToArray();
234245
}
235246

236-
static bool TryCreateServiceMethodInfoFromMethodSymbol(MethodCollectorContext ctx, MagicOnionTypeInfo serviceType, IMethodSymbol methodSymbol, [NotNullWhen(true)] out MagicOnionServiceInfo.MagicOnionServiceMethodInfo? serviceMethodInfo, out Diagnostic? diagnostic)
247+
static bool TryCreateServiceMethodInfoFromMethodSymbol(MethodCollectorContext ctx, MagicOnionTypeInfo serviceType, string serviceName, IMethodSymbol methodSymbol, [NotNullWhen(true)] out MagicOnionServiceInfo.MagicOnionServiceMethodInfo? serviceMethodInfo, out Diagnostic? diagnostic)
237248
{
238249
var methodReturnType = ctx.GetOrCreateTypeInfoFromSymbol(methodSymbol.ReturnType);
239250
var methodParameters = CreateParameterInfoListFromMethodSymbol(ctx, methodSymbol);
@@ -306,9 +317,9 @@ static bool TryCreateServiceMethodInfoFromMethodSymbol(MethodCollectorContext ct
306317
diagnostic = null;
307318
serviceMethodInfo = new MagicOnionServiceInfo.MagicOnionServiceMethodInfo(
308319
methodType,
309-
serviceType.Name,
320+
serviceName,
310321
methodSymbol.Name,
311-
$"{serviceType.Name}/{methodSymbol.Name}",
322+
$"{serviceName}/{methodSymbol.Name}",
312323
methodParameters,
313324
methodReturnType,
314325
requestType,

src/MagicOnion.Client.SourceGenerator/CodeGen/StaticStreamingHubClientGenerator.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ static void EmitConstructor(StreamingHubClientBuildContext ctx)
187187
{
188188
ctx.Writer.AppendLineWithFormat($$"""
189189
public {{ctx.Hub.GetClientFullName()}}({{ctx.Hub.Receiver.ReceiverType.FullName}} receiver, global::Grpc.Core.CallInvoker callInvoker, global::MagicOnion.Client.StreamingHubClientOptions options, global::MagicOnion.Client.IStreamingHubDiagnosticHandler diagnosticHandler)
190-
: base("{{ctx.Hub.ServiceType.Name}}", receiver, callInvoker, options)
190+
: base("{{ctx.Hub.ServiceName}}", receiver, callInvoker, options)
191191
{
192192
this.diagnosticHandler = diagnosticHandler;
193193
}
@@ -197,7 +197,7 @@ static void EmitConstructor(StreamingHubClientBuildContext ctx)
197197
{
198198
ctx.Writer.AppendLineWithFormat($$"""
199199
public {{ctx.Hub.GetClientFullName()}}({{ctx.Hub.Receiver.ReceiverType.FullName}} receiver, global::Grpc.Core.CallInvoker callInvoker, global::MagicOnion.Client.StreamingHubClientOptions options)
200-
: base("{{ctx.Hub.ServiceType.Name}}", receiver, callInvoker, options)
200+
: base("{{ctx.Hub.ServiceName}}", receiver, callInvoker, options)
201201
{
202202
}
203203
""");

src/MagicOnion.Client/DynamicClient/DynamicStreamingHubClientBuilder.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ static FieldInfo DefineConstructor(TypeBuilder typeBuilder, Type interfaceType,
153153

154154
// base("InterfaceName", receiver, callInvoker, options);
155155
il.Emit(OpCodes.Ldarg_0);
156-
il.Emit(OpCodes.Ldstr, interfaceType.Name);
156+
il.Emit(OpCodes.Ldstr, MagicOnion.Internal.ServiceNameHelper.GetServiceName(interfaceType));
157157
il.Emit(OpCodes.Ldarg_1); // receiver
158158
il.Emit(OpCodes.Ldarg_2); // callInvoker
159159
il.Emit(OpCodes.Ldarg_3); // options
@@ -761,7 +761,7 @@ static MethodInfoCache()
761761

762762
class MethodDefinition
763763
{
764-
public string Path => ServiceType.Name + "/" + MethodInfo.Name;
764+
public string Path => MagicOnion.Internal.ServiceNameHelper.GetServiceName(ServiceType) + "/" + MethodInfo.Name;
765765

766766
public Type ServiceType { get; set; }
767767
public MethodInfo MethodInfo { get; set; }

src/MagicOnion.Client/DynamicClient/ServiceClientDefinition.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Diagnostics.CodeAnalysis;
33
using System.Reflection;
44
using Grpc.Core;
5+
using MagicOnion.Internal;
56
using MessagePack;
67

78
namespace MagicOnion.Client.DynamicClient;
@@ -52,12 +53,13 @@ public MagicOnionServiceMethodInfo(MethodType methodType, string serviceName, st
5253
public static MagicOnionServiceMethodInfo Create(Type serviceType, MethodInfo methodInfo)
5354
{
5455
var (methodType, requestType, responseType) = GetMethodTypeAndResponseTypeFromMethod(methodInfo);
56+
var resolvedServiceName = ServiceNameHelper.GetServiceName(serviceType);
5557

5658
var method = new MagicOnionServiceMethodInfo(
5759
methodType,
58-
serviceType.Name,
60+
resolvedServiceName,
5961
methodInfo.Name,
60-
$"{serviceType.Name}/{methodInfo.Name}",
62+
$"{resolvedServiceName}/{methodInfo.Name}",
6163
methodInfo.GetParameters().Select(y => y.ParameterType).ToArray(),
6264
methodInfo.ReturnType,
6365
requestType ?? GetRequestTypeFromMethod(methodInfo),
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using System.Reflection;
2+
3+
namespace MagicOnion.Internal;
4+
5+
internal static class ServiceNameHelper
6+
{
7+
/// <summary>
8+
/// Resolves the gRPC service name for a given service interface type.
9+
/// If the interface has a <see cref="ServiceNameAttribute"/>, its value is used.
10+
/// Otherwise, the short type name (<see cref="Type.Name"/>) is used as the default.
11+
/// </summary>
12+
/// <param name="serviceInterfaceType">The service interface type (e.g., IMyService).</param>
13+
/// <returns>The resolved service name string.</returns>
14+
public static string GetServiceName(Type serviceInterfaceType)
15+
{
16+
var attr = serviceInterfaceType.GetCustomAttribute<ServiceNameAttribute>();
17+
if (attr is not null)
18+
{
19+
return attr.Name;
20+
}
21+
22+
return serviceInterfaceType.Name;
23+
}
24+
}

0 commit comments

Comments
 (0)