diff --git a/src/IntegrationTestApp/ClientEndpoint.cs b/src/IntegrationTestApp/ClientEndpoint.cs new file mode 100644 index 00000000..87952c7c --- /dev/null +++ b/src/IntegrationTestApp/ClientEndpoint.cs @@ -0,0 +1,20 @@ +namespace IntegrationTestApp; + +using IntegrationTest.Shared; +using Microsoft.Extensions.DependencyInjection; + +public static class ClientEndpoint +{ + [NServiceBusSendOnlyFunction("client", Connection = "AzureWebJobsServiceBus")] + public static void ConfigureClient(EndpointConfiguration endpointConfiguration, IServiceCollection services) + { + services.AddSingleton(new MyComponent("client")); + + var transport = new AzureServiceBusServerlessTransport(TopicTopology.Default); + + var routing = endpointConfiguration.UseTransport(transport); + + routing.RouteToEndpoint(typeof(SubmitOrder), "sales"); + endpointConfiguration.UseSerialization(); + } +} \ No newline at end of file diff --git a/src/IntegrationTestApp/Program.cs b/src/IntegrationTestApp/Program.cs index e2fef745..ffce51d9 100644 --- a/src/IntegrationTestApp/Program.cs +++ b/src/IntegrationTestApp/Program.cs @@ -17,18 +17,6 @@ builder.AddNServiceBusFunctions(); -builder.AddSendOnlyNServiceBusEndpoint("client", (configuration, services) => -{ - services.AddSingleton(new MyComponent("client")); - - var transport = new AzureServiceBusServerlessTransport(TopicTopology.Default) { ConnectionName = "AzureWebJobsServiceBus" }; - - var routing = configuration.UseTransport(transport); - - routing.RouteToEndpoint(typeof(SubmitOrder), "sales"); - configuration.UseSerialization(); -}); - var host = builder.Build(); await host.RunAsync(); diff --git a/src/NServiceBus.AzureFunctions.Analyzer/CompilationAssemblyDetails.cs b/src/NServiceBus.AzureFunctions.Analyzer/CompilationAssemblyDetails.cs index c73782d3..755c6271 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/CompilationAssemblyDetails.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/CompilationAssemblyDetails.cs @@ -7,10 +7,22 @@ readonly record struct CompilationAssemblyDetails(string SimpleName, string Iden { public static CompilationAssemblyDetails FromAssembly(IAssemblySymbol assembly) => new(assembly.Name, assembly.Identity.GetDisplayName()); - const string NamePrefix = "GeneratedFunctionRegistrations_"; + const string FunctionNamePrefix = "GeneratedFunctionRegistrations_"; + const string SendOnlyNamePrefix = "GeneratedSendOnlyEndpointRegistrations_"; + public string ToGenerationClassName() + { + return ToClassName(FunctionNamePrefix); + } + + public string ToSendOnlyGenerationClassName() + { + return ToClassName(SendOnlyNamePrefix); + } + + string ToClassName(string prefix) { var hash = NonCryptographicHash.GetHash(Identity); - return $"{NamePrefix}{SimpleName.Replace('.', '_')}_{hash:x16}"; + return $"{prefix}{SimpleName.Replace('.', '_')}_{hash:x16}"; } } \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.Analyzer/ConfigurationAnalyzer.cs b/src/NServiceBus.AzureFunctions.Analyzer/ConfigurationAnalyzer.cs index 3f4c73bb..2dbabd8f 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/ConfigurationAnalyzer.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/ConfigurationAnalyzer.cs @@ -1,6 +1,5 @@ namespace NServiceBus.AzureFunctions.Analyzer; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.Immutable; using System.Threading; @@ -23,156 +22,50 @@ public override void Initialize(AnalysisContext context) { context.EnableConcurrentExecution(); context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); - context.RegisterCompilationStartAction(OnCompilationStart); - } - static void OnCompilationStart(CompilationStartAnalysisContext context) - { - var knownSymbols = new KnownSymbols( - context.Compilation.GetTypeByMetadataName(KnownTypeNames.EndpointConfigurationType), - context.Compilation.GetTypeByMetadataName(KnownTypeNames.IServiceCollection), - context.Compilation.GetTypeByMetadataName(KnownTypeNames.IConfiguration), - context.Compilation.GetTypeByMetadataName(KnownTypeNames.IHostEnvironment), - context.Compilation.GetTypeByMetadataName(KnownTypeNames.SendOptions), - context.Compilation.GetTypeByMetadataName(KnownTypeNames.ReplyOptions), - context.Compilation.GetTypeByMetadataName(KnownTypeNames.AzureServiceBusServerlessTransport), - context.Compilation.GetTypeByMetadataName(KnownTypeNames.ActionOfT), - context.Compilation.GetTypeByMetadataName(KnownTypeNames.ActionOfT1T2), - context.Compilation.GetTypeByMetadataName(KnownTypeNames.FunctionsHostApplicationBuilderExtensions)); - - var sendOnlyConfigureMethods = new ConcurrentDictionary(SymbolEqualityComparer.Default); - var deferredInvocations = new ConcurrentBag(); - - // Collect EndpointConfiguration invocations in qualifying methods. - // Context resolution is deferred to compilation end to avoid race conditions - // with sendOnlyConfigureMethods population from other syntax-node actions. - context.RegisterCodeBlockStartAction(blockStartContext => + context.RegisterCompilationStartAction(compilationStartContext => { - if (blockStartContext.OwningSymbol is IMethodSymbol method && HasSupportedConfigureMethodSignature(method, knownSymbols)) - { - blockStartContext.RegisterSyntaxNodeAction( - nodeContext => CollectEndpointConfigurationInvocation(nodeContext, method, deferredInvocations), - SyntaxKind.InvocationExpression); - } - }); + var knownSymbols = new KnownSymbols( + compilationStartContext.Compilation.GetTypeByMetadataName(KnownTypeNames.EndpointConfigurationType), + compilationStartContext.Compilation.GetTypeByMetadataName(KnownTypeNames.IServiceCollection), + compilationStartContext.Compilation.GetTypeByMetadataName(KnownTypeNames.IConfiguration), + compilationStartContext.Compilation.GetTypeByMetadataName(KnownTypeNames.IHostEnvironment), + compilationStartContext.Compilation.GetTypeByMetadataName(KnownTypeNames.SendOptions), + compilationStartContext.Compilation.GetTypeByMetadataName(KnownTypeNames.ReplyOptions), + compilationStartContext.Compilation.GetTypeByMetadataName(KnownTypeNames.AzureServiceBusServerlessTransport), + compilationStartContext.Compilation.GetTypeByMetadataName(KnownTypeNames.NServiceBusSendOnlyFunctionAttribute)); - // Track method group arguments to AddSendOnlyNServiceBusEndpoint and analyze - // EndpointConfiguration calls inside lambda callbacks (syntax-tree walking). - context.RegisterSyntaxNodeAction( - nodeContext => AnalyzeSendOnlyInvocation(nodeContext, knownSymbols, sendOnlyConfigureMethods), - SyntaxKind.InvocationExpression); - - // SendOptions/ReplyOptions: syntax-node-scoped, purely receiver-type checked. - context.RegisterSyntaxNodeAction( - nodeContext => AnalyzeSendAndReplyOptions(nodeContext, knownSymbols), - SyntaxKind.InvocationExpression); - - // Resolve deferred invocations after all syntax-node and code-block actions have completed. - // By this point sendOnlyConfigureMethods is fully populated — no race. - context.RegisterCompilationEndAction(endContext => - { - foreach (var deferred in deferredInvocations) + compilationStartContext.RegisterCodeBlockStartAction(blockStartContext => { - var endpointContext = sendOnlyConfigureMethods.ContainsKey(deferred.OwningMethod.OriginalDefinition) - ? EndpointConfigurationContext.SendOnlyEndpoint - : EndpointConfigurationContext.AzureFunctionsEndpoint; - - AnalyzeEndpointConfiguration( - deferred.Invocation, - deferred.SemanticModel, - endContext.ReportDiagnostic, - endpointContext, - knownSymbols, - endContext.CancellationToken); - } - }); - } + if (blockStartContext.OwningSymbol is not IMethodSymbol method) + { + return; + } - static void CollectEndpointConfigurationInvocation( - SyntaxNodeAnalysisContext context, - IMethodSymbol owningMethod, - ConcurrentBag deferredInvocations) - { - if (context.Node is not InvocationExpressionSyntax { Expression: MemberAccessExpressionSyntax } invocationExpression) - { - return; - } + if (!HasSupportedConfigureMethodSignature(method, knownSymbols)) + { + return; + } - deferredInvocations.Add(new DeferredInvocation(invocationExpression, owningMethod, context.SemanticModel)); - } + var endpointContext = HasSendOnlyEndpointAttribute(method, knownSymbols.SendOnlyEndpointAttribute) ? EndpointConfigurationContext.SendOnlyEndpoint : EndpointConfigurationContext.AzureFunctionsEndpoint; - static void AnalyzeEndpointConfiguration( - InvocationExpressionSyntax invocationExpression, - SemanticModel semanticModel, - Action reportDiagnostic, - EndpointConfigurationContext endpointContext, - KnownSymbols knownSymbols, - CancellationToken cancellationToken) - { - if (invocationExpression.Expression is not MemberAccessExpressionSyntax memberAccessExpression) - { - return; - } + blockStartContext.RegisterSyntaxNodeAction(nodeContext => AnalyzeEndpointConfigurationInvocation(nodeContext, endpointContext, knownSymbols), SyntaxKind.InvocationExpression); + }); - if (memberAccessExpression.Name.Identifier.ValueText == UseTransportMethodName) - { - AnalyzeInvalidTransportConfiguration(invocationExpression, semanticModel, reportDiagnostic, endpointContext, knownSymbols, cancellationToken); - return; - } - - if (!InvalidEndpointConfigurationMethods.TryGetValue(memberAccessExpression.Name.Identifier.ValueText, out var rule)) - { - return; - } - - reportDiagnostic(Diagnostic.Create( - DiagnosticIds.InvalidEndpointConfigurationDescriptor, - invocationExpression.GetLocation(), - rule.ApiName, - GetEndpointContextLabel(endpointContext), - GetEndpointConfigurationReason(rule, endpointContext))); + compilationStartContext.RegisterSyntaxNodeAction(nodeContext => AnalyzeSendAndReplyOptions(nodeContext, knownSymbols), SyntaxKind.InvocationExpression); + }); } - static void AnalyzeSendOnlyInvocation( - SyntaxNodeAnalysisContext context, - KnownSymbols knownSymbols, - ConcurrentDictionary sendOnlyConfigureMethods) + static void AnalyzeEndpointConfigurationInvocation(SyntaxNodeAnalysisContext context, EndpointConfigurationContext endpointContext, KnownSymbols knownSymbols) { - if (context.Node is not InvocationExpressionSyntax invocationExpression) - { - return; - } - - if (IsAddSendOnlyNServiceBusEndpoint(context.SemanticModel.GetSymbolInfo(invocationExpression, context.CancellationToken).Symbol, knownSymbols) - && invocationExpression.ArgumentList.Arguments.Count >= 2) - { - var lastArgument = invocationExpression.ArgumentList.Arguments[^1].Expression; - - if (context.SemanticModel.GetSymbolInfo(lastArgument, context.CancellationToken).Symbol is IMethodSymbol referencedMethod - && HasSupportedConfigureMethodSignature(referencedMethod, knownSymbols)) - { - sendOnlyConfigureMethods.TryAdd(referencedMethod.OriginalDefinition, true); - } - } - - if (context.Node is not InvocationExpressionSyntax { Expression: MemberAccessExpressionSyntax memberAccessExpression }) - { - return; - } - - if (!IsEndpointConfigurationReceiver(memberAccessExpression, context.SemanticModel, knownSymbols.EndpointConfiguration, context.CancellationToken)) - { - return; - } - - if (!IsInsideSendOnlyCallback(invocationExpression, context.SemanticModel, knownSymbols, context.CancellationToken)) + if (context.Node is not InvocationExpressionSyntax { Expression: MemberAccessExpressionSyntax memberAccessExpression } invocationExpression) { return; } if (memberAccessExpression.Name.Identifier.ValueText == UseTransportMethodName) { - AnalyzeInvalidTransportConfiguration(invocationExpression, context.SemanticModel, context.ReportDiagnostic, EndpointConfigurationContext.SendOnlyEndpoint, knownSymbols, context.CancellationToken); + AnalyzeInvalidTransportConfiguration(invocationExpression, context.SemanticModel, context.ReportDiagnostic, endpointContext, knownSymbols, context.CancellationToken); return; } @@ -185,8 +78,8 @@ static void AnalyzeSendOnlyInvocation( DiagnosticIds.InvalidEndpointConfigurationDescriptor, invocationExpression.GetLocation(), rule.ApiName, - GetEndpointContextLabel(EndpointConfigurationContext.SendOnlyEndpoint), - GetEndpointConfigurationReason(rule, EndpointConfigurationContext.SendOnlyEndpoint))); + GetEndpointContextLabel(endpointContext), + GetEndpointConfigurationReason(rule, endpointContext))); } static void AnalyzeInvalidTransportConfiguration( @@ -214,24 +107,6 @@ static void AnalyzeInvalidTransportConfiguration( GetEndpointContextLabel(endpointContext))); } - static bool IsInsideSendOnlyCallback( - InvocationExpressionSyntax invocationExpression, - SemanticModel semanticModel, - KnownSymbols knownSymbols, - CancellationToken cancellationToken) - { - for (SyntaxNode? current = invocationExpression; current is not null; current = current.Parent) - { - if (current is AnonymousFunctionExpressionSyntax anonymousFunction - && IsSendOnlyEndpointConfigurationCallback(anonymousFunction, semanticModel, knownSymbols, cancellationToken)) - { - return true; - } - } - - return false; - } - static void AnalyzeSendAndReplyOptions(SyntaxNodeAnalysisContext context, KnownSymbols knownSymbols) { if (context.Node is not InvocationExpressionSyntax { Expression: MemberAccessExpressionSyntax memberAccessExpression } invocationExpression) @@ -259,20 +134,6 @@ static void AnalyzeSendAndReplyOptions(SyntaxNodeAnalysisContext context, KnownS rule.Reason)); } - static bool IsAddSendOnlyNServiceBusEndpoint(ISymbol? symbol, KnownSymbols knownSymbols) - => symbol is IMethodSymbol { Name: KnownTypeNames.AddSendOnlyNServiceBusEndpoint, Parameters.Length: 2 } method - && ContainsType(method, knownSymbols.FunctionsHostApplicationBuilderExtensions); - - static bool IsEndpointConfigurationReceiver( - MemberAccessExpressionSyntax memberAccessExpression, - SemanticModel semanticModel, - INamedTypeSymbol? endpointConfigurationSymbol, - CancellationToken cancellationToken) - { - var receiverType = semanticModel.GetTypeInfo(memberAccessExpression.Expression, cancellationToken).Type; - return SymbolEqualityComparer.Default.Equals(receiverType, endpointConfigurationSymbol); - } - static bool HasSupportedConfigureMethodSignature(IMethodSymbol method, KnownSymbols knownSymbols) { if (method.Parameters.Length == 0 @@ -292,71 +153,15 @@ static bool HasSupportedConfigureMethodSignature(IMethodSymbol method, KnownSymb return true; } - static bool IsSendOnlyEndpointConfigurationCallback( - AnonymousFunctionExpressionSyntax anonymousFunction, - SemanticModel semanticModel, - KnownSymbols knownSymbols, - CancellationToken cancellationToken) - { - if (anonymousFunction.Parent is not ArgumentSyntax { Parent: ArgumentListSyntax { Parent: InvocationExpressionSyntax invocationExpression } argumentList }) - { - return false; - } - - if (argumentList.Arguments.Count == 0 || argumentList.Arguments[^1].Expression != anonymousFunction) - { - return false; - } - - if (semanticModel.GetSymbolInfo(invocationExpression, cancellationToken).Symbol is not IMethodSymbol methodSymbol - || !IsAddSendOnlyNServiceBusEndpoint(methodSymbol, knownSymbols)) - { - return false; - } - - return methodSymbol.Parameters[1].Type is INamedTypeSymbol { TypeArguments.Length: 1 or 2 } delegateType - && IsSupportedSendOnlyCallbackDelegate(delegateType, knownSymbols) - && SymbolEqualityComparer.Default.Equals(delegateType.TypeArguments[0], knownSymbols.EndpointConfiguration) - && (delegateType.TypeArguments.Length == 1 - || SymbolEqualityComparer.Default.Equals(delegateType.TypeArguments[1], knownSymbols.IServiceCollection)); - } - - static bool IsSupportedSendOnlyCallbackDelegate(INamedTypeSymbol delegateType, KnownSymbols knownSymbols) - => delegateType.TypeArguments.Length switch - { - 1 => SymbolEqualityComparer.Default.Equals(delegateType.OriginalDefinition, knownSymbols.ActionOfT), - 2 => SymbolEqualityComparer.Default.Equals(delegateType.OriginalDefinition, knownSymbols.ActionOfT1T2), - _ => false - }; - - static bool ContainsType(IMethodSymbol method, INamedTypeSymbol? target) - { - if (target is null) - { - return false; - } - - for (INamedTypeSymbol? type = method.ContainingType; type is not null; type = type.ContainingType) - { - if (SymbolEqualityComparer.Default.Equals(type, target)) - { - return true; - } - } - - return false; - } + static bool HasSendOnlyEndpointAttribute(IMethodSymbol method, INamedTypeSymbol? sendOnlyEndpointAttribute) + => sendOnlyEndpointAttribute is not null && method.HasAttribute(sendOnlyEndpointAttribute); static bool IsAllowedConfigureMethodParameterType(ITypeSymbol parameterType, KnownSymbols knownSymbols) - => SymbolEqualityComparer.Default.Equals(parameterType, knownSymbols.IServiceCollection) - || SymbolEqualityComparer.Default.Equals(parameterType, knownSymbols.IConfiguration) - || SymbolEqualityComparer.Default.Equals(parameterType, knownSymbols.IHostEnvironment); + => parameterType.IsAllowedConfigureMethodParameterType(knownSymbols.IServiceCollection!, knownSymbols.IConfiguration!, knownSymbols.IHostEnvironment!); static string GetEndpointContextLabel(EndpointConfigurationContext endpointContext) => endpointContext == EndpointConfigurationContext.SendOnlyEndpoint ? SendOnlyEndpoints : AzureFunctionsEndpoints; - static string GetEndpointConfigurationReason( - InvalidEndpointConfigurationRule rule, - EndpointConfigurationContext endpointContext) + static string GetEndpointConfigurationReason(InvalidEndpointConfigurationRule rule, EndpointConfigurationContext endpointContext) { if (endpointContext == EndpointConfigurationContext.SendOnlyEndpoint && rule.SendOnlyReason is { } sendOnlyReason) @@ -393,24 +198,6 @@ static bool UsesAllowedTransport( const string UseTransportMethodName = "UseTransport"; const string AzureFunctionsEndpoints = "Azure Functions endpoints"; const string SendOnlyEndpoints = "Send-only endpoints"; - const string SendOnlyEndpointReason = "Send-only endpoints do not receive messages."; - - readonly record struct DeferredInvocation( - InvocationExpressionSyntax Invocation, - IMethodSymbol OwningMethod, - SemanticModel SemanticModel); - - readonly record struct KnownSymbols( - INamedTypeSymbol? EndpointConfiguration, - INamedTypeSymbol? IServiceCollection, - INamedTypeSymbol? IConfiguration, - INamedTypeSymbol? IHostEnvironment, - INamedTypeSymbol? SendOptions, - INamedTypeSymbol? ReplyOptions, - INamedTypeSymbol? AzureServiceBusServerlessTransport, - INamedTypeSymbol? ActionOfT, - INamedTypeSymbol? ActionOfT1T2, - INamedTypeSymbol? FunctionsHostApplicationBuilderExtensions); enum EndpointConfigurationContext { @@ -422,6 +209,16 @@ enum EndpointConfigurationContext readonly record struct InvalidSendOptionsRule(string Reason); + readonly record struct KnownSymbols( + INamedTypeSymbol? EndpointConfiguration, + INamedTypeSymbol? IServiceCollection, + INamedTypeSymbol? IConfiguration, + INamedTypeSymbol? IHostEnvironment, + INamedTypeSymbol? SendOptions, + INamedTypeSymbol? ReplyOptions, + INamedTypeSymbol? AzureServiceBusServerlessTransport, + INamedTypeSymbol? SendOnlyEndpointAttribute); + static readonly Dictionary InvalidEndpointConfigurationMethods = new() { @@ -434,6 +231,8 @@ enum EndpointConfigurationContext ["OverrideLocalAddress"] = new("EndpointConfiguration.OverrideLocalAddress", "The endpoint address is determined by the trigger configuration.", SendOnlyEndpointReason) }; + const string SendOnlyEndpointReason = "Send-only endpoints do not receive messages."; + static readonly Dictionary InvalidSendAndReplyOptions = new() { diff --git a/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs b/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs index f2b416e8..65c34721 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs @@ -17,6 +17,7 @@ static class DiagnosticIds public const string InvalidEndpointConfiguration = "NSBFUNC008"; public const string InvalidSendOptions = "NSBFUNC009"; public const string InvalidEndpointTransportConfiguration = "NSBFUNC010"; + public const string InvalidSendOnlyEndpointMethod = "NSBFUNC011"; internal static readonly DiagnosticDescriptor ClassMustBePartialDescriptor = new( id: ClassMustBePartial, @@ -98,4 +99,12 @@ static class DiagnosticIds category: "NServiceBus.AzureFunctions", defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); + + internal static readonly DiagnosticDescriptor InvalidSendOnlyEndpointMethodDescriptor = new( + id: InvalidSendOnlyEndpointMethod, + title: "Invalid NServiceBus send-only endpoint method", + messageFormat: "Method '{0}' defines an NServiceBus send-only endpoint but is not correctly implemented: {1}", + category: "NServiceBus.AzureFunctions", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); } diff --git a/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticsSpec.cs b/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticsSpec.cs new file mode 100644 index 00000000..83d76bea --- /dev/null +++ b/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticsSpec.cs @@ -0,0 +1,9 @@ +namespace NServiceBus.AzureFunctions.Analyzer; + +using Core.Analyzer; +using Microsoft.CodeAnalysis; + +interface IDiagnosticsSpec +{ + ImmutableEquatableArray Diagnostics { get; } +} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticsSpecExtensions.cs b/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticsSpecExtensions.cs new file mode 100644 index 00000000..568cf103 --- /dev/null +++ b/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticsSpecExtensions.cs @@ -0,0 +1,24 @@ +namespace NServiceBus.AzureFunctions.Analyzer; + +using System.Collections.Immutable; +using Core.Analyzer; +using Microsoft.CodeAnalysis; + +static class DiagnosticsSpecExtensions +{ + public static IEnumerable ToDiagnostics(this ImmutableArray results) where T : IDiagnosticsSpec + { + // DiagnosticWithInfo implements structural equality (Location, Info, AdditionalLocations) + // so HashSet deduplicates correctly. ImmutableEquatableArray enables incremental caching: + // unchanged documents reuse the same SyntaxTree references, so diagnostics compare equal + // across steps. Within an edited file, new tree references cause re-reporting, which is + // correct and cheap. + var diagnostics = new HashSet(); + foreach (var result in results) + { + diagnostics.UnionWith(result.Diagnostics); + } + + return diagnostics.ToImmutableEquatableArray(); + } +} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionCompositionGenerator.Emitter.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionCompositionGenerator.Emitter.cs index 3714702f..5f7bc00a 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionCompositionGenerator.Emitter.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionCompositionGenerator.Emitter.cs @@ -35,10 +35,20 @@ public static void Emit(SourceProductionContext context, CompositionSpec? compos foreach (var registrationClass in composition.RegistrationClasses) { context.CancellationToken.ThrowIfCancellationRequested(); - writer.WriteLine($"foreach (var manifest in global::{registrationClass.FullClassName}.GetFunctionManifests())"); - writer.WriteLine("{"); - writer.WriteLine(" manifest.Register(builder, manifest);"); - writer.WriteLine("}"); + if (registrationClass.Kind == RegistrationClassKind.Function) + { + writer.WriteLine($"foreach (var manifest in global::{registrationClass.FullClassName}.GetFunctionManifests())"); + writer.WriteLine("{"); + writer.WriteLine(" manifest.Register(builder, manifest);"); + writer.WriteLine("}"); + } + else + { + writer.WriteLine($"foreach (var manifest in global::{registrationClass.FullClassName}.GetSendOnlyEndpointManifests())"); + writer.WriteLine("{"); + writer.WriteLine(" manifest.Register(builder, manifest);"); + writer.WriteLine("}"); + } } writer.CloseCurlies(); diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionCompositionGenerator.Parser.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionCompositionGenerator.Parser.cs index fa449908..87a1919f 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionCompositionGenerator.Parser.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionCompositionGenerator.Parser.cs @@ -23,7 +23,7 @@ internal static HostProjectSpec ParseHostProject(AnalyzerConfigOptionsProvider p return new HostProjectSpec(isHostProject, effectiveRootNameSpace); } - internal static CompositionSpec? ParseComposition(Compilation compilation, HostProjectSpec hostProject, bool hasLocalFunctions, CancellationToken cancellationToken = default) + internal static CompositionSpec? ParseComposition(Compilation compilation, HostProjectSpec hostProject, bool hasLocalFunctions, bool hasLocalSendOnlyEndpoints, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -38,16 +38,27 @@ internal static HostProjectSpec ParseHostProject(AnalyzerConfigOptionsProvider p { cancellationToken.ThrowIfCancellationRequested(); - var registration = CreateGeneratedRegistrationClassSpec(referencedAssembly); - if (compilation.GetTypeByMetadataName(registration.FullClassName) is not null) - { - registrations.Add(registration); - } + AddGeneratedRegistrationClasses(compilation, referencedAssembly, registrations); } if (hasLocalFunctions) { - registrations.Add(CreateGeneratedRegistrationClassSpec(compilation.Assembly)); + AddGeneratedRegistrationClass( + compilation, + $"NServiceBus.Generated.{CompilationAssemblyDetails.FromAssembly(compilation.Assembly).ToGenerationClassName()}", + RegistrationClassKind.Function, + registrations, + includeWithoutLookup: true); + } + + if (hasLocalSendOnlyEndpoints) + { + AddGeneratedRegistrationClass( + compilation, + $"NServiceBus.Generated.{CompilationAssemblyDetails.FromAssembly(compilation.Assembly).ToSendOnlyGenerationClassName()}", + RegistrationClassKind.SendOnly, + registrations, + includeWithoutLookup: true); } if (registrations.Count == 0) @@ -62,14 +73,39 @@ internal static HostProjectSpec ParseHostProject(AnalyzerConfigOptionsProvider p return new CompositionSpec(orderedRegistrations, hostProject.RootNamespace); } - static GeneratedRegistrationClassSpec CreateGeneratedRegistrationClassSpec(IAssemblySymbol assembly) + static void AddGeneratedRegistrationClasses( + Compilation compilation, + IAssemblySymbol assembly, + ISet registrations, + bool includeWithoutLookup = false) + { + var details = CompilationAssemblyDetails.FromAssembly(assembly); + + AddGeneratedRegistrationClass(compilation, $"NServiceBus.Generated.{details.ToGenerationClassName()}", RegistrationClassKind.Function, registrations, includeWithoutLookup); + AddGeneratedRegistrationClass(compilation, $"NServiceBus.Generated.{details.ToSendOnlyGenerationClassName()}", RegistrationClassKind.SendOnly, registrations, includeWithoutLookup); + } + + static void AddGeneratedRegistrationClass( + Compilation compilation, + string fullClassName, + RegistrationClassKind kind, + ISet registrations, + bool includeWithoutLookup) { - var className = CompilationAssemblyDetails.FromAssembly(assembly).ToGenerationClassName(); - return new GeneratedRegistrationClassSpec($"NServiceBus.Generated.{className}"); + if (includeWithoutLookup || compilation.GetTypeByMetadataName(fullClassName) is not null) + { + registrations.Add(new GeneratedRegistrationClassSpec(fullClassName, kind)); + } } } - internal readonly record struct GeneratedRegistrationClassSpec(string FullClassName); + internal readonly record struct GeneratedRegistrationClassSpec(string FullClassName, RegistrationClassKind Kind); + internal enum RegistrationClassKind + { + Function, + SendOnly + } + internal readonly record struct HostProjectSpec(bool IsHostProject, string? RootNamespace); internal sealed record CompositionSpec(ImmutableEquatableArray RegistrationClasses, string? RootNamespace); } diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionCompositionGenerator.TrackingNames.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionCompositionGenerator.TrackingNames.cs index d0867ffe..46764b34 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionCompositionGenerator.TrackingNames.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionCompositionGenerator.TrackingNames.cs @@ -6,8 +6,9 @@ internal static class TrackingNames { public const string HostProject = nameof(HostProject); public const string LocalFunctions = nameof(LocalFunctions); + public const string LocalSendOnlyEndpoints = nameof(LocalSendOnlyEndpoints); public const string Composition = nameof(Composition); - public static string[] All => [HostProject, LocalFunctions, Composition]; + public static string[] All => [HostProject, LocalFunctions, LocalSendOnlyEndpoints, Composition]; } } \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionCompositionGenerator.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionCompositionGenerator.cs index 769fb37d..578c7e6f 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionCompositionGenerator.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionCompositionGenerator.cs @@ -21,10 +21,20 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .Select(static (matches, _) => matches.Length > 0) .WithTrackingName(TrackingNames.LocalFunctions); + var hasLocalSendOnlyEndpoints = context.SyntaxProvider + .ForAttributeWithMetadataName( + KnownTypeNames.NServiceBusSendOnlyFunctionAttribute, + static (node, _) => node is MethodDeclarationSyntax, + static (_, _) => true) + .Collect() + .Select(static (matches, _) => matches.Length > 0) + .WithTrackingName(TrackingNames.LocalSendOnlyEndpoints); + var compositions = context.CompilationProvider .Combine(hostProject) .Combine(hasLocalFunctions) - .Select(static (data, cancellationToken) => Parser.ParseComposition(data.Left.Left, data.Left.Right, data.Right, cancellationToken)) + .Combine(hasLocalSendOnlyEndpoints) + .Select(static (data, cancellationToken) => Parser.ParseComposition(data.Left.Left.Left, data.Left.Left.Right, data.Left.Right, data.Right, cancellationToken)) .WithTrackingName(TrackingNames.Composition); context.RegisterSourceOutput( diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs index a5ea280c..f83e8fad 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.AzureServiceBus.cs @@ -4,14 +4,8 @@ namespace NServiceBus.AzureFunctions.Analyzer; public sealed partial class FunctionEndpointGenerator { - static readonly ParameterRole MessageActions = new("MessageActions"); - - static readonly TriggerDefinition AzureServiceBusTrigger = new( - TriggerAttributeMetadataName: "Microsoft.Azure.Functions.Worker.ServiceBusTriggerAttribute", - AdditionalParameterTypes: new AdditionalParameterType[] - { - new("Microsoft.Azure.Functions.Worker.ServiceBusMessageActions", MessageActions) - }.ToImmutableEquatableArray(), + internal sealed record AzureServiceBusTriggerDefinition() : TriggerDefinition(TriggerAttributeMetadataName: "Microsoft.Azure.Functions.Worker.ServiceBusTriggerAttribute", + AdditionalParameterTypes: new AdditionalParameterType[] { new("Microsoft.Azure.Functions.Worker.ServiceBusMessageActions", MessageActions) }.ToImmutableEquatableArray(), ProcessorTypeFullyQualified: "global::NServiceBus.AzureFunctions.AzureServiceBus.AzureServiceBusMessageProcessor", AddressExtraction: AddressExtractionPolicy.FromNamedConstructorParameter("queueName"), ConnectionSetting: ConnectionSettingPolicy.FromNamedProperty("Connection"), @@ -22,5 +16,8 @@ public sealed partial class FunctionEndpointGenerator ParameterRole.TriggerMessage, MessageActions, ParameterRole.FunctionContext, - ParameterRole.CancellationToken)); -} + ParameterRole.CancellationToken)) + { + static readonly ParameterRole MessageActions = new("MessageActions"); + } +} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs index 91d8dfbb..ede46367 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.Parser.cs @@ -40,19 +40,19 @@ static FunctionSpecs ExtractFromMethod(IMethodSymbol methodSymbol, FunctionEndpo if (!methodSymbol.IsPartialDefinition) { - diagnostics.Add(CreateDiagnostic(DiagnosticIds.MethodMustBePartialDescriptor, methodSymbol, methodSymbol.Name)); + diagnostics.Add(methodSymbol.CreateDiagnostic(DiagnosticIds.MethodMustBePartialDescriptor, methodSymbol.Name)); } var classSymbol = methodSymbol.ContainingType; if (!IsPartial(classSymbol, cancellationToken)) { - diagnostics.Add(CreateDiagnostic(DiagnosticIds.ClassMustBePartialDescriptor, classSymbol, classSymbol.Name)); + diagnostics.Add(classSymbol.CreateDiagnostic(DiagnosticIds.ClassMustBePartialDescriptor, classSymbol.Name)); } if (ImplementsIHandleMessages(classSymbol, knownTypes)) { - diagnostics.Add(CreateDiagnostic(DiagnosticIds.ShouldNotImplementIHandleMessagesDescriptor, classSymbol, classSymbol.Name)); + diagnostics.Add(classSymbol.CreateDiagnostic(DiagnosticIds.ShouldNotImplementIHandleMessagesDescriptor, classSymbol.Name)); } var spec = ExtractFunctionSpec(methodSymbol, knownTypes, triggerDefinition, diagnostics); @@ -224,12 +224,12 @@ static FunctionSpecs ExtractFromMethod(IMethodSymbol methodSymbol, FunctionEndpo if (autoCompleteMustBeDisabled) { - diagnostics.Add(CreateDiagnostic( - DiagnosticIds.AutoCompleteMustBeExplicitlyDisabled, - method, - method.Name, - autoCompletePropertyName!, - knownTypes.TriggerAttribute.Name)); + diagnostics.Add( + method.CreateDiagnostic( + DiagnosticIds.AutoCompleteMustBeExplicitlyDisabled, + method.Name, + autoCompletePropertyName!, + knownTypes.TriggerAttribute.Name)); } connectionSettingName ??= ""; @@ -473,7 +473,7 @@ static string FormatShape(ImmutableEquatableArray orderedParamete { if (configureMethod is not null) { - diagnostics.Add(CreateDiagnostic(DiagnosticIds.MultipleConfigureMethodsDescriptor, functionClassType, configureMethodName, functionClassType.Name)); + diagnostics.Add(functionClassType.CreateDiagnostic(DiagnosticIds.MultipleConfigureMethodsDescriptor, configureMethodName, functionClassType.Name)); return null; } @@ -530,19 +530,7 @@ static bool ImplementsIHandleMessages(INamedTypeSymbol classSymbol, FunctionEndp } static bool TryGetFunctionAttribute(IMethodSymbol method, FunctionEndpointGeneratorKnownTypes knownTypes, [NotNullWhen(true)] out AttributeData? functionAttribute) - { - foreach (var attribute in method.GetAttributes()) - { - if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, knownTypes.FunctionAttribute)) - { - functionAttribute = attribute; - return true; - } - } - - functionAttribute = null!; - return false; - } + => method.TryGetAttribute(knownTypes.FunctionAttribute, out functionAttribute); static bool IsPartial(INamedTypeSymbol type, CancellationToken cancellationToken) { @@ -566,12 +554,7 @@ static bool IsPartial(INamedTypeSymbol type, CancellationToken cancellationToken return false; } - static Diagnostic CreateDiagnostic(DiagnosticDescriptor descriptor, ISymbol symbol, params object[] arguments) - { - var locations = symbol.Locations; - var location = locations.Length > 0 ? locations[0] : null; - return Diagnostic.Create(descriptor, location, arguments); - } + } internal readonly record struct ConfigureMethodSpec(string ContainingTypeFullyQualified, string MethodName, ImmutableEquatableArray ParameterTypeNames); @@ -592,7 +575,7 @@ internal sealed record FunctionSpec( string ProcessCallExpression, ConfigureMethodSpec ConfigureMethod); - internal readonly record struct FunctionSpecs(ImmutableEquatableArray Functions, ImmutableEquatableArray Diagnostics) + internal readonly record struct FunctionSpecs(ImmutableEquatableArray Functions, ImmutableEquatableArray Diagnostics) : IDiagnosticsSpec { public static FunctionSpecs Empty { get; } = new(ImmutableEquatableArray.Empty, ImmutableEquatableArray.Empty); } diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.TriggerDefinition.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.TriggerDefinition.cs index 1705fcba..be7061cf 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.TriggerDefinition.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.TriggerDefinition.cs @@ -22,7 +22,7 @@ public sealed partial class FunctionEndpointGenerator /// Trigger method signatures are validated against . /// /// - internal sealed record TriggerDefinition( + internal abstract record TriggerDefinition( string TriggerAttributeMetadataName, ImmutableEquatableArray AdditionalParameterTypes, string ProcessorTypeFullyQualified, diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.cs index 18bcdccc..cf20ec36 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.cs @@ -1,16 +1,19 @@ namespace NServiceBus.AzureFunctions.Analyzer; -using Core.Analyzer; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; [Generator] public sealed partial class FunctionEndpointGenerator : IIncrementalGenerator { - public void Initialize(IncrementalGeneratorInitializationContext context) - => InitializeGenerator(context, AzureServiceBusTrigger); - - internal static void InitializeGenerator(IncrementalGeneratorInitializationContext context, TriggerDefinition triggerDefinition) + public void Initialize(IncrementalGeneratorInitializationContext context) => InitializeGenerator(context); + + // This method currently exists to proof that technically the generator pipeline can be extended with other trigger definitions + // without having to change the analyzer. In practice, we don't have any other trigger definitions at the moment, + // but there are tests that verify it would be possible to add more. There might still be some refactoring opportunities to make the extension story better + // but this is good enough for now. + internal static void InitializeGenerator(IncrementalGeneratorInitializationContext context) + where TDefinition : TriggerDefinition, new() { var extractionCandidates = context.SyntaxProvider .ForAttributeWithMetadataName( @@ -18,29 +21,14 @@ internal static void InitializeGenerator(IncrementalGeneratorInitializationConte predicate: static (node, _) => node is MethodDeclarationSyntax, transform: static (ctx, _) => ctx); - var triggerDefinitionProvider = CreateTriggerDefinitionProvider(context, triggerDefinition); - var extractionResults = extractionCandidates - .Combine(triggerDefinitionProvider) + .Combine(context.CompilationProvider.Select(static (_, _) => new TDefinition())) .Select(static (pair, ct) => Parser.Extract(pair.Left, pair.Right, ct)) .WithTrackingName(TrackingNames.Extraction); var diagnostics = extractionResults - .Collect() // Materialize all results for cross-method diagnostic deduplication. - .SelectMany(static (results, _) => - { - // DiagnosticWithInfo implements structural equality (Location, Info, AdditionalLocations) - // so HashSet deduplicates correctly. ImmutableEquatableArray enables incremental caching: - // unchanged documents reuse the same SyntaxTree references, so diagnostics compare equal - // across steps. Within an edited file, new tree references cause re-reporting, which is - // correct and cheap. - var diagnostics = new HashSet(); - foreach (var result in results) - { - diagnostics.UnionWith(result.Diagnostics); - } - return diagnostics.ToImmutableEquatableArray(); - }) + .Collect() + .SelectMany(static (results, _) => results.ToDiagnostics()) .WithTrackingName(TrackingNames.Diagnostics); context.RegisterSourceOutput(diagnostics, static (spc, diag) => @@ -59,10 +47,5 @@ internal static void InitializeGenerator(IncrementalGeneratorInitializationConte .WithTrackingName(TrackingNames.Combined); context.RegisterSourceOutput(combined, static (spc, data) => Emitter.Emit(spc, data.Left, data.Right)); - - static IncrementalValueProvider CreateTriggerDefinitionProvider( - IncrementalGeneratorInitializationContext context, - TriggerDefinition triggerDefinition) => - context.CompilationProvider.Select((_, _) => triggerDefinition); } } \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.Analyzer/KnownTypeNames.cs b/src/NServiceBus.AzureFunctions.Analyzer/KnownTypeNames.cs index 8002f5a2..c0ebbeab 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/KnownTypeNames.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/KnownTypeNames.cs @@ -5,20 +5,18 @@ static class KnownTypeNames public const string FunctionAttribute = "Microsoft.Azure.Functions.Worker.FunctionAttribute"; public const string FunctionContext = "Microsoft.Azure.Functions.Worker.FunctionContext"; public const string NServiceBusFunctionAttribute = "NServiceBus.NServiceBusFunctionAttribute"; + public const string NServiceBusSendOnlyFunctionAttribute = "NServiceBus.NServiceBusSendOnlyFunctionAttribute"; public const string CancellationToken = "System.Threading.CancellationToken"; public const string EndpointConfigurationType = "NServiceBus.EndpointConfiguration"; public const string IHandleMessages = "NServiceBus.IHandleMessages`1"; public const string SendOptions = "NServiceBus.SendOptions"; public const string ReplyOptions = "NServiceBus.ReplyOptions"; public const string AzureServiceBusServerlessTransport = "NServiceBus.AzureServiceBusServerlessTransport"; - public const string ActionOfT = "System.Action`1"; - public const string ActionOfT1T2 = "System.Action`2"; public const string IServiceCollection = "Microsoft.Extensions.DependencyInjection.IServiceCollection"; public const string IConfiguration = "Microsoft.Extensions.Configuration.IConfiguration"; public const string IHostEnvironment = "Microsoft.Extensions.Hosting.IHostEnvironment"; - public const string FunctionsHostApplicationBuilderExtensions = "NServiceBus.FunctionsHostApplicationBuilderExtensions"; public const string AzureServiceBusFunctionsHostApplicationBuilderExtensions = "NServiceBus.Configuration.AdvancedExtensibility.AzureServiceBusFunctionsHostApplicationBuilderExtensions"; - public const string AddSendOnlyNServiceBusEndpoint = "AddSendOnlyNServiceBusEndpoint"; public const string AddNServiceBusFunctions = "AddNServiceBusFunctions"; public const string AddNServiceBusAzureServiceBusFunction = "AddNServiceBusAzureServiceBusFunction"; + public const string AddNServiceBusAzureServiceBusSendOnlyEndpoint = "AddNServiceBusAzureServiceBusSendOnlyEndpoint"; } \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.Analyzer/MissingCompositionCallAnalyzer.cs b/src/NServiceBus.AzureFunctions.Analyzer/MissingCompositionCallAnalyzer.cs index d6d8e970..225e4a62 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/MissingCompositionCallAnalyzer.cs +++ b/src/NServiceBus.AzureFunctions.Analyzer/MissingCompositionCallAnalyzer.cs @@ -82,8 +82,7 @@ static void OnCompilationStart(CompilationStartAnalysisContext context) static bool HasAutoGeneratedRegistrationTypes(Compilation compilation, CancellationToken cancellationToken) { - var currentClassName = CompilationAssemblyDetails.FromAssembly(compilation.Assembly).ToGenerationClassName(); - if (compilation.GetTypeByMetadataName($"NServiceBus.Generated.{currentClassName}") is not null) + if (HasRegistrationType(compilation, compilation.Assembly)) { return true; } @@ -91,14 +90,20 @@ static bool HasAutoGeneratedRegistrationTypes(Compilation compilation, Cancellat foreach (var assembly in compilation.SourceModule.ReferencedAssemblySymbols) { cancellationToken.ThrowIfCancellationRequested(); - var className = CompilationAssemblyDetails.FromAssembly(assembly).ToGenerationClassName(); - if (compilation.GetTypeByMetadataName($"NServiceBus.Generated.{className}") is not null) + if (HasRegistrationType(compilation, assembly)) { return true; } } return false; + + static bool HasRegistrationType(Compilation compilation, IAssemblySymbol assembly) + { + var details = CompilationAssemblyDetails.FromAssembly(assembly); + return compilation.GetTypeByMetadataName($"NServiceBus.Generated.{details.ToGenerationClassName()}") is not null + || compilation.GetTypeByMetadataName($"NServiceBus.Generated.{details.ToSendOnlyGenerationClassName()}") is not null; + } } static string? InferCompositionNamespace(Compilation compilation, CancellationToken cancellationToken) diff --git a/src/NServiceBus.AzureFunctions.Analyzer/SendOnlyEndpointGenerator.Emitter.cs b/src/NServiceBus.AzureFunctions.Analyzer/SendOnlyEndpointGenerator.Emitter.cs new file mode 100644 index 00000000..8e4ab8fe --- /dev/null +++ b/src/NServiceBus.AzureFunctions.Analyzer/SendOnlyEndpointGenerator.Emitter.cs @@ -0,0 +1,67 @@ +namespace NServiceBus.AzureFunctions.Analyzer; + +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Utility; + +public sealed partial class SendOnlyEndpointGenerator +{ + static class Emitter + { + public static void Emit(SourceProductionContext spc, ImmutableArray sendOnlyEndpoints, string assemblyClassName) + { + if (sendOnlyEndpoints.Length <= 0) + { + return; + } + + var writer = new SourceWriter(); + writer.PreAmble(); + writer.WithFileScopedNamespace("NServiceBus.Generated"); + writer.WriteLine("/// "); + writer.WriteLine("/// Registrations for NServiceBus send-only endpoints in this assembly."); + writer.WriteLine("/// "); + writer.WriteLine("[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]"); + writer.WriteLine("[global::NServiceBus.AutoGeneratedFunctionRegistrationsAttribute]"); + writer.WithGeneratedCodeAttribute(); + writer.WriteLine($"public static class {assemblyClassName}"); + writer.WriteLine("{"); + writer.Indentation++; + writer.WriteLine("/// "); + writer.WriteLine("/// Gets send-only endpoint manifests for NServiceBus endpoints in this assembly."); + writer.WriteLine("/// "); + writer.WriteLine("public static global::System.Collections.Generic.IEnumerable"); + writer.WriteLine(" GetSendOnlyEndpointManifests()"); + writer.WriteLine("{"); + writer.Indentation++; + + foreach (var endpoint in sendOnlyEndpoints.OrderBy(f => f.EndpointName, StringComparer.Ordinal)) + { + var connectionSettingName = endpoint.ConnectionSettingName is not null + ? $"\"{endpoint.ConnectionSettingName}\"" + : "null"; + + writer.WriteLine("yield return new global::NServiceBus.SendOnlyEndpointManifest("); + writer.WriteLine($" \"{endpoint.EndpointName}\","); + writer.WriteLine($" {connectionSettingName},"); + writer.WriteLine($" {GenerateConfigureMethodCall(endpoint.ConfigureMethod)},"); + writer.WriteLine($" {endpoint.RegistrationMethodFullyQualified});"); + } + + writer.WriteLine("yield break;"); + writer.Indentation--; + writer.WriteLine("}"); + writer.Indentation--; + writer.WriteLine("}"); + + spc.AddSource("SendOnlyEndpointRegistration.g.cs", writer.ToSourceText()); + } + + static string GenerateConfigureMethodCall(ConfigureMethodSpec configureMethod) + { + var argumentList = string.Join(", ", configureMethod.ParameterTypeNames); + return $"(endpointconfiguration, iservicecollection, iconfiguration, ihostenvironment) => {configureMethod.ContainingTypeFullyQualified}.{configureMethod.MethodName}({argumentList})"; + } + } +} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.Analyzer/SendOnlyEndpointGenerator.Parser.cs b/src/NServiceBus.AzureFunctions.Analyzer/SendOnlyEndpointGenerator.Parser.cs new file mode 100644 index 00000000..dffdcb22 --- /dev/null +++ b/src/NServiceBus.AzureFunctions.Analyzer/SendOnlyEndpointGenerator.Parser.cs @@ -0,0 +1,184 @@ +namespace NServiceBus.AzureFunctions.Analyzer; + +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using NServiceBus.Core.Analyzer; + +public sealed partial class SendOnlyEndpointGenerator +{ + static class Parser + { + internal static SendOnlyEndpointSpecs Extract(GeneratorAttributeSyntaxContext context, SendOnlyEndpointDefinition sendOnlyEndpointDefinition, CancellationToken cancellationToken = default) + { + if (context.Attributes.Length == 0) + { + return SendOnlyEndpointSpecs.Empty; + } + + cancellationToken.ThrowIfCancellationRequested(); + + if (!SendOnlyEndpointGeneratorKnownTypes.TryGet(context.SemanticModel.Compilation, out var knownTypes)) + { + return SendOnlyEndpointSpecs.Empty; + } + + return context.TargetSymbol switch + { + IMethodSymbol methodSymbol => ExtractFromMethod(methodSymbol, knownTypes, sendOnlyEndpointDefinition), + _ => SendOnlyEndpointSpecs.Empty + }; + } + + static SendOnlyEndpointSpecs ExtractFromMethod(IMethodSymbol methodSymbol, SendOnlyEndpointGeneratorKnownTypes knownTypes, SendOnlyEndpointDefinition sendOnlyEndpointDefinition) + { + var diagnostics = new List(); + var spec = ExtractSendOnlyEndpointSpec(methodSymbol, knownTypes, sendOnlyEndpointDefinition, diagnostics); + var sendOnlyEndpoints = spec is null + ? ImmutableEquatableArray.Empty + : ((SendOnlyEndpointSpec[])[spec]).ToImmutableEquatableArray(); + return new SendOnlyEndpointSpecs(sendOnlyEndpoints, diagnostics.ToImmutableEquatableArray()); + } + + static SendOnlyEndpointSpec? ExtractSendOnlyEndpointSpec( + IMethodSymbol method, + SendOnlyEndpointGeneratorKnownTypes knownTypes, + SendOnlyEndpointDefinition sendOnlyEndpointDefinition, + List diagnostics) + { + if (!method.TryGetAttribute(knownTypes.SendOnlyEndpointAttribute, out var sendOnlyEndpointAttribute) + || sendOnlyEndpointAttribute.ConstructorArguments.Length == 0 + || sendOnlyEndpointAttribute.ConstructorArguments[0].Value is not string endpointName) + { + return null; + } + + var problems = ImmutableList.CreateBuilder(); + + if (!method.IsStatic) + { + problems.Add("method must be static"); + } + + var expectedMethodName = $"Configure{endpointName}"; + if (!string.Equals(method.Name, expectedMethodName, StringComparison.OrdinalIgnoreCase)) + { + problems.Add($"method name must be '{expectedMethodName}'"); + } + + if (method.Parameters.Length == 0 || !SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, knownTypes.EndpointConfiguration)) + { + problems.Add("first parameter must be EndpointConfiguration"); + } + + for (var i = 1; i < method.Parameters.Length; i++) + { + if (!IsAllowedConfigureMethodParameterType(method.Parameters[i].Type, knownTypes)) + { + problems.Add("parameters after EndpointConfiguration must be IServiceCollection, IConfiguration, or IHostEnvironment"); + break; + } + } + + if (problems.Count > 0) + { + diagnostics.Add(method.CreateDiagnostic(DiagnosticIds.InvalidSendOnlyEndpointMethodDescriptor, method.Name, string.Join(", ", problems))); + return null; + } + + var parameterTypeNames = new string[method.Parameters.Length]; + for (var i = 0; i < method.Parameters.Length; i++) + { + parameterTypeNames[i] = method.Parameters[i].Type.Name.ToLowerInvariant(); + } + + var connectionSettingName = ExtractConnectionSettingName(sendOnlyEndpointAttribute); + + return new SendOnlyEndpointSpec( + endpointName, + connectionSettingName, + sendOnlyEndpointDefinition.RegistrationMethodFullyQualified, + new ConfigureMethodSpec( + method.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + method.Name, + parameterTypeNames.ToImmutableEquatableArray())); + } + + static string? ExtractConnectionSettingName(AttributeData attribute) + { + foreach (var namedArg in attribute.NamedArguments) + { + if (namedArg is { Key: "Connection", Value.Value: string connectionName }) + { + return connectionName; + } + } + + return null; + } + + static bool IsAllowedConfigureMethodParameterType(ITypeSymbol parameterType, SendOnlyEndpointGeneratorKnownTypes knownTypes) + => parameterType.IsAllowedConfigureMethodParameterType(knownTypes.IServiceCollection, knownTypes.IConfiguration, knownTypes.IHostEnvironment); + } + + internal readonly record struct ConfigureMethodSpec(string ContainingTypeFullyQualified, string MethodName, ImmutableEquatableArray ParameterTypeNames); + + internal sealed record SendOnlyEndpointSpec( + string EndpointName, + string? ConnectionSettingName, + string RegistrationMethodFullyQualified, + ConfigureMethodSpec ConfigureMethod); + + internal readonly record struct SendOnlyEndpointSpecs(ImmutableEquatableArray SendOnlyEndpoints, ImmutableEquatableArray Diagnostics) : IDiagnosticsSpec + { + public static SendOnlyEndpointSpecs Empty { get; } = new(ImmutableEquatableArray.Empty, ImmutableEquatableArray.Empty); + } + + internal readonly record struct SendOnlyEndpointDefinition + { + public string RegistrationMethodFullyQualified { get; } = $"global::{KnownTypeNames.AzureServiceBusFunctionsHostApplicationBuilderExtensions}.{KnownTypeNames.AddNServiceBusAzureServiceBusSendOnlyEndpoint}"; + + public SendOnlyEndpointDefinition() { } + } + + readonly struct SendOnlyEndpointGeneratorKnownTypes( + INamedTypeSymbol sendOnlyEndpointAttribute, + INamedTypeSymbol endpointConfiguration, + INamedTypeSymbol iServiceCollection, + INamedTypeSymbol iConfiguration, + INamedTypeSymbol iHostEnvironment) + { + public INamedTypeSymbol SendOnlyEndpointAttribute { get; } = sendOnlyEndpointAttribute; + public INamedTypeSymbol EndpointConfiguration { get; } = endpointConfiguration; + public INamedTypeSymbol IServiceCollection { get; } = iServiceCollection; + public INamedTypeSymbol IConfiguration { get; } = iConfiguration; + public INamedTypeSymbol IHostEnvironment { get; } = iHostEnvironment; + + public static bool TryGet(Compilation compilation, out SendOnlyEndpointGeneratorKnownTypes knownTypes) + { + var sendOnlyEndpointAttribute = compilation.GetTypeByMetadataName(KnownTypeNames.NServiceBusSendOnlyFunctionAttribute); + var endpointConfiguration = compilation.GetTypeByMetadataName(KnownTypeNames.EndpointConfigurationType); + var iServiceCollection = compilation.GetTypeByMetadataName(KnownTypeNames.IServiceCollection); + var iconfiguration = compilation.GetTypeByMetadataName(KnownTypeNames.IConfiguration); + var iHostEnvironment = compilation.GetTypeByMetadataName(KnownTypeNames.IHostEnvironment); + + if (sendOnlyEndpointAttribute is null + || endpointConfiguration is null + || iServiceCollection is null + || iconfiguration is null + || iHostEnvironment is null) + { + knownTypes = default; + return false; + } + + knownTypes = new SendOnlyEndpointGeneratorKnownTypes( + sendOnlyEndpointAttribute, + endpointConfiguration, + iServiceCollection, + iconfiguration, + iHostEnvironment); + + return true; + } + } +} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.Analyzer/SendOnlyEndpointGenerator.TrackingNames.cs b/src/NServiceBus.AzureFunctions.Analyzer/SendOnlyEndpointGenerator.TrackingNames.cs new file mode 100644 index 00000000..246d8a92 --- /dev/null +++ b/src/NServiceBus.AzureFunctions.Analyzer/SendOnlyEndpointGenerator.TrackingNames.cs @@ -0,0 +1,15 @@ +namespace NServiceBus.AzureFunctions.Analyzer; + +public sealed partial class SendOnlyEndpointGenerator +{ + internal static class TrackingNames + { + public const string Extraction = nameof(Extraction); + public const string Diagnostics = nameof(Diagnostics); + public const string SendOnlyEndpoints = nameof(SendOnlyEndpoints); + public const string AssemblyClassName = nameof(AssemblyClassName); + public const string Combined = nameof(Combined); + + public static string[] All => [Extraction, Diagnostics, SendOnlyEndpoints, AssemblyClassName, Combined]; + } +} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.Analyzer/SendOnlyEndpointGenerator.cs b/src/NServiceBus.AzureFunctions.Analyzer/SendOnlyEndpointGenerator.cs new file mode 100644 index 00000000..b06cf03b --- /dev/null +++ b/src/NServiceBus.AzureFunctions.Analyzer/SendOnlyEndpointGenerator.cs @@ -0,0 +1,43 @@ +namespace NServiceBus.AzureFunctions.Analyzer; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +[Generator] +public sealed partial class SendOnlyEndpointGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var extractionCandidates = context.SyntaxProvider + .ForAttributeWithMetadataName( + KnownTypeNames.NServiceBusSendOnlyFunctionAttribute, + predicate: static (node, _) => node is MethodDeclarationSyntax, + transform: static (ctx, _) => ctx); + + var extractionResults = extractionCandidates + .Combine(context.CompilationProvider.Select(static (_, _) => new SendOnlyEndpointDefinition())) + .Select(static (pair, ct) => Parser.Extract(pair.Left, pair.Right, ct)) + .WithTrackingName(TrackingNames.Extraction); + + var diagnostics = extractionResults + .Collect() + .SelectMany(static (results, _) => results.ToDiagnostics()) + .WithTrackingName(TrackingNames.Diagnostics); + + context.RegisterSourceOutput(diagnostics, static (spc, diag) => spc.ReportDiagnostic(diag)); + + var sendOnlyEndpointSpecs = extractionResults + .SelectMany(static (result, _) => result.SendOnlyEndpoints) + .WithTrackingName(TrackingNames.SendOnlyEndpoints); + + var assemblyClassName = context.CompilationProvider + .Select(static (c, _) => CompilationAssemblyDetails.FromAssembly(c.Assembly).ToSendOnlyGenerationClassName()) + .WithTrackingName(TrackingNames.AssemblyClassName); + + var combined = sendOnlyEndpointSpecs.Collect() + .Combine(assemblyClassName) + .WithTrackingName(TrackingNames.Combined); + + context.RegisterSourceOutput(combined, static (spc, data) => Emitter.Emit(spc, data.Left, data.Right)); + } +} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.Analyzer/SymbolExtensions.cs b/src/NServiceBus.AzureFunctions.Analyzer/SymbolExtensions.cs new file mode 100644 index 00000000..f9919c32 --- /dev/null +++ b/src/NServiceBus.AzureFunctions.Analyzer/SymbolExtensions.cs @@ -0,0 +1,44 @@ +namespace NServiceBus.AzureFunctions.Analyzer; + +using System.Diagnostics.CodeAnalysis; +using Microsoft.CodeAnalysis; + +static class SymbolExtensions +{ + extension(ISymbol symbol) + { + public bool HasAttribute(INamedTypeSymbol attributeType) + { + foreach (var attribute in symbol.GetAttributes()) + { + if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, attributeType)) + { + return true; + } + } + + return false; + } + + public bool TryGetAttribute(INamedTypeSymbol attributeType, [NotNullWhen(true)] out AttributeData? attributeData) + { + foreach (var attribute in symbol.GetAttributes()) + { + if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, attributeType)) + { + attributeData = attribute; + return true; + } + } + + attributeData = null; + return false; + } + + public Diagnostic CreateDiagnostic(DiagnosticDescriptor descriptor, params object[] arguments) + { + var location = symbol.Locations.Length > 0 ? symbol.Locations[0] : null; + return Diagnostic.Create(descriptor, location, arguments); + } + } +} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.Analyzer/TypeSymbolExtensions.cs b/src/NServiceBus.AzureFunctions.Analyzer/TypeSymbolExtensions.cs new file mode 100644 index 00000000..87467ead --- /dev/null +++ b/src/NServiceBus.AzureFunctions.Analyzer/TypeSymbolExtensions.cs @@ -0,0 +1,17 @@ +namespace NServiceBus.AzureFunctions.Analyzer; + +using Microsoft.CodeAnalysis; + +static class TypeSymbolExtensions +{ + extension(ITypeSymbol type) + { + public bool IsAllowedConfigureMethodParameterType( + INamedTypeSymbol iServiceCollection, + INamedTypeSymbol iConfiguration, + INamedTypeSymbol iHostEnvironment) + => SymbolEqualityComparer.Default.Equals(type, iServiceCollection) + || SymbolEqualityComparer.Default.Equals(type, iConfiguration) + || SymbolEqualityComparer.Default.Equals(type, iHostEnvironment); + } +} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusFunctionsHostApplicationBuilderExtensions.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusFunctionsHostApplicationBuilderExtensions.cs index 1ba1324f..25f46052 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusFunctionsHostApplicationBuilderExtensions.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusFunctionsHostApplicationBuilderExtensions.cs @@ -21,21 +21,47 @@ public static void AddNServiceBusAzureServiceBusFunction(this FunctionsApplicati { builder.Services.AddAzureClientsCore(); - var endpointConfiguration = FunctionEndpointConfigurationBuilder.BuildReceiveEndpointConfiguration(builder, functionManifest, nameof(FunctionsHostApplicationBuilderExtensions.AddSendOnlyNServiceBusEndpoint)); + var endpointConfiguration = FunctionEndpointConfigurationBuilder.BuildReceiveEndpointConfiguration(builder, functionManifest); var transport = GetAzureServiceBusTransport(endpointConfiguration); - var resolvedConnectionSettingName = string.IsNullOrWhiteSpace(functionManifest.ConnectionSettingName) - ? functionManifest.ConnectionSettingName - : FunctionBindingExpression.Resolve(functionManifest.ConnectionSettingName, builder.Configuration); - transport.ConnectionName = resolvedConnectionSettingName; + ApplyConnectionSettingName(transport, functionManifest); + builder.Services.AddNServiceBusEndpoint(endpointConfiguration, endpointConfiguration.EndpointName); builder.Services.AddKeyedSingleton(functionManifest.Name, (_, _) => new AzureServiceBusMessageProcessor(transport, functionManifest.Name)); } + /// + /// Adds the necessary services to the container to support the send-only endpoint described by the provided . + /// Should only be called by the source generator. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static void AddNServiceBusAzureServiceBusSendOnlyEndpoint(this FunctionsApplicationBuilder builder, SendOnlyEndpointManifest sendOnlyEndpointManifest) + { + builder.Services.AddAzureClientsCore(); + + var endpointConfiguration = FunctionEndpointConfigurationBuilder.BuildSendOnlyEndpointConfiguration(builder, sendOnlyEndpointManifest); + var transport = GetAzureServiceBusTransport(endpointConfiguration); + + ApplyConnectionSettingName(transport, sendOnlyEndpointManifest); + + builder.Services.AddNServiceBusEndpoint(endpointConfiguration, endpointConfiguration.EndpointName); + } + static AzureServiceBusServerlessTransport GetAzureServiceBusTransport(EndpointConfiguration endpointConfiguration) { var transport = endpointConfiguration.GetSettings().GetOrDefault() as AzureServiceBusServerlessTransport; return transport ?? throw new InvalidOperationException($"Endpoint '{endpointConfiguration.EndpointName}' must be configured with an '{nameof(AzureServiceBusServerlessTransport)}'."); } + + static void ApplyConnectionSettingName(AzureServiceBusServerlessTransport transport, IConnectionSettingManifest manifest) + { + if (!string.IsNullOrEmpty(manifest.ConnectionSettingName)) + { + // the connection name is resolved at runtime from the configuration and doesn't need to + // support binding expressions since Azure Functions also doesn't support them for trigger connection settings. + // The binding expression always wins + transport.ConnectionName = manifest.ConnectionSettingName; + } + } } \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusServerlessTransport.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusServerlessTransport.cs index 8df3e651..5628c3a6 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusServerlessTransport.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusServerlessTransport.cs @@ -23,7 +23,7 @@ public class AzureServiceBusServerlessTransport : TransportDefinition { /// /// Creates a new transport using the supplied . The connection is - /// resolved from configuration during via . + /// resolved from configuration during . /// /// The topic topology describing how events are published and subscribed to. public AzureServiceBusServerlessTransport(TopicTopology topology) : base(TransportTransactionMode.ReceiveOnly, @@ -128,7 +128,7 @@ public HierarchyNamespaceOptions HierarchyNamespaceOptions /// AzureWebJobsServiceBus, matching the setting used by Service Bus triggers. /// /// The resolved value may be a connection string, or a configuration section containing a fullyQualifiedNamespace entry for token-credential authentication. - public string ConnectionName { get; set; } = DefaultServiceBusConnectionName; + internal string ConnectionName { get; set; } = DefaultServiceBusConnectionName; /// public override async Task Initialize( diff --git a/src/NServiceBus.AzureFunctions.Common/FunctionEndpointConfigurationBuilder.cs b/src/NServiceBus.AzureFunctions.Common/FunctionEndpointConfigurationBuilder.cs index b871db45..22162d99 100644 --- a/src/NServiceBus.AzureFunctions.Common/FunctionEndpointConfigurationBuilder.cs +++ b/src/NServiceBus.AzureFunctions.Common/FunctionEndpointConfigurationBuilder.cs @@ -19,12 +19,10 @@ public static class FunctionEndpointConfigurationBuilder /// /// The Functions application builder the endpoint is attached to. /// The manifest describing the endpoint to configure. - /// Name of the send-only API included in the exception thrown when a send-only configuration is detected. /// Thrown when the supplied manifest configures the endpoint as send-only. public static EndpointConfiguration BuildReceiveEndpointConfiguration( FunctionsApplicationBuilder builder, - FunctionManifest functionManifest, - string sendOnlyEndpointApiName) + FunctionManifest functionManifest) { ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(functionManifest); @@ -37,7 +35,7 @@ public static EndpointConfiguration BuildReceiveEndpointConfiguration( if (endpointConfiguration.IsSendOnly) { - throw new InvalidOperationException($"Functions can't be send only endpoints, use {sendOnlyEndpointApiName}"); + throw new InvalidOperationException($"Functions can't be send-only endpoints, use [{typeof(NServiceBusSendOnlyFunctionAttribute)}] to create send-only endpoints."); } var resolvedAddress = FunctionBindingExpression.Resolve(functionManifest.Address, builder.Configuration); @@ -50,22 +48,21 @@ public static EndpointConfiguration BuildReceiveEndpointConfiguration( } /// - /// Builds a send-only with the customizations supplied via - /// . + /// Builds a send-only from the supplied . /// /// The Functions application builder the endpoint is attached to. - /// The logical name of the send-only endpoint. - /// Callback invoked to configure the endpoint and register endpoint-specific services. + /// The manifest describing the send-only endpoint to configure. public static EndpointConfiguration BuildSendOnlyEndpointConfiguration( FunctionsApplicationBuilder builder, - string endpointName, - Action configure) + SendOnlyEndpointManifest manifest) { ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(endpointName); - ArgumentNullException.ThrowIfNull(configure); + ArgumentNullException.ThrowIfNull(manifest); - var endpointConfiguration = CreateDefaultEndpointConfiguration(endpointName, builder, configure); + var endpointConfiguration = CreateDefaultEndpointConfiguration( + manifest.Name, + builder, + (configuration, endpointServices) => manifest.Configuration(configuration, endpointServices, builder.Configuration, builder.Environment)); endpointConfiguration.SendOnly(); diff --git a/src/NServiceBus.AzureFunctions.Common/FunctionManifest.cs b/src/NServiceBus.AzureFunctions.Common/FunctionManifest.cs index d9bf9aa8..2492131b 100644 --- a/src/NServiceBus.AzureFunctions.Common/FunctionManifest.cs +++ b/src/NServiceBus.AzureFunctions.Common/FunctionManifest.cs @@ -13,4 +13,4 @@ namespace NServiceBus; /// The configuration key whose value resolves to the transport connection (for example, a connection string or fully-qualified namespace). /// Callback invoked to customize the endpoint configuration and its service registrations. /// Transport-specific callback that registers the endpoint with the Functions host builder. -public sealed record FunctionManifest(string Name, string Address, string ConnectionSettingName, FunctionEndpointConfiguration Configuration, Action Register); +public sealed record FunctionManifest(string Name, string Address, string ConnectionSettingName, FunctionEndpointConfiguration Configuration, Action Register) : IConnectionSettingManifest; \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.Common/FunctionsHostApplicationBuilderExtensions.cs b/src/NServiceBus.AzureFunctions.Common/FunctionsHostApplicationBuilderExtensions.cs deleted file mode 100644 index 23bdb292..00000000 --- a/src/NServiceBus.AzureFunctions.Common/FunctionsHostApplicationBuilderExtensions.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace NServiceBus; - -using System; -using Microsoft.Azure.Functions.Worker.Builder; -using Microsoft.Extensions.DependencyInjection; - -/// -/// Extensions methods to configure the FunctionsApplicationBuilder with NServiceBus endpoints. -/// -public static class FunctionsHostApplicationBuilderExtensions -{ - /// The functions application builder. - extension(FunctionsApplicationBuilder builder) - { - /// - /// Adds an NServiceBus endpoint to the Azure Functions host. The endpoint will be configured as send-only. - /// - /// It is possible to use any transport as send-only with this method but only the Serverless variants like AzureServiceBusServerlessTransport will provide first class - /// functions host integration support. - /// The endpoint name. - /// The configuration action to configure the endpoint configuration. - public void AddSendOnlyNServiceBusEndpoint(string endpointName, - Action configure) => builder.AddSendOnlyNServiceBusEndpoint(endpointName, (endpointConfiguration, _) => configure(endpointConfiguration)); - - /// - /// Adds an NServiceBus endpoint to the Azure Functions host. The endpoint will be configured as send-only. - /// - /// It is possible to use any transport as send-only with this method but only the Serverless variants like AzureServiceBusServerlessTransport will provide first class - /// functions host integration support. - /// The endpoint name. - /// The configuration action to configure the endpoint configuration and the endpoint-specific services, if any. - public void AddSendOnlyNServiceBusEndpoint(string endpointName, - Action configure) - { - var endpointConfiguration = FunctionEndpointConfigurationBuilder.BuildSendOnlyEndpointConfiguration(builder, endpointName, configure); - - builder.Services.AddNServiceBusEndpoint(endpointConfiguration, endpointName); - } - } -} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.Common/IConnectionSettingManifest.cs b/src/NServiceBus.AzureFunctions.Common/IConnectionSettingManifest.cs new file mode 100644 index 00000000..70054b17 --- /dev/null +++ b/src/NServiceBus.AzureFunctions.Common/IConnectionSettingManifest.cs @@ -0,0 +1,15 @@ +namespace NServiceBus; + +/// +/// Provides the name of the configuration key used to resolve the transport connection. +/// Implemented by manifest types produced by the source generator. +/// +/// The API surface might be changed between versions according to the needs of the source generator. +public interface IConnectionSettingManifest +{ + /// + /// The name of the application setting or configuration section that contains the transport connection details, + /// or to use the transport's default. + /// + string? ConnectionSettingName { get; } +} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.Common/NServiceBusSendOnlyFunctionAttribute.cs b/src/NServiceBus.AzureFunctions.Common/NServiceBusSendOnlyFunctionAttribute.cs new file mode 100644 index 00000000..e8b08d86 --- /dev/null +++ b/src/NServiceBus.AzureFunctions.Common/NServiceBusSendOnlyFunctionAttribute.cs @@ -0,0 +1,24 @@ +namespace NServiceBus; + +/// +/// Marks a static configuration method as the source of a send-only NServiceBus endpoint hosted in Azure Functions. +/// The source generator produces endpoint registration code from methods marked with this attribute. +/// +[AttributeUsage(AttributeTargets.Method, Inherited = false)] +public sealed class NServiceBusSendOnlyFunctionAttribute(string endpointName) : Attribute +{ + /// + /// Gets the logical endpoint name. + /// + public string EndpointName { get; } = endpointName; + + /// + /// Gets or sets the name of the application setting or configuration section that contains the transport connection details. + /// When not set, the trigger's default connection name is used. + /// + /// + /// This property holds the name of a configuration key, not the connection string value itself. + /// At runtime, the transport looks up this key in the application configuration to resolve the actual connection details. + /// + public string? Connection { get; set; } +} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.Common/SendOnlyEndpointManifest.cs b/src/NServiceBus.AzureFunctions.Common/SendOnlyEndpointManifest.cs new file mode 100644 index 00000000..68b97bcb --- /dev/null +++ b/src/NServiceBus.AzureFunctions.Common/SendOnlyEndpointManifest.cs @@ -0,0 +1,19 @@ +namespace NServiceBus; + +using Microsoft.Azure.Functions.Worker.Builder; + +/// +/// Describes a send-only NServiceBus endpoint hosted in Azure Functions. Produced by the source generator +/// and passed to the transport-specific registration method referenced by . +/// Should only be created by the source generator. +/// +/// The API surface might be changed between versions according to the needs of the source generator. +/// The endpoint name. +/// The name of the application setting or configuration section that contains the transport connection details, or to use the transport's default. +/// Callback invoked to customize the endpoint configuration and its service registrations. +/// Transport-specific callback that registers the endpoint with the Functions host builder. +public sealed record SendOnlyEndpointManifest( + string Name, + string? ConnectionSettingName, + FunctionEndpointConfiguration Configuration, + Action Register) : IConnectionSettingManifest; \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.Tests/ApprovalFiles/ApiApprovals.ApproveAzureServiceBusComponentApi.approved.txt b/src/NServiceBus.AzureFunctions.Tests/ApprovalFiles/ApiApprovals.ApproveAzureServiceBusComponentApi.approved.txt index dc8730e6..29ccf8ed 100644 --- a/src/NServiceBus.AzureFunctions.Tests/ApprovalFiles/ApiApprovals.ApproveAzureServiceBusComponentApi.approved.txt +++ b/src/NServiceBus.AzureFunctions.Tests/ApprovalFiles/ApiApprovals.ApproveAzureServiceBusComponentApi.approved.txt @@ -13,7 +13,6 @@ namespace NServiceBus { public AzureServiceBusServerlessTransport(NServiceBus.TopicTopology topology) { } public bool AutoForwardDeadLetteredMessagesToErrorQueue { get; set; } - public string ConnectionName { get; set; } public bool EnablePartitioning { get; set; } public int EntityMaximumSize { get; set; } public NServiceBus.Transport.AzureServiceBus.HierarchyNamespaceOptions HierarchyNamespaceOptions { get; set; } @@ -29,5 +28,6 @@ namespace NServiceBus.Configuration.AdvancedExtensibility public static class AzureServiceBusFunctionsHostApplicationBuilderExtensions { public static void AddNServiceBusAzureServiceBusFunction(this Microsoft.Azure.Functions.Worker.Builder.FunctionsApplicationBuilder builder, NServiceBus.FunctionManifest functionManifest) { } + public static void AddNServiceBusAzureServiceBusSendOnlyEndpoint(this Microsoft.Azure.Functions.Worker.Builder.FunctionsApplicationBuilder builder, NServiceBus.SendOnlyEndpointManifest sendOnlyEndpointManifest) { } } } \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.Tests/ApprovalFiles/ApiApprovals.ApproveFunctionsComponentApi.approved.txt b/src/NServiceBus.AzureFunctions.Tests/ApprovalFiles/ApiApprovals.ApproveFunctionsComponentApi.approved.txt index 59950049..ed90791c 100644 --- a/src/NServiceBus.AzureFunctions.Tests/ApprovalFiles/ApiApprovals.ApproveFunctionsComponentApi.approved.txt +++ b/src/NServiceBus.AzureFunctions.Tests/ApprovalFiles/ApiApprovals.ApproveFunctionsComponentApi.approved.txt @@ -12,10 +12,10 @@ namespace NServiceBus public delegate void FunctionEndpointConfiguration(NServiceBus.EndpointConfiguration endpoint, Microsoft.Extensions.DependencyInjection.IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration, Microsoft.Extensions.Hosting.IHostEnvironment environment); public static class FunctionEndpointConfigurationBuilder { - public static NServiceBus.EndpointConfiguration BuildReceiveEndpointConfiguration(Microsoft.Azure.Functions.Worker.Builder.FunctionsApplicationBuilder builder, NServiceBus.FunctionManifest functionManifest, string sendOnlyEndpointApiName) { } - public static NServiceBus.EndpointConfiguration BuildSendOnlyEndpointConfiguration(Microsoft.Azure.Functions.Worker.Builder.FunctionsApplicationBuilder builder, string endpointName, System.Action configure) { } + public static NServiceBus.EndpointConfiguration BuildReceiveEndpointConfiguration(Microsoft.Azure.Functions.Worker.Builder.FunctionsApplicationBuilder builder, NServiceBus.FunctionManifest functionManifest) { } + public static NServiceBus.EndpointConfiguration BuildSendOnlyEndpointConfiguration(Microsoft.Azure.Functions.Worker.Builder.FunctionsApplicationBuilder builder, NServiceBus.SendOnlyEndpointManifest manifest) { } } - public sealed class FunctionManifest : System.IEquatable + public sealed class FunctionManifest : NServiceBus.IConnectionSettingManifest, System.IEquatable { public FunctionManifest(string Name, string Address, string ConnectionSettingName, NServiceBus.FunctionEndpointConfiguration Configuration, System.Action Register) { } public string Address { get; init; } @@ -24,19 +24,30 @@ namespace NServiceBus public string Name { get; init; } public System.Action Register { get; init; } } - public static class FunctionsHostApplicationBuilderExtensions + public interface IConnectionSettingManifest { - extension(Microsoft.Azure.Functions.Worker.Builder.FunctionsApplicationBuilder builder) - { - public void AddSendOnlyNServiceBusEndpoint(string endpointName, System.Action configure) { } - public void AddSendOnlyNServiceBusEndpoint(string endpointName, System.Action configure) { } - } + string? ConnectionSettingName { get; } } [System.AttributeUsage(System.AttributeTargets.Method, Inherited=false)] public sealed class NServiceBusFunctionAttribute : System.Attribute { public NServiceBusFunctionAttribute() { } } + [System.AttributeUsage(System.AttributeTargets.Method, Inherited=false)] + public sealed class NServiceBusSendOnlyFunctionAttribute : System.Attribute + { + public NServiceBusSendOnlyFunctionAttribute(string endpointName) { } + public string? Connection { get; set; } + public string EndpointName { get; } + } + public sealed class SendOnlyEndpointManifest : NServiceBus.IConnectionSettingManifest, System.IEquatable + { + public SendOnlyEndpointManifest(string Name, string? ConnectionSettingName, NServiceBus.FunctionEndpointConfiguration Configuration, System.Action Register) { } + public NServiceBus.FunctionEndpointConfiguration Configuration { get; init; } + public string? ConnectionSettingName { get; init; } + public string Name { get; init; } + public System.Action Register { get; init; } + } public sealed class ServerlessTransportInfrastructure : NServiceBus.Transport.TransportInfrastructure { public ServerlessTransportInfrastructure(NServiceBus.Transport.TransportInfrastructure baseTransportInfrastructure, System.Func messageReceiverFactory) { } diff --git a/src/Tests.Analyzers/ApprovalFiles/FunctionCompositionGeneratorTests.GeneratesProjectComposition.approved.txt b/src/Tests.Analyzers/ApprovalFiles/FunctionCompositionGeneratorTests.GeneratesProjectComposition.approved.txt index d5dc2f21..abcaff1d 100644 --- a/src/Tests.Analyzers/ApprovalFiles/FunctionCompositionGeneratorTests.GeneratesProjectComposition.approved.txt +++ b/src/Tests.Analyzers/ApprovalFiles/FunctionCompositionGeneratorTests.GeneratesProjectComposition.approved.txt @@ -1,13 +1,12 @@ -// == GlobalUsings.cs ================================================================================================== -global using System.Threading; -global using System.Threading.Tasks; -global using Microsoft.Azure.Functions.Worker; -global using Azure.Messaging.ServiceBus; -global using Microsoft.Extensions.Configuration; -global using Microsoft.Extensions.Hosting; -global using NServiceBus; - -// == Source01.cs ====================================================================================================== +// == Source00.cs ====================================================================================================== +using System.Threading; +using System.Threading.Tasks; +using Azure.Messaging.ServiceBus; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using NServiceBus; + namespace Demo; public partial class Functions @@ -28,6 +27,20 @@ public partial class Functions } } +// == SendOnly.cs ====================================================================================================== +using NServiceBus; +namespace Demo; + +using Microsoft.Extensions.DependencyInjection; + +public static class ClientEndpoint +{ + [NServiceBusSendOnlyFunction("client")] + public static void ConfigureClient(EndpointConfiguration endpointConfiguration, IServiceCollection services) + { + } +} + // == NServiceBus.AzureFunctions.Analyzer/NServiceBus.AzureFunctions.Analyzer.FunctionCompositionGenerator/Composition.cs == // @@ -52,6 +65,10 @@ internal static class NServiceBusFunctionsComposition { manifest.Register(builder, manifest); } + foreach (var manifest in global::NServiceBus.Generated.GeneratedSendOnlyEndpointRegistrations_GeneratesProjectComposition_4d91953014478208.GetSendOnlyEndpointManifests()) + { + manifest.Register(builder, manifest); + } } } } @@ -116,4 +133,38 @@ public static class GeneratedFunctionRegistrations_GeneratesProjectComposition_4 global::NServiceBus.Configuration.AdvancedExtensibility.AzureServiceBusFunctionsHostApplicationBuilderExtensions.AddNServiceBusAzureServiceBusFunction); yield break; } +} + +// == NServiceBus.AzureFunctions.Analyzer/NServiceBus.AzureFunctions.Analyzer.SendOnlyEndpointGenerator/SendOnlyEndpointRegistration.g.cs == +// + +#nullable enable annotations +#nullable disable warnings + +// Suppress warnings about [Obsolete] usage in generated code. +#pragma warning disable CS0612, CS0618 + +namespace NServiceBus.Generated; + +/// +/// Registrations for NServiceBus send-only endpoints in this assembly. +/// +[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] +[global::NServiceBus.AutoGeneratedFunctionRegistrationsAttribute] +[global::System.CodeDom.Compiler.GeneratedCodeAttribute("NService.Core.Analyzer.Tests", "1.0.0")] +public static class GeneratedSendOnlyEndpointRegistrations_GeneratesProjectComposition_4d91953014478208 +{ + /// + /// Gets send-only endpoint manifests for NServiceBus endpoints in this assembly. + /// + public static global::System.Collections.Generic.IEnumerable + GetSendOnlyEndpointManifests() + { + yield return new global::NServiceBus.SendOnlyEndpointManifest( + "client", + null, + (endpointconfiguration, iservicecollection, iconfiguration, ihostenvironment) => global::Demo.ClientEndpoint.ConfigureClient(endpointconfiguration, iservicecollection), + global::NServiceBus.Configuration.AdvancedExtensibility.AzureServiceBusFunctionsHostApplicationBuilderExtensions.AddNServiceBusAzureServiceBusSendOnlyEndpoint); + yield break; + } } \ No newline at end of file diff --git a/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorLenientShapeTests.GeneratesEndpointWithExtraUnrecognizedParametersWhenShapeAllowsAdditionalParameters.approved.txt b/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorLenientShapeTests.GeneratesEndpointWithExtraUnrecognizedParametersWhenShapeAllowsAdditionalParameters.approved.txt index 77d98b5d..d9d1e968 100644 --- a/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorLenientShapeTests.GeneratesEndpointWithExtraUnrecognizedParametersWhenShapeAllowsAdditionalParameters.approved.txt +++ b/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorLenientShapeTests.GeneratesEndpointWithExtraUnrecognizedParametersWhenShapeAllowsAdditionalParameters.approved.txt @@ -1,8 +1,3 @@ -// == Compilation Diagnostics ========================================================================================== -GlobalUsings.cs(4,1): hidden CS8019: Unnecessary using directive. -GlobalUsings.cs(5,1): hidden CS8019: Unnecessary using directive. -GlobalUsings.cs(6,1): hidden CS8019: Unnecessary using directive. - // == NServiceBus.AzureFunctions.Analyzers.Tests/NServiceBus.AzureFunctions.Analyzers.Tests.LenientNoMessageActionsGenerator/FunctionMethodBodies.g.cs == // diff --git a/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorTests.GeneratesEndpointWithoutMessageActions.approved.txt b/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorTests.GeneratesEndpointWithoutMessageActions.approved.txt index 2557dd15..3d076a05 100644 --- a/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorTests.GeneratesEndpointWithoutMessageActions.approved.txt +++ b/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorTests.GeneratesEndpointWithoutMessageActions.approved.txt @@ -1,8 +1,3 @@ -// == Compilation Diagnostics ========================================================================================== -GlobalUsings.cs(4,1): hidden CS8019: Unnecessary using directive. -GlobalUsings.cs(5,1): hidden CS8019: Unnecessary using directive. -GlobalUsings.cs(6,1): hidden CS8019: Unnecessary using directive. - // == NServiceBus.AzureFunctions.Analyzers.Tests/NServiceBus.AzureFunctions.Analyzers.Tests.NoMessageActionsGenerator/FunctionMethodBodies.g.cs == // diff --git a/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorTests.GeneratesNoRegistrationsForOrdinaryFunctionsOnly.approved.txt b/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorTests.GeneratesNoRegistrationsForOrdinaryFunctionsOnly.approved.txt index 72695e4b..e69de29b 100644 --- a/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorTests.GeneratesNoRegistrationsForOrdinaryFunctionsOnly.approved.txt +++ b/src/Tests.Analyzers/ApprovalFiles/FunctionEndpointGeneratorTests.GeneratesNoRegistrationsForOrdinaryFunctionsOnly.approved.txt @@ -1,4 +0,0 @@ -// == Compilation Diagnostics ========================================================================================== -GlobalUsings.cs(5,1): hidden CS8019: Unnecessary using directive. -GlobalUsings.cs(6,1): hidden CS8019: Unnecessary using directive. -GlobalUsings.cs(7,1): hidden CS8019: Unnecessary using directive. \ No newline at end of file diff --git a/src/Tests.Analyzers/ApprovalFiles/SendOnlyEndpointGeneratorTests.GeneratesMultipleSendOnlyEndpoints.approved.txt b/src/Tests.Analyzers/ApprovalFiles/SendOnlyEndpointGeneratorTests.GeneratesMultipleSendOnlyEndpoints.approved.txt new file mode 100644 index 00000000..a5f1d89b --- /dev/null +++ b/src/Tests.Analyzers/ApprovalFiles/SendOnlyEndpointGeneratorTests.GeneratesMultipleSendOnlyEndpoints.approved.txt @@ -0,0 +1,38 @@ +// == NServiceBus.AzureFunctions.Analyzer/NServiceBus.AzureFunctions.Analyzer.SendOnlyEndpointGenerator/SendOnlyEndpointRegistration.g.cs == +// + +#nullable enable annotations +#nullable disable warnings + +// Suppress warnings about [Obsolete] usage in generated code. +#pragma warning disable CS0612, CS0618 + +namespace NServiceBus.Generated; + +/// +/// Registrations for NServiceBus send-only endpoints in this assembly. +/// +[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] +[global::NServiceBus.AutoGeneratedFunctionRegistrationsAttribute] +[global::System.CodeDom.Compiler.GeneratedCodeAttribute("NService.Core.Analyzer.Tests", "1.0.0")] +public static class GeneratedSendOnlyEndpointRegistrations_GeneratesMultipleSendOnlyEndpoints_0a5bc3aa83395047 +{ + /// + /// Gets send-only endpoint manifests for NServiceBus endpoints in this assembly. + /// + public static global::System.Collections.Generic.IEnumerable + GetSendOnlyEndpointManifests() + { + yield return new global::NServiceBus.SendOnlyEndpointManifest( + "client", + null, + (endpointconfiguration, iservicecollection, iconfiguration, ihostenvironment) => global::Demo.ClientEndpoint.ConfigureClient(endpointconfiguration, iservicecollection), + global::NServiceBus.Configuration.AdvancedExtensibility.AzureServiceBusFunctionsHostApplicationBuilderExtensions.AddNServiceBusAzureServiceBusSendOnlyEndpoint); + yield return new global::NServiceBus.SendOnlyEndpointManifest( + "sender", + null, + (endpointconfiguration, iservicecollection, iconfiguration, ihostenvironment) => global::Demo.SenderEndpoint.ConfigureSender(endpointconfiguration, iconfiguration, ihostenvironment), + global::NServiceBus.Configuration.AdvancedExtensibility.AzureServiceBusFunctionsHostApplicationBuilderExtensions.AddNServiceBusAzureServiceBusSendOnlyEndpoint); + yield break; + } +} \ No newline at end of file diff --git a/src/Tests.Analyzers/ApprovalFiles/SendOnlyEndpointGeneratorTests.GeneratesNoRegistrationsWhenNoSendOnlyEndpoints.approved.txt b/src/Tests.Analyzers/ApprovalFiles/SendOnlyEndpointGeneratorTests.GeneratesNoRegistrationsWhenNoSendOnlyEndpoints.approved.txt new file mode 100644 index 00000000..e69de29b diff --git a/src/Tests.Analyzers/ApprovalFiles/SendOnlyEndpointGeneratorTests.GeneratesSendOnlyEndpointInGlobalNamespace.approved.txt b/src/Tests.Analyzers/ApprovalFiles/SendOnlyEndpointGeneratorTests.GeneratesSendOnlyEndpointInGlobalNamespace.approved.txt new file mode 100644 index 00000000..00e9b04d --- /dev/null +++ b/src/Tests.Analyzers/ApprovalFiles/SendOnlyEndpointGeneratorTests.GeneratesSendOnlyEndpointInGlobalNamespace.approved.txt @@ -0,0 +1,33 @@ +// == NServiceBus.AzureFunctions.Analyzer/NServiceBus.AzureFunctions.Analyzer.SendOnlyEndpointGenerator/SendOnlyEndpointRegistration.g.cs == +// + +#nullable enable annotations +#nullable disable warnings + +// Suppress warnings about [Obsolete] usage in generated code. +#pragma warning disable CS0612, CS0618 + +namespace NServiceBus.Generated; + +/// +/// Registrations for NServiceBus send-only endpoints in this assembly. +/// +[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] +[global::NServiceBus.AutoGeneratedFunctionRegistrationsAttribute] +[global::System.CodeDom.Compiler.GeneratedCodeAttribute("NService.Core.Analyzer.Tests", "1.0.0")] +public static class GeneratedSendOnlyEndpointRegistrations_GeneratesSendOnlyEndpointInGlobalNamespace_e2e010c95a40b63d +{ + /// + /// Gets send-only endpoint manifests for NServiceBus endpoints in this assembly. + /// + public static global::System.Collections.Generic.IEnumerable + GetSendOnlyEndpointManifests() + { + yield return new global::NServiceBus.SendOnlyEndpointManifest( + "client", + null, + (endpointconfiguration, iservicecollection, iconfiguration, ihostenvironment) => global::ClientEndpoint.ConfigureClient(endpointconfiguration, iservicecollection), + global::NServiceBus.Configuration.AdvancedExtensibility.AzureServiceBusFunctionsHostApplicationBuilderExtensions.AddNServiceBusAzureServiceBusSendOnlyEndpoint); + yield break; + } +} \ No newline at end of file diff --git a/src/Tests.Analyzers/ApprovalFiles/SendOnlyEndpointGeneratorTests.GeneratesSendOnlyEndpointRegistration.approved.txt b/src/Tests.Analyzers/ApprovalFiles/SendOnlyEndpointGeneratorTests.GeneratesSendOnlyEndpointRegistration.approved.txt new file mode 100644 index 00000000..fe6c6f35 --- /dev/null +++ b/src/Tests.Analyzers/ApprovalFiles/SendOnlyEndpointGeneratorTests.GeneratesSendOnlyEndpointRegistration.approved.txt @@ -0,0 +1,33 @@ +// == NServiceBus.AzureFunctions.Analyzer/NServiceBus.AzureFunctions.Analyzer.SendOnlyEndpointGenerator/SendOnlyEndpointRegistration.g.cs == +// + +#nullable enable annotations +#nullable disable warnings + +// Suppress warnings about [Obsolete] usage in generated code. +#pragma warning disable CS0612, CS0618 + +namespace NServiceBus.Generated; + +/// +/// Registrations for NServiceBus send-only endpoints in this assembly. +/// +[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] +[global::NServiceBus.AutoGeneratedFunctionRegistrationsAttribute] +[global::System.CodeDom.Compiler.GeneratedCodeAttribute("NService.Core.Analyzer.Tests", "1.0.0")] +public static class GeneratedSendOnlyEndpointRegistrations_GeneratesSendOnlyEndpointRegistration_12baa95a3a042aa3 +{ + /// + /// Gets send-only endpoint manifests for NServiceBus endpoints in this assembly. + /// + public static global::System.Collections.Generic.IEnumerable + GetSendOnlyEndpointManifests() + { + yield return new global::NServiceBus.SendOnlyEndpointManifest( + "client", + null, + (endpointconfiguration, iservicecollection, iconfiguration, ihostenvironment) => global::Demo.ClientEndpoint.ConfigureClient(endpointconfiguration, iservicecollection), + global::NServiceBus.Configuration.AdvancedExtensibility.AzureServiceBusFunctionsHostApplicationBuilderExtensions.AddNServiceBusAzureServiceBusSendOnlyEndpoint); + yield break; + } +} \ No newline at end of file diff --git a/src/Tests.Analyzers/ApprovalFiles/SendOnlyEndpointGeneratorTests.GeneratesSendOnlyEndpointWithAllAdditionalParameters.approved.txt b/src/Tests.Analyzers/ApprovalFiles/SendOnlyEndpointGeneratorTests.GeneratesSendOnlyEndpointWithAllAdditionalParameters.approved.txt new file mode 100644 index 00000000..f2ba057e --- /dev/null +++ b/src/Tests.Analyzers/ApprovalFiles/SendOnlyEndpointGeneratorTests.GeneratesSendOnlyEndpointWithAllAdditionalParameters.approved.txt @@ -0,0 +1,33 @@ +// == NServiceBus.AzureFunctions.Analyzer/NServiceBus.AzureFunctions.Analyzer.SendOnlyEndpointGenerator/SendOnlyEndpointRegistration.g.cs == +// + +#nullable enable annotations +#nullable disable warnings + +// Suppress warnings about [Obsolete] usage in generated code. +#pragma warning disable CS0612, CS0618 + +namespace NServiceBus.Generated; + +/// +/// Registrations for NServiceBus send-only endpoints in this assembly. +/// +[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] +[global::NServiceBus.AutoGeneratedFunctionRegistrationsAttribute] +[global::System.CodeDom.Compiler.GeneratedCodeAttribute("NService.Core.Analyzer.Tests", "1.0.0")] +public static class GeneratedSendOnlyEndpointRegistrations_GeneratesSendOnlyEndpointWithAllAdditionalParameters_a2de95ba8b4b7d2e +{ + /// + /// Gets send-only endpoint manifests for NServiceBus endpoints in this assembly. + /// + public static global::System.Collections.Generic.IEnumerable + GetSendOnlyEndpointManifests() + { + yield return new global::NServiceBus.SendOnlyEndpointManifest( + "client", + null, + (endpointconfiguration, iservicecollection, iconfiguration, ihostenvironment) => global::Demo.ClientEndpoint.ConfigureClient(endpointconfiguration, iservicecollection, iconfiguration, ihostenvironment), + global::NServiceBus.Configuration.AdvancedExtensibility.AzureServiceBusFunctionsHostApplicationBuilderExtensions.AddNServiceBusAzureServiceBusSendOnlyEndpoint); + yield break; + } +} \ No newline at end of file diff --git a/src/Tests.Analyzers/ApprovalFiles/SendOnlyEndpointGeneratorTests.GeneratesSendOnlyEndpointWithConnection.approved.txt b/src/Tests.Analyzers/ApprovalFiles/SendOnlyEndpointGeneratorTests.GeneratesSendOnlyEndpointWithConnection.approved.txt new file mode 100644 index 00000000..71896e79 --- /dev/null +++ b/src/Tests.Analyzers/ApprovalFiles/SendOnlyEndpointGeneratorTests.GeneratesSendOnlyEndpointWithConnection.approved.txt @@ -0,0 +1,33 @@ +// == NServiceBus.AzureFunctions.Analyzer/NServiceBus.AzureFunctions.Analyzer.SendOnlyEndpointGenerator/SendOnlyEndpointRegistration.g.cs == +// + +#nullable enable annotations +#nullable disable warnings + +// Suppress warnings about [Obsolete] usage in generated code. +#pragma warning disable CS0612, CS0618 + +namespace NServiceBus.Generated; + +/// +/// Registrations for NServiceBus send-only endpoints in this assembly. +/// +[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] +[global::NServiceBus.AutoGeneratedFunctionRegistrationsAttribute] +[global::System.CodeDom.Compiler.GeneratedCodeAttribute("NService.Core.Analyzer.Tests", "1.0.0")] +public static class GeneratedSendOnlyEndpointRegistrations_GeneratesSendOnlyEndpointWithConnection_6424a5930f5a14a8 +{ + /// + /// Gets send-only endpoint manifests for NServiceBus endpoints in this assembly. + /// + public static global::System.Collections.Generic.IEnumerable + GetSendOnlyEndpointManifests() + { + yield return new global::NServiceBus.SendOnlyEndpointManifest( + "client", + "MyCustomConnection", + (endpointconfiguration, iservicecollection, iconfiguration, ihostenvironment) => global::Demo.ClientEndpoint.ConfigureClient(endpointconfiguration, iservicecollection), + global::NServiceBus.Configuration.AdvancedExtensibility.AzureServiceBusFunctionsHostApplicationBuilderExtensions.AddNServiceBusAzureServiceBusSendOnlyEndpoint); + yield break; + } +} \ No newline at end of file diff --git a/src/Tests.Analyzers/ApprovalFiles/SendOnlyEndpointGeneratorTests.GeneratesSendOnlyEndpointWithNoAdditionalParameters.approved.txt b/src/Tests.Analyzers/ApprovalFiles/SendOnlyEndpointGeneratorTests.GeneratesSendOnlyEndpointWithNoAdditionalParameters.approved.txt new file mode 100644 index 00000000..fcea13bd --- /dev/null +++ b/src/Tests.Analyzers/ApprovalFiles/SendOnlyEndpointGeneratorTests.GeneratesSendOnlyEndpointWithNoAdditionalParameters.approved.txt @@ -0,0 +1,33 @@ +// == NServiceBus.AzureFunctions.Analyzer/NServiceBus.AzureFunctions.Analyzer.SendOnlyEndpointGenerator/SendOnlyEndpointRegistration.g.cs == +// + +#nullable enable annotations +#nullable disable warnings + +// Suppress warnings about [Obsolete] usage in generated code. +#pragma warning disable CS0612, CS0618 + +namespace NServiceBus.Generated; + +/// +/// Registrations for NServiceBus send-only endpoints in this assembly. +/// +[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] +[global::NServiceBus.AutoGeneratedFunctionRegistrationsAttribute] +[global::System.CodeDom.Compiler.GeneratedCodeAttribute("NService.Core.Analyzer.Tests", "1.0.0")] +public static class GeneratedSendOnlyEndpointRegistrations_GeneratesSendOnlyEndpointWithNoAdditionalParameters_5427349d0ebb643e +{ + /// + /// Gets send-only endpoint manifests for NServiceBus endpoints in this assembly. + /// + public static global::System.Collections.Generic.IEnumerable + GetSendOnlyEndpointManifests() + { + yield return new global::NServiceBus.SendOnlyEndpointManifest( + "client", + null, + (endpointconfiguration, iservicecollection, iconfiguration, ihostenvironment) => global::Demo.ClientEndpoint.ConfigureClient(endpointconfiguration), + global::NServiceBus.Configuration.AdvancedExtensibility.AzureServiceBusFunctionsHostApplicationBuilderExtensions.AddNServiceBusAzureServiceBusSendOnlyEndpoint); + yield break; + } +} \ No newline at end of file diff --git a/src/Tests.Analyzers/ConfigurationAnalyzerTests.cs b/src/Tests.Analyzers/ConfigurationAnalyzerTests.cs index 57c56dd8..50b4d6e5 100644 --- a/src/Tests.Analyzers/ConfigurationAnalyzerTests.cs +++ b/src/Tests.Analyzers/ConfigurationAnalyzerTests.cs @@ -8,6 +8,23 @@ namespace NServiceBus.AzureFunctions.Analyzers.Tests; [TestFixture] public class ConfigurationAnalyzerTests : AnalyzerTestFixture { + const string NServiceBusUsings = """ + using System.Threading.Tasks; + using NServiceBus; + + """; + + const string FunctionUsings = """ + using System.Threading; + using System.Threading.Tasks; + using Azure.Messaging.ServiceBus; + using Microsoft.Azure.Functions.Worker; + using Microsoft.Extensions.Configuration; + using Microsoft.Extensions.Hosting; + using NServiceBus; + + """; + static readonly TestCaseData[] UnsupportedEndpointConfigurationCallCases = [ new("PurgeOnStartup(true)", DiagnosticIds.InvalidEndpointConfiguration), @@ -36,7 +53,7 @@ public class ConfigurationAnalyzerTests : AnalyzerTestFixture - { - endpointConfiguration.UseTransport(new AzureServiceBusServerlessTransport(TopicTopology.Default)); - }); + endpointConfiguration.UseTransport(new AzureServiceBusServerlessTransport(TopicTopology.Default)); } } """; @@ -143,7 +157,7 @@ public static void Configure(FunctionsApplicationBuilder builder) [Test] public Task DoesNotReportUseTransportWithAzureServiceBusServerlessTransportVariable() { - var source = """ + var source = NServiceBusUsings + """ using NServiceBus.AzureFunctions.AzureServiceBus; using NServiceBus.Transport.AzureServiceBus; namespace Demo; @@ -164,18 +178,15 @@ public static void Apply(EndpointConfiguration endpointConfiguration) [TestCaseSource(nameof(UnsupportedEndpointConfigurationCallCases))] public Task ReportsDiagnosticForUnsupportedEndpointConfigurationCallsInSendOnlyCallbacks(string configuration, string diagnosticId) { - var source = $$""" - using Microsoft.Azure.Functions.Worker.Builder; + var source = NServiceBusUsings + $$""" namespace Demo; - public static class Program + public static class ClientEndpoint { - public static void Configure(FunctionsApplicationBuilder builder) + [NServiceBusSendOnlyFunction("client")] + public static void ConfigureClient(EndpointConfiguration endpointConfiguration) { - builder.AddSendOnlyNServiceBusEndpoint("client", (endpointConfiguration, services) => - { - [|endpointConfiguration.{{configuration}}|]; - }); + [|endpointConfiguration.{{configuration}}|]; } } """; @@ -186,19 +197,14 @@ public static void Configure(FunctionsApplicationBuilder builder) [Test] public Task ReportsDiagnosticForUnsupportedEndpointConfigurationCallsInSendOnlyMethodGroupCallback() { - var source = """ - using Microsoft.Azure.Functions.Worker.Builder; + var source = NServiceBusUsings + """ using Microsoft.Extensions.DependencyInjection; namespace Demo; - public static class Program + public static class ClientEndpoint { - public static void Configure(FunctionsApplicationBuilder builder) - { - builder.AddSendOnlyNServiceBusEndpoint("client", Program.ConfigureSendOnly); - } - - static void ConfigureSendOnly(EndpointConfiguration endpointConfiguration, IServiceCollection services) + [NServiceBusSendOnlyFunction("client")] + public static void ConfigureClient(EndpointConfiguration endpointConfiguration, IServiceCollection services) { [|endpointConfiguration.MakeInstanceUniquelyAddressable("instance")|]; } @@ -211,7 +217,7 @@ static void ConfigureSendOnly(EndpointConfiguration endpointConfiguration, IServ [TestCaseSource(nameof(UnsupportedEndpointConfigurationCallCases))] public Task ReportsDiagnosticForUnsupportedEndpointConfigurationCallsInHelperMethodsWithAllowedParameters(string configuration, string diagnosticId) { - var source = $$""" + var source = NServiceBusUsings + $$""" using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; @@ -257,4 +263,4 @@ public void Send(SomeOtherOptions options) return Assert(source, diagnosticId); } -} \ No newline at end of file +} diff --git a/src/Tests.Analyzers/FunctionCompositionGeneratorTests.cs b/src/Tests.Analyzers/FunctionCompositionGeneratorTests.cs index 0c799609..b39bf139 100644 --- a/src/Tests.Analyzers/FunctionCompositionGeneratorTests.cs +++ b/src/Tests.Analyzers/FunctionCompositionGeneratorTests.cs @@ -12,12 +12,15 @@ public class FunctionCompositionGeneratorTests public void GeneratesProjectComposition() => SourceGeneratorTest.ForIncrementalGenerator() .WithIncrementalGenerator() + .WithIncrementalGenerator() .WithSource(TestSources.ValidFunction) + .WithSource(TestSources.ValidSendOnlyEndpoint, "SendOnly.cs") .ControlOutput(All) .WithProperty("build_property.OutputType", "Exe") .WithProperty("build_property.FunctionsExecutionModel", "isolated") .WithProperty("build_property.RootNamespace", "My.FunctionApp") .SuppressCompilationErrors() .Run() - .Approve(); + .Approve() + .AssertRunsAreEqual(); } \ No newline at end of file diff --git a/src/Tests.Analyzers/FunctionEndpointGeneratorLenientShapeTests.cs b/src/Tests.Analyzers/FunctionEndpointGeneratorLenientShapeTests.cs index a969a149..0a6c34d9 100644 --- a/src/Tests.Analyzers/FunctionEndpointGeneratorLenientShapeTests.cs +++ b/src/Tests.Analyzers/FunctionEndpointGeneratorLenientShapeTests.cs @@ -11,9 +11,15 @@ public void GeneratesEndpointWithExtraUnrecognizedParametersWhenShapeAllowsAddit SourceGeneratorTest.ForIncrementalGenerator() .WithSource(SourceWithAdditionalParameter) .Run() - .Approve(); + .Approve() + .AssertRunsAreEqual(); const string SourceWithAdditionalParameter = """ + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Azure.Functions.Worker; + using NServiceBus; + namespace Demo.Testing; [System.AttributeUsage(System.AttributeTargets.Parameter)] diff --git a/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs b/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs index e739a2af..b12d1f91 100644 --- a/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs +++ b/src/Tests.Analyzers/FunctionEndpointGeneratorTests.cs @@ -13,28 +13,32 @@ public void GeneratesFunctionEndpoint() => SourceGeneratorTest.ForIncrementalGenerator() .WithSource(TestSources.ValidFunction) .Run() - .Approve(); + .Approve() + .AssertRunsAreEqual(); [Test] public void GeneratesFunctionEndpointInGlobalNamespace() => SourceGeneratorTest.ForIncrementalGenerator() .WithSource(TestSources.ValidFunctionInGlobalNamespace) .Run() - .Approve(); + .Approve() + .AssertRunsAreEqual(); [Test] public void GeneratesNoRegistrationsForOrdinaryFunctionsOnly() => SourceGeneratorTest.ForIncrementalGenerator() .WithSource(TestSources.OrdinaryFunctionOnly) .Run() - .Approve(); + .Approve() + .AssertRunsAreEqual(); [Test] public void GeneratesEndpointWithoutMessageActions() => SourceGeneratorTest.ForIncrementalGenerator() .WithSource(TestSources.NoMessageActionsFunction) .Run() - .Approve(); + .Approve() + .AssertRunsAreEqual(); [Test] public void ReportsInvalidFunctionMethodWhenShapeContainsExtraUnrecognizedParameters() @@ -84,6 +88,14 @@ public void ReportsStructuralDiagnostics(string source, string diagnosticId) } const string FunctionClassMustBePartial = """ + using System.Threading; + using System.Threading.Tasks; + using Azure.Messaging.ServiceBus; + using Microsoft.Azure.Functions.Worker; + using Microsoft.Extensions.Configuration; + using Microsoft.Extensions.Hosting; + using NServiceBus; + namespace Demo; public class Functions @@ -106,6 +118,14 @@ public static void ConfigureProcessOrder( """; const string FunctionMethodMustBePartial = """ + using System.Threading; + using System.Threading.Tasks; + using Azure.Messaging.ServiceBus; + using Microsoft.Azure.Functions.Worker; + using Microsoft.Extensions.Configuration; + using Microsoft.Extensions.Hosting; + using NServiceBus; + namespace Demo; public partial class Functions @@ -131,6 +151,14 @@ public static void ConfigureProcessOrder( """; const string FunctionClassShouldNotImplementIHandleMessages = """ + using System.Threading; + using System.Threading.Tasks; + using Azure.Messaging.ServiceBus; + using Microsoft.Azure.Functions.Worker; + using Microsoft.Extensions.Configuration; + using Microsoft.Extensions.Hosting; + using NServiceBus; + namespace Demo; public partial class Functions : IHandleMessages @@ -162,9 +190,15 @@ public void ReportsIHandleMessagesWarningOnlyOnceForMultipleAttributedMethods() { var result = SourceGeneratorTest.ForIncrementalGenerator() .WithSource(""" - namespace Demo; + using System.Threading; + using System.Threading.Tasks; + using Azure.Messaging.ServiceBus; + using Microsoft.Azure.Functions.Worker; + using NServiceBus; - public partial class Functions : IHandleMessages + namespace Demo; + + public partial class Functions : IHandleMessages { [NServiceBusFunction] [Function("ProcessOrder")] @@ -213,6 +247,13 @@ public static void ConfigureProcessOrder2(EndpointConfiguration endpointConfigur } const string MultipleConfigureMethods = """ + using System.Threading; + using System.Threading.Tasks; + using Azure.Messaging.ServiceBus; + using Microsoft.Azure.Functions.Worker; + using Microsoft.Extensions.Configuration; + using NServiceBus; + namespace Demo; public partial class Functions @@ -238,6 +279,12 @@ public static void ConfigureProcessOrder( """; const string MissingAutoComplete = """ + using System.Threading; + using System.Threading.Tasks; + using Azure.Messaging.ServiceBus; + using Microsoft.Azure.Functions.Worker; + using NServiceBus; + namespace Demo; public partial class Functions @@ -257,6 +304,12 @@ public static void ConfigureProcessOrder(EndpointConfiguration endpointConfigura """; const string AutoCompleteEnabled = """ + using System.Threading; + using System.Threading.Tasks; + using Azure.Messaging.ServiceBus; + using Microsoft.Azure.Functions.Worker; + using NServiceBus; + namespace Demo; public partial class Functions @@ -283,6 +336,11 @@ public static void ConfigureProcessOrder(EndpointConfiguration endpointConfigura public void ReportsInvalidFunctionMethodWhenTriggerParameterMissing() { var diagnostic = GetInvalidFunctionMethodDiagnostic(""" + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Azure.Functions.Worker; + using NServiceBus; + namespace Demo; public partial class Functions @@ -306,6 +364,12 @@ public static void ConfigureProcessOrder(EndpointConfiguration endpointConfigura public void ReportsInvalidFunctionMethodWhenMessageActionsRequiredButMissing() { var diagnostic = GetInvalidFunctionMethodDiagnostic(""" + using System.Threading; + using System.Threading.Tasks; + using Azure.Messaging.ServiceBus; + using Microsoft.Azure.Functions.Worker; + using NServiceBus; + namespace Demo; public partial class Functions @@ -471,6 +535,12 @@ public void ReportsInvalidFunctionMethodWhenServiceBusTriggerUsesTopicSubscripti { var result = SourceGeneratorTest.ForIncrementalGenerator() .WithSource(""" + using System.Threading; + using System.Threading.Tasks; + using Azure.Messaging.ServiceBus; + using Microsoft.Azure.Functions.Worker; + using NServiceBus; + namespace Demo; public partial class Functions @@ -558,6 +628,11 @@ static string NoMessageActionsSource(string classBody, bool triggerHasConstructo : ""; return $$""" + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Azure.Functions.Worker; + using NServiceBus; + namespace Demo.Testing; [System.AttributeUsage(System.AttributeTargets.Parameter)] diff --git a/src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs b/src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs index f8f3c371..4e52a980 100644 --- a/src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs +++ b/src/Tests.Analyzers/LenientNoMessageActionsGenerator.cs @@ -11,20 +11,21 @@ namespace NServiceBus.AzureFunctions.Analyzers.Tests; class LenientNoMessageActionsGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) - => FunctionEndpointGenerator.InitializeGenerator(context, - new FunctionEndpointGenerator.TriggerDefinition( - TriggerAttributeMetadataName: "Demo.Testing.TestTriggerAttribute", - AdditionalParameterTypes: ImmutableEquatableArray.Empty, - ProcessorTypeFullyQualified: "global::Demo.Testing.TestProcessor", - AddressExtraction: FunctionEndpointGenerator.AddressExtractionPolicy.FromNamedConstructorParameter("queueName"), - ConnectionSetting: FunctionEndpointGenerator.ConnectionSettingPolicy.FromNamedProperty("ConnSetting"), - AutoComplete: FunctionEndpointGenerator.AutoCompletePolicy.None, - RegistrationMethodFullyQualified: "global::Demo.Testing.TestFunctionManifestRegistration.Register", - ProcessMethodName: "Process", - Shape: FunctionEndpointGenerator.TriggerShape.RequiredAllowingAdditionalParameters( - FunctionEndpointGenerator.ParameterRole.TriggerMessage, - FunctionEndpointGenerator.ParameterRole.FunctionContext, - FunctionEndpointGenerator.ParameterRole.CancellationToken))); + => FunctionEndpointGenerator.InitializeGenerator(context); + + sealed record LenientNoMessageActionsTriggerDefinition() : FunctionEndpointGenerator.TriggerDefinition( + TriggerAttributeMetadataName: "Demo.Testing.TestTriggerAttribute", + AdditionalParameterTypes: ImmutableEquatableArray.Empty, + ProcessorTypeFullyQualified: "global::Demo.Testing.TestProcessor", + AddressExtraction: FunctionEndpointGenerator.AddressExtractionPolicy.FromNamedConstructorParameter("queueName"), + ConnectionSetting: FunctionEndpointGenerator.ConnectionSettingPolicy.FromNamedProperty("ConnSetting"), + AutoComplete: FunctionEndpointGenerator.AutoCompletePolicy.None, + RegistrationMethodFullyQualified: "global::Demo.Testing.TestFunctionManifestRegistration.Register", + ProcessMethodName: "Process", + Shape: FunctionEndpointGenerator.TriggerShape.RequiredAllowingAdditionalParameters( + FunctionEndpointGenerator.ParameterRole.TriggerMessage, + FunctionEndpointGenerator.ParameterRole.FunctionContext, + FunctionEndpointGenerator.ParameterRole.CancellationToken)); internal static class TrackingNames { diff --git a/src/Tests.Analyzers/MissingCompositionCallAnalyzerTests.cs b/src/Tests.Analyzers/MissingCompositionCallAnalyzerTests.cs index 03a3d988..5df55ace 100644 --- a/src/Tests.Analyzers/MissingCompositionCallAnalyzerTests.cs +++ b/src/Tests.Analyzers/MissingCompositionCallAnalyzerTests.cs @@ -80,6 +80,7 @@ public static void Configure(FunctionsApplicationBuilder builder) static SourceGeneratorTest CreateSourceGeneratorAnalyzerTest() => SourceGeneratorTest.ForIncrementalGenerator() + .WithIncrementalGenerator() .WithIncrementalGenerator() .WithAnalyzer() .BuildAs(OutputKind.ConsoleApplication) diff --git a/src/Tests.Analyzers/NoMessageActionsGenerator.cs b/src/Tests.Analyzers/NoMessageActionsGenerator.cs index 6bebefff..dbdd1638 100644 --- a/src/Tests.Analyzers/NoMessageActionsGenerator.cs +++ b/src/Tests.Analyzers/NoMessageActionsGenerator.cs @@ -11,20 +11,21 @@ namespace NServiceBus.AzureFunctions.Analyzers.Tests; class NoMessageActionsGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) - => FunctionEndpointGenerator.InitializeGenerator(context, - new FunctionEndpointGenerator.TriggerDefinition( - TriggerAttributeMetadataName: "Demo.Testing.TestTriggerAttribute", - AdditionalParameterTypes: ImmutableEquatableArray.Empty, - ProcessorTypeFullyQualified: "global::Demo.Testing.TestProcessor", - AddressExtraction: FunctionEndpointGenerator.AddressExtractionPolicy.FromNamedConstructorParameter("queueName"), - ConnectionSetting: FunctionEndpointGenerator.ConnectionSettingPolicy.FromNamedProperty("ConnSetting"), - AutoComplete: FunctionEndpointGenerator.AutoCompletePolicy.None, - RegistrationMethodFullyQualified: "global::Demo.Testing.TestFunctionManifestRegistration.Register", - ProcessMethodName: "Process", - Shape: FunctionEndpointGenerator.TriggerShape.Required( - FunctionEndpointGenerator.ParameterRole.TriggerMessage, - FunctionEndpointGenerator.ParameterRole.FunctionContext, - FunctionEndpointGenerator.ParameterRole.CancellationToken))); + => FunctionEndpointGenerator.InitializeGenerator(context); + + record NoMessageTriggerDefinition() : FunctionEndpointGenerator.TriggerDefinition( + TriggerAttributeMetadataName: "Demo.Testing.TestTriggerAttribute", + AdditionalParameterTypes: ImmutableEquatableArray.Empty, + ProcessorTypeFullyQualified: "global::Demo.Testing.TestProcessor", + AddressExtraction: FunctionEndpointGenerator.AddressExtractionPolicy.FromNamedConstructorParameter("queueName"), + ConnectionSetting: FunctionEndpointGenerator.ConnectionSettingPolicy.FromNamedProperty("ConnSetting"), + AutoComplete: FunctionEndpointGenerator.AutoCompletePolicy.None, + RegistrationMethodFullyQualified: "global::Demo.Testing.TestFunctionManifestRegistration.Register", + ProcessMethodName: "Process", + Shape: FunctionEndpointGenerator.TriggerShape.Required( + FunctionEndpointGenerator.ParameterRole.TriggerMessage, + FunctionEndpointGenerator.ParameterRole.FunctionContext, + FunctionEndpointGenerator.ParameterRole.CancellationToken)); internal static class TrackingNames { @@ -36,4 +37,4 @@ internal static class TrackingNames public static string[] All => [Extraction, Diagnostics, Functions, AssemblyClassName, Combined]; } -} +} \ No newline at end of file diff --git a/src/Tests.Analyzers/SendOnlyEndpointGeneratorTests.cs b/src/Tests.Analyzers/SendOnlyEndpointGeneratorTests.cs new file mode 100644 index 00000000..89721972 --- /dev/null +++ b/src/Tests.Analyzers/SendOnlyEndpointGeneratorTests.cs @@ -0,0 +1,212 @@ +namespace NServiceBus.AzureFunctions.Analyzers.Tests; + +using Microsoft.CodeAnalysis; +using NServiceBus.AzureFunctions.Analyzer; +using NUnit.Framework; +using Particular.AnalyzerTesting; + +[TestFixture] +public class SendOnlyEndpointGeneratorTests +{ + [Test] + public void GeneratesSendOnlyEndpointRegistration() => + SourceGeneratorTest.ForIncrementalGenerator() + .WithSource(TestSources.ValidSendOnlyEndpoint) + .Run() + .Approve() + .AssertRunsAreEqual(); + + [Test] + public void GeneratesSendOnlyEndpointInGlobalNamespace() => + SourceGeneratorTest.ForIncrementalGenerator() + .WithSource(TestSources.ValidSendOnlyEndpointInGlobalNamespace) + .Run() + .Approve() + .AssertRunsAreEqual(); + + [Test] + public void GeneratesSendOnlyEndpointWithAllAdditionalParameters() => + SourceGeneratorTest.ForIncrementalGenerator() + .WithSource(TestSources.ValidSendOnlyEndpointWithAllAdditionalParameters) + .Run() + .Approve() + .AssertRunsAreEqual(); + + [Test] + public void GeneratesSendOnlyEndpointWithNoAdditionalParameters() => + SourceGeneratorTest.ForIncrementalGenerator() + .WithSource(TestSources.ValidSendOnlyEndpointWithNoAdditionalParameters) + .Run() + .Approve() + .AssertRunsAreEqual(); + + [Test] + public void GeneratesNoRegistrationsWhenNoSendOnlyEndpoints() => + SourceGeneratorTest.ForIncrementalGenerator() + .WithSource(TestSources.NoSendOnlyEndpoints) + .Run() + .Approve() + .AssertRunsAreEqual(); + + [Test] + public void GeneratesSendOnlyEndpointWithConnection() => + SourceGeneratorTest.ForIncrementalGenerator() + .WithSource(TestSources.ValidSendOnlyEndpointWithConnection) + .Run() + .Approve() + .AssertRunsAreEqual(); + + [Test] + public void GeneratesMultipleSendOnlyEndpoints() => + SourceGeneratorTest.ForIncrementalGenerator() + .WithSource(TestSources.MultipleSendOnlyEndpoints) + .Run() + .Approve() + .AssertRunsAreEqual(); + + [Test] + public void ReportsInvalidSendOnlyEndpointMethodWhenMethodIsNotStatic() + { + var result = SourceGeneratorTest.ForIncrementalGenerator() + .WithSource(""" + using NServiceBus; + + namespace Demo; + + public class ClientEndpoint + { + [NServiceBusSendOnlyFunction("client")] + public void ConfigureClient(EndpointConfiguration endpointConfiguration) + { + } + } + """) + .SuppressCompilationErrors() + .SuppressDiagnosticErrors() + .Run(); + + Assert.That(result.GeneratorDiagnostics, Has.Some.Matches(d => d.Id == DiagnosticIds.InvalidSendOnlyEndpointMethod)); + } + + [Test] + public void ReportsInvalidSendOnlyEndpointMethodWhenMethodNameWrong() + { + var diagnostic = GetInvalidSendOnlyEndpointMethodDiagnostic(""" + using NServiceBus; + + namespace Demo; + + public static class ClientEndpoint + { + [NServiceBusSendOnlyFunction("client")] + public static void WrongName(EndpointConfiguration endpointConfiguration) + { + } + } + """); + + Assert.That(diagnostic.GetMessage(), Does.Contain("method name must be 'Configureclient'")); + } + + [Test] + public void ReportsInvalidSendOnlyEndpointMethodWhenFirstParameterNotEndpointConfiguration() + { + var diagnostic = GetInvalidSendOnlyEndpointMethodDiagnostic(""" + using NServiceBus; + + namespace Demo; + + public static class ClientEndpoint + { + [NServiceBusSendOnlyFunction("client")] + public static void ConfigureClient(string wrongParam) + { + } + } + """); + + Assert.That(diagnostic.GetMessage(), Does.Contain("first parameter must be EndpointConfiguration")); + } + + [Test] + public void ReportsInvalidSendOnlyEndpointMethodWhenNoParameters() + { + var diagnostic = GetInvalidSendOnlyEndpointMethodDiagnostic(""" + using NServiceBus; + + namespace Demo; + + public static class ClientEndpoint + { + [NServiceBusSendOnlyFunction("client")] + public static void ConfigureClient() + { + } + } + """); + + Assert.That(diagnostic.GetMessage(), Does.Contain("first parameter must be EndpointConfiguration")); + } + + [Test] + public void ReportsInvalidSendOnlyEndpointMethodWhenInvalidAdditionalParameter() + { + var diagnostic = GetInvalidSendOnlyEndpointMethodDiagnostic(""" + using NServiceBus; + + namespace Demo; + + public static class ClientEndpoint + { + [NServiceBusSendOnlyFunction("client")] + public static void ConfigureClient(EndpointConfiguration endpointConfiguration, string invalidParam) + { + } + } + """); + + Assert.That(diagnostic.GetMessage(), Does.Contain("parameters after EndpointConfiguration must be IServiceCollection, IConfiguration, or IHostEnvironment")); + } + + [Test] + public void ReportsAllProblemsInSingleDiagnostic() + { + var diagnostic = GetInvalidSendOnlyEndpointMethodDiagnostic(""" + using NServiceBus; + + namespace Demo; + + public class ClientEndpoint + { + [NServiceBusSendOnlyFunction("client")] + public void WrongName(string invalidParam) + { + } + } + """); + + var message = diagnostic.GetMessage(); + Assert.That(message, Does.Contain("method must be static")); + Assert.That(message, Does.Contain("method name must be 'Configureclient'")); + Assert.That(message, Does.Contain("first parameter must be EndpointConfiguration")); + } + + #region Helpers + + static Diagnostic GetInvalidSendOnlyEndpointMethodDiagnostic(string source) + { + var result = SourceGeneratorTest.ForIncrementalGenerator() + .WithSource(source) + .SuppressCompilationErrors() + .SuppressDiagnosticErrors() + .Run(); + + var diagnostics = result.GeneratorDiagnostics; + Assert.That(diagnostics, Has.Some.Matches(d => d.Id == DiagnosticIds.InvalidSendOnlyEndpointMethod), + "Expected InvalidSendOnlyEndpointMethod diagnostic to be reported"); + + return diagnostics.First(d => d.Id == DiagnosticIds.InvalidSendOnlyEndpointMethod); + } + + #endregion +} \ No newline at end of file diff --git a/src/Tests.Analyzers/SetUpFixture.cs b/src/Tests.Analyzers/SetUpFixture.cs index eb3cd58d..c3a70eac 100644 --- a/src/Tests.Analyzers/SetUpFixture.cs +++ b/src/Tests.Analyzers/SetUpFixture.cs @@ -16,16 +16,6 @@ namespace NServiceBus.AzureFunctions.Analyzers.Tests; [SetUpFixture] public class SetUpFixture { - const string CommonUsings = """ - global using System.Threading; - global using System.Threading.Tasks; - global using Microsoft.Azure.Functions.Worker; - global using Azure.Messaging.ServiceBus; - global using Microsoft.Extensions.Configuration; - global using Microsoft.Extensions.Hosting; - global using NServiceBus; - """; - static readonly ImmutableList ProjectReferences = [ MetadataReference.CreateFromFile(typeof(object).GetTypeInfo().Assembly.Location), @@ -52,11 +42,9 @@ public class SetUpFixture public void OneTimeSetUp() { AnalyzerTest.ConfigureAllAnalyzerTests(test => test - .AddReferences(ProjectReferences) - .WithSource(CommonUsings, "GlobalUsings.cs")); + .AddReferences(ProjectReferences)); SourceGeneratorTest.ConfigureAllSourceGeneratorTests(test => test - .AddReferences(ProjectReferences) - .WithSource(CommonUsings, "GlobalUsings.cs")); + .AddReferences(ProjectReferences)); } -} \ No newline at end of file +} diff --git a/src/Tests.Analyzers/TestSources.cs b/src/Tests.Analyzers/TestSources.cs index ab150844..bae3e2c0 100644 --- a/src/Tests.Analyzers/TestSources.cs +++ b/src/Tests.Analyzers/TestSources.cs @@ -3,6 +3,11 @@ namespace NServiceBus.AzureFunctions.Analyzers.Tests; static class TestSources { public const string OrdinaryFunctionOnly = """ + using System.Threading; + using System.Threading.Tasks; + using Azure.Messaging.ServiceBus; + using Microsoft.Azure.Functions.Worker; + namespace Demo; public class Functions @@ -17,6 +22,14 @@ public Task Run( """; public const string ValidFunction = """ + using System.Threading; + using System.Threading.Tasks; + using Azure.Messaging.ServiceBus; + using Microsoft.Azure.Functions.Worker; + using Microsoft.Extensions.Configuration; + using Microsoft.Extensions.Hosting; + using NServiceBus; + namespace Demo; public partial class Functions @@ -39,6 +52,11 @@ public static void ConfigureProcessOrder( """; public const string NoMessageActionsFunction = """ + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Azure.Functions.Worker; + using NServiceBus; + namespace Demo.Testing; [System.AttributeUsage(System.AttributeTargets.Parameter)] @@ -59,6 +77,11 @@ public static class TestFunctionManifestRegistration public static void Register(global::Microsoft.Azure.Functions.Worker.Builder.FunctionsApplicationBuilder _, global::NServiceBus.FunctionManifest __) { } } + public static class TestSendOnlyEndpointManifestRegistration + { + public static void Register(global::Microsoft.Azure.Functions.Worker.Builder.FunctionsApplicationBuilder _, global::NServiceBus.SendOnlyEndpointManifest __) { } + } + public partial class Functions { [NServiceBusFunction] @@ -76,6 +99,14 @@ public static void ConfigureProcessOrder( """; public const string ValidFunctionInGlobalNamespace = """ + using System.Threading; + using System.Threading.Tasks; + using Azure.Messaging.ServiceBus; + using Microsoft.Azure.Functions.Worker; + using Microsoft.Extensions.Configuration; + using Microsoft.Extensions.Hosting; + using NServiceBus; + public partial class Functions { [NServiceBusFunction] @@ -94,4 +125,120 @@ public static void ConfigureProcessOrder( } } """; -} + + public const string ValidSendOnlyEndpoint = """ + using NServiceBus; + namespace Demo; + + using Microsoft.Extensions.DependencyInjection; + + public static class ClientEndpoint + { + [NServiceBusSendOnlyFunction("client")] + public static void ConfigureClient(EndpointConfiguration endpointConfiguration, IServiceCollection services) + { + } + } + """; + + public const string ValidSendOnlyEndpointInGlobalNamespace = """ + using NServiceBus; + using Microsoft.Extensions.DependencyInjection; + + public static class ClientEndpoint + { + [NServiceBusSendOnlyFunction("client")] + public static void ConfigureClient(EndpointConfiguration endpointConfiguration, IServiceCollection services) + { + } + } + """; + + public const string ValidSendOnlyEndpointWithAllAdditionalParameters = """ + using NServiceBus; + namespace Demo; + + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Configuration; + using Microsoft.Extensions.Hosting; + + public static class ClientEndpoint + { + [NServiceBusSendOnlyFunction("client")] + public static void ConfigureClient( + EndpointConfiguration endpointConfiguration, + IServiceCollection services, + IConfiguration configuration, + IHostEnvironment environment) + { + } + } + """; + + public const string ValidSendOnlyEndpointWithNoAdditionalParameters = """ + using NServiceBus; + namespace Demo; + + public static class ClientEndpoint + { + [NServiceBusSendOnlyFunction("client")] + public static void ConfigureClient(EndpointConfiguration endpointConfiguration) + { + } + } + """; + + public const string MultipleSendOnlyEndpoints = """ + using NServiceBus; + namespace Demo; + + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Configuration; + using Microsoft.Extensions.Hosting; + + public static class ClientEndpoint + { + [NServiceBusSendOnlyFunction("client")] + public static void ConfigureClient( + EndpointConfiguration endpointConfiguration, + IServiceCollection services) + { + } + } + + public static class SenderEndpoint + { + [NServiceBusSendOnlyFunction("sender")] + public static void ConfigureSender( + EndpointConfiguration endpointConfiguration, + IConfiguration configuration, + IHostEnvironment environment) + { + } + } + """; + + public const string NoSendOnlyEndpoints = """ + namespace Demo; + + public static class SomeClass + { + public static void DoSomething() { } + } + """; + + public const string ValidSendOnlyEndpointWithConnection = """ + using NServiceBus; + namespace Demo; + + using Microsoft.Extensions.DependencyInjection; + + public static class ClientEndpoint + { + [NServiceBusSendOnlyFunction("client", Connection = "MyCustomConnection")] + public static void ConfigureClient(EndpointConfiguration endpointConfiguration, IServiceCollection services) + { + } + } + """; +} \ No newline at end of file