diff --git a/src/MagicOnion.Abstractions/PublicAPI.Unshipped.txt b/src/MagicOnion.Abstractions/PublicAPI.Unshipped.txt
index 4ebb8c6cf..b39ac3ded 100644
--- a/src/MagicOnion.Abstractions/PublicAPI.Unshipped.txt
+++ b/src/MagicOnion.Abstractions/PublicAPI.Unshipped.txt
@@ -3,3 +3,6 @@ MagicOnion.GenerateDefineDebugAttribute
MagicOnion.GenerateDefineDebugAttribute.GenerateDefineDebugAttribute() -> void
MagicOnion.GenerateIfDirectiveAttribute
MagicOnion.GenerateIfDirectiveAttribute.GenerateIfDirectiveAttribute(string! condition) -> void
+MagicOnion.ServiceNameAttribute
+MagicOnion.ServiceNameAttribute.Name.get -> string!
+MagicOnion.ServiceNameAttribute.ServiceNameAttribute(string! name) -> void
diff --git a/src/MagicOnion.Abstractions/ServiceNameAttribute.cs b/src/MagicOnion.Abstractions/ServiceNameAttribute.cs
new file mode 100644
index 000000000..1a544bc71
--- /dev/null
+++ b/src/MagicOnion.Abstractions/ServiceNameAttribute.cs
@@ -0,0 +1,41 @@
+namespace MagicOnion;
+
+///
+/// Specifies a custom gRPC service name for the MagicOnion service or StreamingHub interface.
+/// When applied, this name is used instead of the default interface type name for gRPC routing.
+/// This allows multiple service interfaces with the same short name but different namespaces
+/// to coexist on the same server.
+///
+///
+/// The attribute must be applied consistently on the shared interface that is referenced
+/// by both client and server. The specified name becomes part of the gRPC method path
+/// (e.g., /Custom.ServiceName/MethodName).
+///
+///
+///
+/// [ServiceName("MyNamespace.IMyService")]
+/// public interface IMyService : IService<IMyService>
+/// {
+/// UnaryResult<string> HelloAsync();
+/// }
+///
+///
+[AttributeUsage(AttributeTargets.Interface, AllowMultiple = false, Inherited = true)]
+public sealed class ServiceNameAttribute : Attribute
+{
+ ///
+ /// Gets the custom service name used for gRPC routing.
+ ///
+ public string Name { get; }
+
+ ///
+ /// Initializes a new instance of with the specified service name.
+ ///
+ /// The custom service name to use for gRPC routing.
+ public ServiceNameAttribute(string name)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ throw new ArgumentException("Service name cannot be null or whitespace.", nameof(name));
+ Name = name;
+ }
+}
diff --git a/src/MagicOnion.Client.SourceGenerator/CodeAnalysis/IMagicOnionServiceInfo.cs b/src/MagicOnion.Client.SourceGenerator/CodeAnalysis/IMagicOnionServiceInfo.cs
index ed17a082d..598b08912 100644
--- a/src/MagicOnion.Client.SourceGenerator/CodeAnalysis/IMagicOnionServiceInfo.cs
+++ b/src/MagicOnion.Client.SourceGenerator/CodeAnalysis/IMagicOnionServiceInfo.cs
@@ -3,4 +3,5 @@ namespace MagicOnion.Client.SourceGenerator.CodeAnalysis;
public interface IMagicOnionServiceInfo
{
MagicOnionTypeInfo ServiceType { get; }
+ string ServiceName { get; }
}
diff --git a/src/MagicOnion.Client.SourceGenerator/CodeAnalysis/MagicOnionServiceInfo.cs b/src/MagicOnion.Client.SourceGenerator/CodeAnalysis/MagicOnionServiceInfo.cs
index 8ec799ce7..17e686edf 100644
--- a/src/MagicOnion.Client.SourceGenerator/CodeAnalysis/MagicOnionServiceInfo.cs
+++ b/src/MagicOnion.Client.SourceGenerator/CodeAnalysis/MagicOnionServiceInfo.cs
@@ -6,11 +6,13 @@ namespace MagicOnion.Client.SourceGenerator.CodeAnalysis;
public class MagicOnionServiceInfo : IMagicOnionServiceInfo
{
public MagicOnionTypeInfo ServiceType { get; }
+ public string ServiceName { get; }
public IReadOnlyList Methods { get; }
- public MagicOnionServiceInfo(MagicOnionTypeInfo serviceType, IReadOnlyList methods)
+ public MagicOnionServiceInfo(MagicOnionTypeInfo serviceType, string serviceName, IReadOnlyList methods)
{
ServiceType = serviceType;
+ ServiceName = serviceName;
Methods = methods;
}
diff --git a/src/MagicOnion.Client.SourceGenerator/CodeAnalysis/MagicOnionStreamingHubInfo.cs b/src/MagicOnion.Client.SourceGenerator/CodeAnalysis/MagicOnionStreamingHubInfo.cs
index 4e09ed00b..14616a138 100644
--- a/src/MagicOnion.Client.SourceGenerator/CodeAnalysis/MagicOnionStreamingHubInfo.cs
+++ b/src/MagicOnion.Client.SourceGenerator/CodeAnalysis/MagicOnionStreamingHubInfo.cs
@@ -6,12 +6,14 @@ namespace MagicOnion.Client.SourceGenerator.CodeAnalysis;
public class MagicOnionStreamingHubInfo : IMagicOnionServiceInfo
{
public MagicOnionTypeInfo ServiceType { get; }
+ public string ServiceName { get; }
public IReadOnlyList Methods { get; }
public MagicOnionStreamingHubReceiverInfo Receiver { get; }
- public MagicOnionStreamingHubInfo(MagicOnionTypeInfo serviceType, IReadOnlyList methods, MagicOnionStreamingHubReceiverInfo receiver)
+ public MagicOnionStreamingHubInfo(MagicOnionTypeInfo serviceType, string serviceName, IReadOnlyList methods, MagicOnionStreamingHubReceiverInfo receiver)
{
ServiceType = serviceType;
+ ServiceName = serviceName;
Methods = methods;
Receiver = receiver;
}
diff --git a/src/MagicOnion.Client.SourceGenerator/CodeAnalysis/MethodCollector.cs b/src/MagicOnion.Client.SourceGenerator/CodeAnalysis/MethodCollector.cs
index 55dac16ee..c691f2050 100644
--- a/src/MagicOnion.Client.SourceGenerator/CodeAnalysis/MethodCollector.cs
+++ b/src/MagicOnion.Client.SourceGenerator/CodeAnalysis/MethodCollector.cs
@@ -87,7 +87,7 @@ static IReadOnlyList GetStreamingHubs(MethodCollecto
var receiver = new MagicOnionStreamingHubInfo.MagicOnionStreamingHubReceiverInfo(receiverType, receiverMethods);
- return new MagicOnionStreamingHubInfo(serviceType, methods, receiver);
+ return new MagicOnionStreamingHubInfo(serviceType, GetServiceNameFromSymbol(x, serviceType), methods, receiver);
})
.Where(x => x is not null)
.Cast()
@@ -111,6 +111,16 @@ static int GetHubMethodIdFromMethodSymbol(IMethodSymbol methodSymbol)
static bool HasIgnoreAttribute(ISymbol symbol)
=> symbol.GetAttributes().FindAttributeShortName("IgnoreAttribute") is not null;
+ static string GetServiceNameFromSymbol(INamedTypeSymbol interfaceSymbol, MagicOnionTypeInfo serviceType)
+ {
+ var serviceNameAttr = interfaceSymbol.GetAttributes().FindAttributeShortName("ServiceNameAttribute");
+ if (serviceNameAttr is not null && serviceNameAttr.ConstructorArguments.Length > 0 && serviceNameAttr.ConstructorArguments[0].Value is string name)
+ {
+ return name;
+ }
+ return serviceType.Name;
+ }
+
static bool TryCreateHubMethodInfoFromMethodSymbol(MethodCollectorContext ctx, MagicOnionTypeInfo interfaceType, IMethodSymbol methodSymbol, [NotNullWhen(true)] out MagicOnionStreamingHubInfo.MagicOnionHubMethodInfo? methodInfo, out Diagnostic? diagnostic)
{
var hubId = GetHubMethodIdFromMethodSymbol(methodSymbol);
@@ -200,12 +210,13 @@ static IReadOnlyList GetServices(MethodCollectorContext c
return null;
}
+ var serviceName = GetServiceNameFromSymbol(x, serviceType);
var methods = new List();
var hasError = false;
foreach (var methodSymbol in x.GetMembers().OfType())
{
if (HasIgnoreAttribute(methodSymbol)) continue;
- if (TryCreateServiceMethodInfoFromMethodSymbol(ctx, serviceType, methodSymbol, out var methodInfo, out var diagnostic))
+ if (TryCreateServiceMethodInfoFromMethodSymbol(ctx, serviceType, serviceName, methodSymbol, out var methodInfo, out var diagnostic))
{
methods.Add(methodInfo);
}
@@ -225,7 +236,7 @@ static IReadOnlyList GetServices(MethodCollectorContext c
return null;
}
- return new MagicOnionServiceInfo(serviceType, methods);
+ return new MagicOnionServiceInfo(serviceType, serviceName, methods);
})
.Where(x => x is not null)
.Cast()
@@ -233,7 +244,7 @@ static IReadOnlyList GetServices(MethodCollectorContext c
.ToArray();
}
- static bool TryCreateServiceMethodInfoFromMethodSymbol(MethodCollectorContext ctx, MagicOnionTypeInfo serviceType, IMethodSymbol methodSymbol, [NotNullWhen(true)] out MagicOnionServiceInfo.MagicOnionServiceMethodInfo? serviceMethodInfo, out Diagnostic? diagnostic)
+ static bool TryCreateServiceMethodInfoFromMethodSymbol(MethodCollectorContext ctx, MagicOnionTypeInfo serviceType, string serviceName, IMethodSymbol methodSymbol, [NotNullWhen(true)] out MagicOnionServiceInfo.MagicOnionServiceMethodInfo? serviceMethodInfo, out Diagnostic? diagnostic)
{
var methodReturnType = ctx.GetOrCreateTypeInfoFromSymbol(methodSymbol.ReturnType);
var methodParameters = CreateParameterInfoListFromMethodSymbol(ctx, methodSymbol);
@@ -306,9 +317,9 @@ static bool TryCreateServiceMethodInfoFromMethodSymbol(MethodCollectorContext ct
diagnostic = null;
serviceMethodInfo = new MagicOnionServiceInfo.MagicOnionServiceMethodInfo(
methodType,
- serviceType.Name,
+ serviceName,
methodSymbol.Name,
- $"{serviceType.Name}/{methodSymbol.Name}",
+ $"{serviceName}/{methodSymbol.Name}",
methodParameters,
methodReturnType,
requestType,
diff --git a/src/MagicOnion.Client.SourceGenerator/CodeGen/StaticStreamingHubClientGenerator.cs b/src/MagicOnion.Client.SourceGenerator/CodeGen/StaticStreamingHubClientGenerator.cs
index 50ce1c13a..b67bd0346 100644
--- a/src/MagicOnion.Client.SourceGenerator/CodeGen/StaticStreamingHubClientGenerator.cs
+++ b/src/MagicOnion.Client.SourceGenerator/CodeGen/StaticStreamingHubClientGenerator.cs
@@ -187,7 +187,7 @@ static void EmitConstructor(StreamingHubClientBuildContext ctx)
{
ctx.Writer.AppendLineWithFormat($$"""
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)
- : base("{{ctx.Hub.ServiceType.Name}}", receiver, callInvoker, options)
+ : base("{{ctx.Hub.ServiceName}}", receiver, callInvoker, options)
{
this.diagnosticHandler = diagnosticHandler;
}
@@ -197,7 +197,7 @@ static void EmitConstructor(StreamingHubClientBuildContext ctx)
{
ctx.Writer.AppendLineWithFormat($$"""
public {{ctx.Hub.GetClientFullName()}}({{ctx.Hub.Receiver.ReceiverType.FullName}} receiver, global::Grpc.Core.CallInvoker callInvoker, global::MagicOnion.Client.StreamingHubClientOptions options)
- : base("{{ctx.Hub.ServiceType.Name}}", receiver, callInvoker, options)
+ : base("{{ctx.Hub.ServiceName}}", receiver, callInvoker, options)
{
}
""");
diff --git a/src/MagicOnion.Client/DynamicClient/DynamicStreamingHubClientBuilder.cs b/src/MagicOnion.Client/DynamicClient/DynamicStreamingHubClientBuilder.cs
index 7803d46e2..da304be00 100644
--- a/src/MagicOnion.Client/DynamicClient/DynamicStreamingHubClientBuilder.cs
+++ b/src/MagicOnion.Client/DynamicClient/DynamicStreamingHubClientBuilder.cs
@@ -153,7 +153,7 @@ static FieldInfo DefineConstructor(TypeBuilder typeBuilder, Type interfaceType,
// base("InterfaceName", receiver, callInvoker, options);
il.Emit(OpCodes.Ldarg_0);
- il.Emit(OpCodes.Ldstr, interfaceType.Name);
+ il.Emit(OpCodes.Ldstr, MagicOnion.Internal.ServiceNameHelper.GetServiceName(interfaceType));
il.Emit(OpCodes.Ldarg_1); // receiver
il.Emit(OpCodes.Ldarg_2); // callInvoker
il.Emit(OpCodes.Ldarg_3); // options
@@ -761,7 +761,7 @@ static MethodInfoCache()
class MethodDefinition
{
- public string Path => ServiceType.Name + "/" + MethodInfo.Name;
+ public string Path => MagicOnion.Internal.ServiceNameHelper.GetServiceName(ServiceType) + "/" + MethodInfo.Name;
public Type ServiceType { get; set; }
public MethodInfo MethodInfo { get; set; }
diff --git a/src/MagicOnion.Client/DynamicClient/ServiceClientDefinition.cs b/src/MagicOnion.Client/DynamicClient/ServiceClientDefinition.cs
index 62565af08..61e38933a 100644
--- a/src/MagicOnion.Client/DynamicClient/ServiceClientDefinition.cs
+++ b/src/MagicOnion.Client/DynamicClient/ServiceClientDefinition.cs
@@ -2,6 +2,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using Grpc.Core;
+using MagicOnion.Internal;
using MessagePack;
namespace MagicOnion.Client.DynamicClient;
@@ -52,12 +53,13 @@ public MagicOnionServiceMethodInfo(MethodType methodType, string serviceName, st
public static MagicOnionServiceMethodInfo Create(Type serviceType, MethodInfo methodInfo)
{
var (methodType, requestType, responseType) = GetMethodTypeAndResponseTypeFromMethod(methodInfo);
+ var resolvedServiceName = ServiceNameHelper.GetServiceName(serviceType);
var method = new MagicOnionServiceMethodInfo(
methodType,
- serviceType.Name,
+ resolvedServiceName,
methodInfo.Name,
- $"{serviceType.Name}/{methodInfo.Name}",
+ $"{resolvedServiceName}/{methodInfo.Name}",
methodInfo.GetParameters().Select(y => y.ParameterType).ToArray(),
methodInfo.ReturnType,
requestType ?? GetRequestTypeFromMethod(methodInfo),
diff --git a/src/MagicOnion.Internal/ServiceNameHelper.cs b/src/MagicOnion.Internal/ServiceNameHelper.cs
new file mode 100644
index 000000000..149c87ed2
--- /dev/null
+++ b/src/MagicOnion.Internal/ServiceNameHelper.cs
@@ -0,0 +1,24 @@
+using System.Reflection;
+
+namespace MagicOnion.Internal;
+
+internal static class ServiceNameHelper
+{
+ ///
+ /// Resolves the gRPC service name for a given service interface type.
+ /// If the interface has a , its value is used.
+ /// Otherwise, the short type name () is used as the default.
+ ///
+ /// The service interface type (e.g., IMyService).
+ /// The resolved service name string.
+ public static string GetServiceName(Type serviceInterfaceType)
+ {
+ var attr = serviceInterfaceType.GetCustomAttribute();
+ if (attr is not null)
+ {
+ return attr.Name;
+ }
+
+ return serviceInterfaceType.Name;
+ }
+}
diff --git a/src/MagicOnion.Server/Binder/Internal/DynamicMagicOnionMethodProvider.cs b/src/MagicOnion.Server/Binder/Internal/DynamicMagicOnionMethodProvider.cs
index 0d3099420..9ae933619 100644
--- a/src/MagicOnion.Server/Binder/Internal/DynamicMagicOnionMethodProvider.cs
+++ b/src/MagicOnion.Server/Binder/Internal/DynamicMagicOnionMethodProvider.cs
@@ -24,10 +24,12 @@ public IReadOnlyList GetGrpcMethods() where TSe
.First(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IService<>))
.GenericTypeArguments[0];
+ var serviceName = ServiceNameHelper.GetServiceName(typeServiceInterface);
+
// StreamingHub
if (typeof(TService).IsAssignableTo(typeof(IStreamingHubBase)))
{
- return [new MagicOnionStreamingHubConnectMethod(typeServiceInterface.Name)];
+ return [new MagicOnionStreamingHubConnectMethod(serviceName)];
}
// Unary, ClientStreaming, ServerStreaming, DuplexStreaming
@@ -144,7 +146,7 @@ public IReadOnlyList GetGrpcMethods() where TSe
try
{
- var serviceMethod = Activator.CreateInstance(typeMethod.MakeGenericType(typeMethodTypeArgs), [typeServiceInterface.Name, targetMethod.Name, invoker])!;
+ var serviceMethod = Activator.CreateInstance(typeMethod.MakeGenericType(typeMethodTypeArgs), [serviceName, targetMethod.Name, invoker])!;
methods.Add((IMagicOnionGrpcMethod)serviceMethod);
}
catch (TargetInvocationException e)
@@ -169,6 +171,8 @@ public IReadOnlyList GetStreamingHubMethods x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IService<>))
.GenericTypeArguments[0];
+ var serviceName = ServiceNameHelper.GetServiceName(typeServiceInterface);
+
var interfaceMap = typeServiceImplementation.GetInterfaceMapWithParents(typeServiceInterface);
for (var i = 0; i < interfaceMap.TargetMethods.Length; i++)
{
@@ -216,7 +220,7 @@ public IReadOnlyList GetStreamingHubMethods
readonly string toStringCache;
readonly int getHashCodeCache;
- public string HubName => hubMethod.Metadata.StreamingHubInterfaceType.Name;
+ public string HubName => hubMethod.ServiceName;
public Type HubType => hubMethod.Metadata.StreamingHubImplementationType;
public MethodInfo MethodInfo => hubMethod.Metadata.ImplementationMethod;
public int MethodId => hubMethod.Metadata.MethodId;
diff --git a/tests/MagicOnion.Client.SourceGenerator.Tests/Collector/MethodCollectorServiceNameTest.cs b/tests/MagicOnion.Client.SourceGenerator.Tests/Collector/MethodCollectorServiceNameTest.cs
new file mode 100644
index 000000000..bb0bb2f0e
--- /dev/null
+++ b/tests/MagicOnion.Client.SourceGenerator.Tests/Collector/MethodCollectorServiceNameTest.cs
@@ -0,0 +1,183 @@
+using MagicOnion.Client.SourceGenerator.CodeAnalysis;
+using Microsoft.CodeAnalysis;
+using System.Collections.Immutable;
+
+namespace MagicOnion.Client.SourceGenerator.Tests.Collector;
+
+public class MethodCollectorServiceNameTest
+{
+ [Fact]
+ public void Service_WithServiceNameAttribute_UsesCustomName()
+ {
+ // Arrange
+ var source = @"
+using System;
+using System.Threading.Tasks;
+using MagicOnion;
+using MessagePack;
+
+namespace AreaA
+{
+ [ServiceName(""AreaA.IMyService"")]
+ public interface IMyService : IService
+ {
+ UnaryResult MethodA();
+ }
+}
+
+namespace AreaB
+{
+ [ServiceName(""AreaB.IMyService"")]
+ public interface IMyService : IService
+ {
+ UnaryResult MethodA();
+ }
+}
+";
+ var (compilation, semModel) = CompilationHelper.Create(source);
+ if (!ReferenceSymbols.TryCreate(compilation, out var referenceSymbols)) throw new InvalidOperationException("Cannot create the reference symbols.");
+ var interfaceSymbols = MethodCollectorTestHelper.Traverse(compilation.Assembly.GlobalNamespace).ToImmutableArray();
+
+ // Act
+ var (serviceCollection, diagnostics) = MethodCollector.Collect(interfaceSymbols, referenceSymbols, CancellationToken.None);
+
+ // Assert
+ Assert.DoesNotContain(compilation.GetDiagnostics(TestContext.Current.CancellationToken), x => x.Severity == DiagnosticSeverity.Error);
+ Assert.NotNull(serviceCollection);
+ Assert.Equal(2, serviceCollection.Services.Count);
+
+ var serviceA = serviceCollection.Services.First(s => s.ServiceType.Namespace == "AreaA");
+ var serviceB = serviceCollection.Services.First(s => s.ServiceType.Namespace == "AreaB");
+
+ Assert.Equal("AreaA.IMyService", serviceA.Methods[0].ServiceName);
+ Assert.Equal("AreaA.IMyService/MethodA", serviceA.Methods[0].Path);
+
+ Assert.Equal("AreaB.IMyService", serviceB.Methods[0].ServiceName);
+ Assert.Equal("AreaB.IMyService/MethodA", serviceB.Methods[0].Path);
+
+ Assert.NotEqual(serviceA.Methods[0].ServiceName, serviceB.Methods[0].ServiceName);
+ }
+
+ [Fact]
+ public void Service_WithoutServiceNameAttribute_UsesShortName()
+ {
+ // Arrange
+ var source = @"
+using System;
+using System.Threading.Tasks;
+using MagicOnion;
+using MessagePack;
+
+namespace MyNamespace
+{
+ public interface IMyService : IService
+ {
+ UnaryResult MethodA();
+ }
+}
+";
+ var (compilation, semModel) = CompilationHelper.Create(source);
+ if (!ReferenceSymbols.TryCreate(compilation, out var referenceSymbols)) throw new InvalidOperationException("Cannot create the reference symbols.");
+ var interfaceSymbols = MethodCollectorTestHelper.Traverse(compilation.Assembly.GlobalNamespace).ToImmutableArray();
+
+ // Act
+ var (serviceCollection, diagnostics) = MethodCollector.Collect(interfaceSymbols, referenceSymbols, CancellationToken.None);
+
+ // Assert
+ Assert.DoesNotContain(compilation.GetDiagnostics(TestContext.Current.CancellationToken), x => x.Severity == DiagnosticSeverity.Error);
+ Assert.Single(serviceCollection.Services);
+ Assert.Equal("IMyService", serviceCollection.Services[0].Methods[0].ServiceName);
+ Assert.Equal("IMyService/MethodA", serviceCollection.Services[0].Methods[0].Path);
+ }
+
+ [Fact]
+ public void StreamingHub_WithServiceNameAttribute_UsesCustomName()
+ {
+ // Arrange
+ var source = @"
+using System;
+using System.Threading.Tasks;
+using MagicOnion;
+using MessagePack;
+
+namespace AreaA
+{
+ [ServiceName(""AreaA.IChatHub"")]
+ public interface IChatHub : IStreamingHub
+ {
+ ValueTask SendAsync(string message);
+ }
+ public interface IChatHubReceiver
+ {
+ void OnReceive(string message);
+ }
+}
+
+namespace AreaB
+{
+ [ServiceName(""AreaB.IChatHub"")]
+ public interface IChatHub : IStreamingHub
+ {
+ ValueTask SendAsync(string message);
+ }
+ public interface IChatHubReceiver
+ {
+ void OnReceive(string message);
+ }
+}
+";
+ var (compilation, semModel) = CompilationHelper.Create(source);
+ if (!ReferenceSymbols.TryCreate(compilation, out var referenceSymbols)) throw new InvalidOperationException("Cannot create the reference symbols.");
+ var interfaceSymbols = MethodCollectorTestHelper.Traverse(compilation.Assembly.GlobalNamespace).ToImmutableArray();
+
+ // Act
+ var (serviceCollection, diagnostics) = MethodCollector.Collect(interfaceSymbols, referenceSymbols, CancellationToken.None);
+
+ // Assert
+ Assert.DoesNotContain(compilation.GetDiagnostics(TestContext.Current.CancellationToken), x => x.Severity == DiagnosticSeverity.Error);
+ Assert.NotNull(serviceCollection);
+ Assert.Equal(2, serviceCollection.Hubs.Count);
+
+ var hubA = serviceCollection.Hubs.First(h => h.ServiceType.Namespace == "AreaA");
+ var hubB = serviceCollection.Hubs.First(h => h.ServiceType.Namespace == "AreaB");
+
+ Assert.Equal("AreaA.IChatHub", hubA.ServiceName);
+ Assert.Equal("AreaB.IChatHub", hubB.ServiceName);
+ Assert.NotEqual(hubA.ServiceName, hubB.ServiceName);
+ }
+
+ [Fact]
+ public void StreamingHub_WithoutServiceNameAttribute_UsesShortName()
+ {
+ // Arrange
+ var source = @"
+using System;
+using System.Threading.Tasks;
+using MagicOnion;
+using MessagePack;
+
+namespace MyNamespace
+{
+ public interface IChatHub : IStreamingHub
+ {
+ ValueTask SendAsync(string message);
+ }
+ public interface IChatHubReceiver
+ {
+ void OnReceive(string message);
+ }
+}
+";
+ var (compilation, semModel) = CompilationHelper.Create(source);
+ if (!ReferenceSymbols.TryCreate(compilation, out var referenceSymbols)) throw new InvalidOperationException("Cannot create the reference symbols.");
+ var interfaceSymbols = MethodCollectorTestHelper.Traverse(compilation.Assembly.GlobalNamespace).ToImmutableArray();
+
+ // Act
+ var (serviceCollection, diagnostics) = MethodCollector.Collect(interfaceSymbols, referenceSymbols, CancellationToken.None);
+
+ // Assert
+ Assert.DoesNotContain(compilation.GetDiagnostics(TestContext.Current.CancellationToken), x => x.Severity == DiagnosticSeverity.Error);
+ Assert.Single(serviceCollection.Hubs);
+ Assert.Equal("IChatHub", serviceCollection.Hubs[0].ServiceName);
+ }
+}
diff --git a/tests/MagicOnion.Client.Tests/DynamicClient/SameInterfaceNameTest.cs b/tests/MagicOnion.Client.Tests/DynamicClient/SameInterfaceNameTest.cs
index 49a9a3f0d..54583e91e 100644
--- a/tests/MagicOnion.Client.Tests/DynamicClient/SameInterfaceNameTest.cs
+++ b/tests/MagicOnion.Client.Tests/DynamicClient/SameInterfaceNameTest.cs
@@ -1,4 +1,5 @@
using MagicOnion.Client.DynamicClient;
+using MagicOnion.Internal;
using MagicOnion.Serialization;
namespace MagicOnion.Client.Tests.DynamicClient
@@ -28,6 +29,38 @@ public void DynamicStreamingHubClientFactoryProvider_TryGetFactory()
Assert.NotNull(factoryB);
var clientB = factoryB(receiverB, callInvoker, new ("", default, MagicOnionSerializerProvider.Default, NullMagicOnionClientLogger.Instance));
}
+
+ [Fact]
+ public void ServiceClientDefinition_WithServiceNameAttribute_UsesDifferentPaths()
+ {
+ var defA = ServiceClientDefinition.CreateFromType();
+ var defB = ServiceClientDefinition.CreateFromType();
+
+ var methodA = defA.Methods.First();
+ var methodB = defB.Methods.First();
+
+ // Service names should use the [ServiceName] attribute values
+ Assert.Equal("AreaA.IAttributedFoo", methodA.ServiceName);
+ Assert.Equal("AreaB.IAttributedFoo", methodB.ServiceName);
+
+ // Paths should include the distinct service name
+ Assert.Equal("AreaA.IAttributedFoo/DoAsync", methodA.Path);
+ Assert.Equal("AreaB.IAttributedFoo/DoAsync", methodB.Path);
+
+ Assert.NotEqual(methodA.ServiceName, methodB.ServiceName);
+ Assert.NotEqual(methodA.Path, methodB.Path);
+ }
+
+ [Fact]
+ public void ServiceClientDefinition_WithoutServiceNameAttribute_UsesSameShortName()
+ {
+ var defA = ServiceClientDefinition.CreateFromType();
+ var defB = ServiceClientDefinition.CreateFromType();
+
+ // Without [ServiceName], both use the same short type name
+ Assert.Equal("IFoo", defA.Methods.FirstOrDefault()?.ServiceName ?? ServiceNameHelper.GetServiceName(typeof(AreaA.IFoo)));
+ Assert.Equal("IFoo", defB.Methods.FirstOrDefault()?.ServiceName ?? ServiceNameHelper.GetServiceName(typeof(AreaB.IFoo)));
+ }
}
}
@@ -39,6 +72,12 @@ public interface IBazHub : IStreamingHub
{}
public interface IBazHubReceiver
{}
+
+ [ServiceName("AreaA.IAttributedFoo")]
+ public interface IAttributedFoo : IService
+ {
+ UnaryResult DoAsync();
+ }
}
namespace MagicOnion.Client.Tests.DynamicClient.AreaB
@@ -49,4 +88,10 @@ public interface IBazHub : IStreamingHub
{}
public interface IBazHubReceiver
{}
+
+ [ServiceName("AreaB.IAttributedFoo")]
+ public interface IAttributedFoo : IService
+ {
+ UnaryResult DoAsync();
+ }
}
diff --git a/tests/MagicOnion.Client.Tests/ServiceNameHelperTest.cs b/tests/MagicOnion.Client.Tests/ServiceNameHelperTest.cs
new file mode 100644
index 000000000..b7c16fddf
--- /dev/null
+++ b/tests/MagicOnion.Client.Tests/ServiceNameHelperTest.cs
@@ -0,0 +1,60 @@
+using MagicOnion.Internal;
+
+namespace MagicOnion.Client.Tests
+{
+ public class ServiceNameHelperTest
+ {
+ [Fact]
+ public void NoAttribute_ReturnsTypeName()
+ {
+ var result = ServiceNameHelper.GetServiceName(typeof(INoAttributeService));
+ Assert.Equal("INoAttributeService", result);
+ }
+
+ [Fact]
+ public void WithAttribute_ReturnsAttributeName()
+ {
+ var result = ServiceNameHelper.GetServiceName(typeof(IAttributedService));
+ Assert.Equal("Custom.ServiceName", result);
+ }
+
+ [Fact]
+ public void SameShortName_DifferentNamespaces_DistinguishedByAttribute()
+ {
+ var resultA = ServiceNameHelper.GetServiceName(typeof(ServiceNameHelperAreaA.IProfileAccess));
+ var resultB = ServiceNameHelper.GetServiceName(typeof(ServiceNameHelperAreaB.IProfileAccess));
+ Assert.Equal("ServiceNameHelperAreaA.IProfileAccess", resultA);
+ Assert.Equal("ServiceNameHelperAreaB.IProfileAccess", resultB);
+ Assert.NotEqual(resultA, resultB);
+ }
+ }
+
+ public interface INoAttributeService : IService
+ {
+ UnaryResult HelloAsync();
+ }
+
+ [ServiceName("Custom.ServiceName")]
+ public interface IAttributedService : IService
+ {
+ UnaryResult HelloAsync();
+ }
+}
+
+namespace ServiceNameHelperAreaA
+{
+ [MagicOnion.ServiceName("ServiceNameHelperAreaA.IProfileAccess")]
+ public interface IProfileAccess : MagicOnion.IService
+ {
+ MagicOnion.UnaryResult GetProfileAsync();
+ }
+}
+
+namespace ServiceNameHelperAreaB
+{
+ [MagicOnion.ServiceName("ServiceNameHelperAreaB.IProfileAccess")]
+ public interface IProfileAccess : MagicOnion.IService
+ {
+ MagicOnion.UnaryResult GetProfileAsync();
+ }
+}
diff --git a/tests/MagicOnion.Integration.Tests/SameServiceNameRoutingTest.cs b/tests/MagicOnion.Integration.Tests/SameServiceNameRoutingTest.cs
new file mode 100644
index 000000000..53ed61633
--- /dev/null
+++ b/tests/MagicOnion.Integration.Tests/SameServiceNameRoutingTest.cs
@@ -0,0 +1,97 @@
+using Grpc.Net.Client;
+using MagicOnion.Client;
+using MagicOnion.Client.DynamicClient;
+using MagicOnion.Server;
+
+namespace MagicOnion.Integration.Tests
+{
+ public class SameServiceNameRoutingTest : IClassFixture
+ {
+ readonly SameNameServiceFactory factory;
+
+ public SameServiceNameRoutingTest(SameNameServiceFactory factory)
+ {
+ this.factory = factory;
+ }
+
+ public static IEnumerable