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 EnumerateMagicOnionClientFactory() + { + yield return [new TestMagicOnionClientFactory("Dynamic", DynamicMagicOnionClientFactoryProvider.Instance)]; + } + + [Theory] + [MemberData(nameof(EnumerateMagicOnionClientFactory))] + public async Task AreaA_Service_RoutesCorrectly(TestMagicOnionClientFactory clientFactory) + { + var channel = GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions() { HttpClient = factory.CreateDefaultClient() }); + var client = clientFactory.Create(channel); + var result = await client.GetProfileAsync(); + Assert.Equal("AreaA-Profile", result); + } + + [Theory] + [MemberData(nameof(EnumerateMagicOnionClientFactory))] + public async Task AreaB_Service_RoutesCorrectly(TestMagicOnionClientFactory clientFactory) + { + var channel = GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions() { HttpClient = factory.CreateDefaultClient() }); + var client = clientFactory.Create(channel); + var result = await client.GetProfileAsync(); + Assert.Equal("AreaB-Profile", result); + } + + [Theory] + [MemberData(nameof(EnumerateMagicOnionClientFactory))] + public async Task Both_Services_ReturnDifferentResults(TestMagicOnionClientFactory clientFactory) + { + var channel = GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions() { HttpClient = factory.CreateDefaultClient() }); + var clientA = clientFactory.Create(channel); + var clientB = clientFactory.Create(channel); + + var resultA = await clientA.GetProfileAsync(); + var resultB = await clientB.GetProfileAsync(); + + Assert.Equal("AreaA-Profile", resultA); + Assert.Equal("AreaB-Profile", resultB); + Assert.NotEqual(resultA, resultB); + } + + public class SameNameServiceFactory : MagicOnionApplicationFactory + { + protected override IEnumerable GetServiceImplementationTypes() + { + yield return typeof(SameNameRouting.AreaA.ProfileAccessService); + yield return typeof(SameNameRouting.AreaB.ProfileAccessService); + } + } + } +} + +namespace SameNameRouting.AreaA +{ + [MagicOnion.ServiceName("SameNameRouting.AreaA.IProfileAccess")] + public interface IProfileAccess : MagicOnion.IService + { + MagicOnion.UnaryResult GetProfileAsync(); + } + + public class ProfileAccessService : MagicOnion.Server.ServiceBase, IProfileAccess + { + public MagicOnion.UnaryResult GetProfileAsync() + => MagicOnion.UnaryResult.FromResult("AreaA-Profile"); + } +} + +namespace SameNameRouting.AreaB +{ + [MagicOnion.ServiceName("SameNameRouting.AreaB.IProfileAccess")] + public interface IProfileAccess : MagicOnion.IService + { + MagicOnion.UnaryResult GetProfileAsync(); + } + + public class ProfileAccessService : MagicOnion.Server.ServiceBase, IProfileAccess + { + public MagicOnion.UnaryResult GetProfileAsync() + => MagicOnion.UnaryResult.FromResult("AreaB-Profile"); + } +} diff --git a/tests/MagicOnion.Server.Tests/ServiceNameAttributeTest.cs b/tests/MagicOnion.Server.Tests/ServiceNameAttributeTest.cs new file mode 100644 index 000000000..db0ab2877 --- /dev/null +++ b/tests/MagicOnion.Server.Tests/ServiceNameAttributeTest.cs @@ -0,0 +1,157 @@ +using MagicOnion.Server.Hubs; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace MagicOnion.Server.Tests +{ + public class ServiceNameAttributeTest + { + [Fact] + public void MapMagicOnionService_SameShortName_DifferentNamespaces_WithAttribute() + { + // Arrange + var builder = WebApplication.CreateBuilder(); + builder.Services.AddMagicOnion(); + var app = builder.Build(); + var routeBuilder = new TestEndpointRouteBuilder(app.Services); + + // Act — register both services with the same short name but different namespaces + routeBuilder.MapMagicOnionService([ + typeof(ServiceNameAttrAreaA.ProfileAccessService), + typeof(ServiceNameAttrAreaB.ProfileAccessService) + ]); + + // Assert — endpoints should use the custom [ServiceName] values, not the short name + var endpoints = routeBuilder.DataSources.First().Endpoints; + var displayNames = endpoints.Select(x => x.DisplayName!).ToArray(); + + Assert.Contains(displayNames, x => x == "gRPC - /ServiceNameAttrAreaA.IProfileAccess/GetProfileAsync"); + Assert.Contains(displayNames, x => x == "gRPC - /ServiceNameAttrAreaB.IProfileAccess/GetProfileAsync"); + } + + [Fact] + public void MapMagicOnionService_Hub_SameShortName_DifferentNamespaces_WithAttribute() + { + // Arrange + var builder = WebApplication.CreateBuilder(); + builder.Services.AddMagicOnion(); + var app = builder.Build(); + var routeBuilder = new TestEndpointRouteBuilder(app.Services); + + // Act — register both hubs with the same short name but different namespaces + routeBuilder.MapMagicOnionService([ + typeof(ServiceNameAttrAreaA.ChatHubService), + typeof(ServiceNameAttrAreaB.ChatHubService) + ]); + + // Assert — endpoints should use the custom [ServiceName] values, not the short name + var endpoints = routeBuilder.DataSources.First().Endpoints; + var displayNames = endpoints.Select(x => x.DisplayName!).ToArray(); + + Assert.Contains(displayNames, x => x == "gRPC - /ServiceNameAttrAreaA.IChatHub/Connect"); + Assert.Contains(displayNames, x => x == "gRPC - /ServiceNameAttrAreaB.IChatHub/Connect"); + } + + [Fact] + public void MapMagicOnionService_WithoutAttribute_UsesShortName() + { + // Arrange + var builder = WebApplication.CreateBuilder(); + builder.Services.AddMagicOnion(); + var app = builder.Build(); + var routeBuilder = new TestEndpointRouteBuilder(app.Services); + + // Act + routeBuilder.MapMagicOnionService([typeof(ServiceNameAttrAreaC.FooService)]); + + // Assert — should use short type name (backward compatible) + var endpoints = routeBuilder.DataSources.First().Endpoints; + Assert.Contains(endpoints, x => x.DisplayName == "gRPC - /IFooService/DoAsync"); + } + + class TestEndpointRouteBuilder(IServiceProvider serviceProvider) : IEndpointRouteBuilder + { + public IList DataSourcesList { get; } = new List(); + + public IApplicationBuilder CreateApplicationBuilder() + => new ApplicationBuilder(ServiceProvider); + public IServiceProvider ServiceProvider + => serviceProvider; + public ICollection DataSources + => DataSourcesList; + } + } +} + +namespace ServiceNameAttrAreaA +{ + [MagicOnion.ServiceName("ServiceNameAttrAreaA.IProfileAccess")] + public interface IProfileAccess : MagicOnion.IService + { + MagicOnion.UnaryResult GetProfileAsync(); + } + + public class ProfileAccessService : MagicOnion.Server.ServiceBase, IProfileAccess + { + public MagicOnion.UnaryResult GetProfileAsync() => MagicOnion.UnaryResult.FromResult("AreaA"); + } + + [MagicOnion.ServiceName("ServiceNameAttrAreaA.IChatHub")] + public interface IChatHub : MagicOnion.IStreamingHub + { + ValueTask SendAsync(string message); + } + public interface IChatHubReceiver + { + void OnReceive(string message); + } + + public class ChatHubService : MagicOnion.Server.Hubs.StreamingHubBase, IChatHub + { + public ValueTask SendAsync(string message) => ValueTask.CompletedTask; + } +} + +namespace ServiceNameAttrAreaB +{ + [MagicOnion.ServiceName("ServiceNameAttrAreaB.IProfileAccess")] + public interface IProfileAccess : MagicOnion.IService + { + MagicOnion.UnaryResult GetProfileAsync(); + } + + public class ProfileAccessService : MagicOnion.Server.ServiceBase, IProfileAccess + { + public MagicOnion.UnaryResult GetProfileAsync() => MagicOnion.UnaryResult.FromResult("AreaB"); + } + + [MagicOnion.ServiceName("ServiceNameAttrAreaB.IChatHub")] + public interface IChatHub : MagicOnion.IStreamingHub + { + ValueTask SendAsync(string message); + } + public interface IChatHubReceiver + { + void OnReceive(string message); + } + + public class ChatHubService : MagicOnion.Server.Hubs.StreamingHubBase, IChatHub + { + public ValueTask SendAsync(string message) => ValueTask.CompletedTask; + } +} + +namespace ServiceNameAttrAreaC +{ + // No [ServiceName] attribute — should use short name + public interface IFooService : MagicOnion.IService + { + MagicOnion.UnaryResult DoAsync(); + } + + public class FooService : MagicOnion.Server.ServiceBase, IFooService + { + public MagicOnion.UnaryResult DoAsync() => MagicOnion.UnaryResult.FromResult("Foo"); + } +}