diff --git a/src/IntegrationTest.Billing/BillingFunctions.cs b/src/IntegrationTest.Billing/BillingFunctions.cs new file mode 100644 index 0000000..a35c97f --- /dev/null +++ b/src/IntegrationTest.Billing/BillingFunctions.cs @@ -0,0 +1,37 @@ +namespace IntegrationTest.Billing; + +using Azure.Messaging.ServiceBus; +using IntegrationTest.Shared; +using Microsoft.Azure.Functions.Worker; + +// Pattern for multiple endpoints in one class with separate configs +public partial class BillingFunctions +{ + [Function("BillingApi")] + [NServiceBusFunction] + public partial Task BillingApi( + [ServiceBusTrigger("billing-api", Connection = "AzureWebJobsServiceBus", AutoCompleteMessages = true)] + ServiceBusReceivedMessage message, + FunctionContext functionContext, + CancellationToken cancellationToken = default); + + public static void ConfigureBillingApi(EndpointConfiguration configuration) + { + CommonEndpointConfig.Apply(configuration); + configuration.AddHandler(); + } + + [Function("BillingBackend")] + [NServiceBusFunction] + public partial Task BillingBackend( + [ServiceBusTrigger("billing-backend", Connection = "AzureWebJobsServiceBus", AutoCompleteMessages = true)] + ServiceBusReceivedMessage message, + FunctionContext functionContext, + CancellationToken cancellationToken = default); + + public static void ConfigureBillingBackend(EndpointConfiguration configuration) + { + CommonEndpointConfig.Apply(configuration); + // different handlers for the backend queue + } +} \ No newline at end of file diff --git a/src/IntegrationTest.Billing/Handlers/ProcessPaymentHandler.cs b/src/IntegrationTest.Billing/Handlers/ProcessPaymentHandler.cs new file mode 100644 index 0000000..dc7ff98 --- /dev/null +++ b/src/IntegrationTest.Billing/Handlers/ProcessPaymentHandler.cs @@ -0,0 +1,15 @@ +namespace IntegrationTest.Billing.Handlers; + +using IntegrationTest.Shared; +using Microsoft.Extensions.Logging; +using NServiceBus; + +public class ProcessPaymentHandler(ILogger logger) : IHandleMessages +{ + public async Task Handle(OrderSubmitted message, IMessageHandlerContext context) + { + logger.LogWarning($"Handling {nameof(OrderSubmitted)} in {nameof(ProcessPaymentHandler)}"); + + await context.Publish(new PaymentCleared()).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/IntegrationTest.Billing/IntegrationTest.Billing.csproj b/src/IntegrationTest.Billing/IntegrationTest.Billing.csproj new file mode 100644 index 0000000..b5b139c --- /dev/null +++ b/src/IntegrationTest.Billing/IntegrationTest.Billing.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + $(InterceptorsPreviewNamespaces);NServiceBus + true + $(BaseIntermediateOutputPath)\GeneratedFiles + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/IntegrationTest.Sales/Handlers/AcceptOrderHandler.cs b/src/IntegrationTest.Sales/Handlers/AcceptOrderHandler.cs new file mode 100644 index 0000000..43b2811 --- /dev/null +++ b/src/IntegrationTest.Sales/Handlers/AcceptOrderHandler.cs @@ -0,0 +1,15 @@ +namespace IntegrationTest.Sales.Handlers; + +using IntegrationTest.Shared; +using Microsoft.Extensions.Logging; +using NServiceBus; + +public class AcceptOrderHandler(ILogger logger) : IHandleMessages +{ + public async Task Handle(SubmitOrder message, IMessageHandlerContext context) + { + logger.LogWarning($"Handling {nameof(SubmitOrder)} in {nameof(AcceptOrderHandler)}"); + + await context.Publish(new OrderSubmitted()).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/IntegrationTest.Sales/IntegrationTest.Sales.csproj b/src/IntegrationTest.Sales/IntegrationTest.Sales.csproj new file mode 100644 index 0000000..a8dd877 --- /dev/null +++ b/src/IntegrationTest.Sales/IntegrationTest.Sales.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + $(InterceptorsPreviewNamespaces);NServiceBus + true + $(BaseIntermediateOutputPath)\GeneratedFiles + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/IntegrationTest.Sales/SalesApi.cs b/src/IntegrationTest.Sales/SalesApi.cs new file mode 100644 index 0000000..201f989 --- /dev/null +++ b/src/IntegrationTest.Sales/SalesApi.cs @@ -0,0 +1,39 @@ +namespace IntegrationTest.Sales; + +using System.Net; +using System.Threading.Tasks; +using IntegrationTest.Shared; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NServiceBus; + +[NServiceBusSendOnlyEndpoint] +class SalesApi([FromKeyedServices("SalesApi")] IMessageSession session, ILogger logger) +{ + [Function("SalesApi")] + public async Task Api( + [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] + HttpRequestData req, + CancellationToken cancellationToken) + { + logger.LogInformation("C# HTTP trigger function received a request."); + + await session.Send(new SubmitOrder(), cancellationToken).ConfigureAwait(false); + + var r = req.CreateResponse(HttpStatusCode.OK); + await r.WriteStringAsync($"{nameof(SubmitOrder)} sent.", cancellationToken).ConfigureAwait(false); + return r; + } + + public static void ConfigureSalesApi(EndpointConfiguration configuration) + { + var transport = new AzureServiceBusServerlessTransport(TopicTopology.Default) { ConnectionName = "AzureWebJobsServiceBus" }; + + var routing = configuration.UseTransport(transport); + + routing.RouteToEndpoint(typeof(SubmitOrder), "sales"); + configuration.UseSerialization(); + } +} \ No newline at end of file diff --git a/src/IntegrationTest.Sales/SalesEndpoint.cs b/src/IntegrationTest.Sales/SalesEndpoint.cs new file mode 100644 index 0000000..a2a230b --- /dev/null +++ b/src/IntegrationTest.Sales/SalesEndpoint.cs @@ -0,0 +1,23 @@ +namespace IntegrationTest.Sales; + +using Azure.Messaging.ServiceBus; +using IntegrationTest.Shared; +using Microsoft.Azure.Functions.Worker; + +// Cleanest pattern for single-function endpoints +[NServiceBusFunction] +public partial class SalesEndpoint +{ + [Function("Sales")] + public partial Task Sales( + [ServiceBusTrigger("sales", Connection = "AzureWebJobsServiceBus", AutoCompleteMessages = true)] + ServiceBusReceivedMessage message, + FunctionContext functionContext, + CancellationToken cancellationToken = default); + + public static void ConfigureSales(EndpointConfiguration configuration) + { + CommonEndpointConfig.Apply(configuration); + configuration.AddHandler(); + } +} \ No newline at end of file diff --git a/src/IntegrationTest.Shared/CommonEndpointConfig.cs b/src/IntegrationTest.Shared/CommonEndpointConfig.cs new file mode 100644 index 0000000..6e9d68d --- /dev/null +++ b/src/IntegrationTest.Shared/CommonEndpointConfig.cs @@ -0,0 +1,12 @@ +namespace IntegrationTest.Shared; + +public static class CommonEndpointConfig +{ + public static void Apply(EndpointConfiguration configuration) + { + configuration.UseTransport(new AzureServiceBusServerlessTransport(TopicTopology.Default)); + configuration.EnableInstallers(); + configuration.UsePersistence(); + configuration.UseSerialization(); + } +} \ No newline at end of file diff --git a/src/IntegrationTest.Shared/IntegrationTest.Shared.csproj b/src/IntegrationTest.Shared/IntegrationTest.Shared.csproj new file mode 100644 index 0000000..9c30893 --- /dev/null +++ b/src/IntegrationTest.Shared/IntegrationTest.Shared.csproj @@ -0,0 +1,11 @@ + + + + net10.0 + + + + + + + \ No newline at end of file diff --git a/src/IntegrationTest.Shared/OrderSubmitted.cs b/src/IntegrationTest.Shared/OrderSubmitted.cs new file mode 100644 index 0000000..db37cce --- /dev/null +++ b/src/IntegrationTest.Shared/OrderSubmitted.cs @@ -0,0 +1,5 @@ +namespace IntegrationTest.Shared; + +using NServiceBus; + +public class OrderSubmitted : IEvent; \ No newline at end of file diff --git a/src/IntegrationTest.Shared/PaymentCleared.cs b/src/IntegrationTest.Shared/PaymentCleared.cs new file mode 100644 index 0000000..a213e26 --- /dev/null +++ b/src/IntegrationTest.Shared/PaymentCleared.cs @@ -0,0 +1,5 @@ +namespace IntegrationTest.Shared; + +using NServiceBus; + +public class PaymentCleared : IEvent; \ No newline at end of file diff --git a/src/IntegrationTest.Shared/SubmitOrder.cs b/src/IntegrationTest.Shared/SubmitOrder.cs new file mode 100644 index 0000000..32c2ddc --- /dev/null +++ b/src/IntegrationTest.Shared/SubmitOrder.cs @@ -0,0 +1,5 @@ +namespace IntegrationTest.Shared; + +using NServiceBus; + +public class SubmitOrder : ICommand; \ No newline at end of file diff --git a/src/IntegrationTest.Shipping/Handlers/ShipOrderHandler.cs b/src/IntegrationTest.Shipping/Handlers/ShipOrderHandler.cs new file mode 100644 index 0000000..18ac333 --- /dev/null +++ b/src/IntegrationTest.Shipping/Handlers/ShipOrderHandler.cs @@ -0,0 +1,15 @@ +namespace IntegrationTest.Shipping.Handlers; + +using IntegrationTest.Shared; +using Microsoft.Extensions.Logging; +using NServiceBus; + +public class ShipOrderHandler(ILogger logger) : IHandleMessages +{ + public Task Handle(PaymentCleared message, IMessageHandlerContext context) + { + logger.LogWarning($"Handling {nameof(PaymentCleared)} in {nameof(ShipOrderHandler)}"); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/IntegrationTest.Shipping/IntegrationTest.Shipping.csproj b/src/IntegrationTest.Shipping/IntegrationTest.Shipping.csproj new file mode 100644 index 0000000..b5b139c --- /dev/null +++ b/src/IntegrationTest.Shipping/IntegrationTest.Shipping.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + $(InterceptorsPreviewNamespaces);NServiceBus + true + $(BaseIntermediateOutputPath)\GeneratedFiles + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/IntegrationTest.Shipping/ShippingEndpoint.cs b/src/IntegrationTest.Shipping/ShippingEndpoint.cs new file mode 100644 index 0000000..25d66b0 --- /dev/null +++ b/src/IntegrationTest.Shipping/ShippingEndpoint.cs @@ -0,0 +1,22 @@ +namespace IntegrationTest.Shipping; + +using Azure.Messaging.ServiceBus; +using IntegrationTest.Shared; +using Microsoft.Azure.Functions.Worker; + +[NServiceBusFunction] +public partial class ShippingEndpoint +{ + [Function(nameof(Shipping))] + public partial Task Shipping( + [ServiceBusTrigger("shipping", AutoCompleteMessages = true)] + ServiceBusReceivedMessage message, + FunctionContext functionContext, + CancellationToken cancellationToken = default); + + public static void ConfigureShipping(EndpointConfiguration endpoint) + { + CommonEndpointConfig.Apply(endpoint); + endpoint.AddHandler(); + } +} \ No newline at end of file diff --git a/src/IntegrationTest/HttpSender.cs b/src/IntegrationTest/HttpSender.cs deleted file mode 100644 index 798cdfa..0000000 --- a/src/IntegrationTest/HttpSender.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace IntegrationTest; - -using System.Net; -using System.Threading.Tasks; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using NServiceBus; - -class HttpSender([FromKeyedServices("SenderEndpoint")] IMessageSession session, ILogger logger) -{ - [Function("HttpSenderV4")] - public async Task Run( - [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequestData req, - FunctionContext executionContext) - { - _ = executionContext; // For now - logger.LogInformation("C# HTTP trigger function received a request."); - - await session.Send("ReceiverEndpoint", new TriggerMessage()).ConfigureAwait(false); - - var r = req.CreateResponse(HttpStatusCode.OK); - await r.WriteStringAsync($"{nameof(TriggerMessage)} sent.") - .ConfigureAwait(false); - return r; - } -} \ No newline at end of file diff --git a/src/IntegrationTest/IntegrationTest.csproj b/src/IntegrationTest/IntegrationTest.csproj index 31e7039..284a41b 100644 --- a/src/IntegrationTest/IntegrationTest.csproj +++ b/src/IntegrationTest/IntegrationTest.csproj @@ -4,17 +4,24 @@ net10.0 v4 Exe + $(InterceptorsPreviewNamespaces);NServiceBus true $(BaseIntermediateOutputPath)\GeneratedFiles + + + + + - diff --git a/src/IntegrationTest/Program.cs b/src/IntegrationTest/Program.cs index 6524f42..0ed9071 100644 --- a/src/IntegrationTest/Program.cs +++ b/src/IntegrationTest/Program.cs @@ -1,48 +1,18 @@ -using IntegrationTest; +using System.Text.Json; using Microsoft.Azure.Functions.Worker.Builder; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using NServiceBus.Logging; -using NServiceBus.MultiHosting; +using Microsoft.Extensions.Logging; var builder = FunctionsApplication.CreateBuilder(args); -// as early as possible -LogManager.UseFactory(MultiEndpointLoggerFactory.Instance); - -builder.Services.AddHostedService(); - -builder.AddNServiceBusFunction("SenderEndpoint", endpoint => -{ - endpoint.UseTransport(new AzureServiceBusServerlessTransport(TopicTopology.Default)); - endpoint.SendOnly(); - endpoint.UseSerialization(); -}); - -builder.AddNServiceBusFunction("ReceiverEndpoint", endpoint => +builder.Logging.ClearProviders(); +builder.Logging.AddJsonConsole(o => { - endpoint.UseTransport(new AzureServiceBusServerlessTransport(TopicTopology.Default)); - endpoint.EnableInstallers(); - endpoint.UsePersistence(); - endpoint.UseSerialization(); - - endpoint.AddHandler(); - endpoint.AddHandler(); - endpoint.AddHandler(); -}); - -builder.AddNServiceBusFunction("AnotherReceiverEndpoint", endpoint => -{ - endpoint.UseTransport(new AzureServiceBusServerlessTransport(TopicTopology.Default) - { - ConnectionName = "AnotherServiceBusConnection" - }); - endpoint.EnableInstallers(); - endpoint.UsePersistence(); - endpoint.UseSerialization(); - - endpoint.AddHandler(); + o.IncludeScopes = true; + o.JsonWriterOptions = new JsonWriterOptions { Indented = true }; }); +builder.Logging.SetMinimumLevel(LogLevel.Information); +builder.AddNServiceBusFunctions(); var host = builder.Build(); diff --git a/src/IntegrationTest/ReceiverEndpoint.cs b/src/IntegrationTest/ReceiverEndpoint.cs deleted file mode 100644 index f89b21f..0000000 --- a/src/IntegrationTest/ReceiverEndpoint.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace IntegrationTest; - -using System.Threading; -using System.Threading.Tasks; -using Azure.Messaging.ServiceBus; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Extensions.DependencyInjection; -using NServiceBus.AzureFunctions.AzureServiceBus; - -public class ReceiverEndpoint([FromKeyedServices("ReceiverEndpoint")] IMessageProcessor processor) -{ - [Function("ReceiverEndpoint")] - public Task Receiver( - [ServiceBusTrigger("ReceiverEndpoint", Connection = "AzureWebJobsServiceBus", AutoCompleteMessages = true)] - ServiceBusReceivedMessage message, - ServiceBusMessageActions messageActions, FunctionContext context, CancellationToken cancellationToken = default) - { - return processor.Process(message, messageActions, context, cancellationToken); - } -} - -public class AnotherReceiverEndpoint([FromKeyedServices("AnotherReceiverEndpoint")] IMessageProcessor processor) -{ - [Function("AnotherReceiverEndpoint")] - public Task Receiver( - [ServiceBusTrigger("AnotherReceiverEndpoint", Connection = "AzureWebJobsServiceBus", AutoCompleteMessages = true)] - ServiceBusReceivedMessage message, - ServiceBusMessageActions messageActions, FunctionContext context, CancellationToken cancellationToken = default) - { - return processor.Process(message, messageActions, context, cancellationToken); - } -} \ No newline at end of file diff --git a/src/IntegrationTest/SomeEvent.cs b/src/IntegrationTest/SomeEvent.cs deleted file mode 100644 index c97351b..0000000 --- a/src/IntegrationTest/SomeEvent.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace IntegrationTest; - -using NServiceBus; - -public class SomeEvent : IEvent; \ No newline at end of file diff --git a/src/IntegrationTest/SomeEventMessageHandler.cs b/src/IntegrationTest/SomeEventMessageHandler.cs deleted file mode 100644 index e39a58b..0000000 --- a/src/IntegrationTest/SomeEventMessageHandler.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace IntegrationTest; - -using System.Threading.Tasks; -using NServiceBus; -using NServiceBus.Logging; - -public class SomeEventMessageHandler : IHandleMessages -{ - // using static logger here deliberately - static readonly ILog Log = LogManager.GetLogger(); - - public Task Handle(SomeEvent message, IMessageHandlerContext context) - { - Log.Warn($"Handling {nameof(SomeEvent)} in {nameof(SomeEventMessageHandler)}"); - - return Task.CompletedTask; - } -} \ No newline at end of file diff --git a/src/IntegrationTest/SomeOtherMessage.cs b/src/IntegrationTest/SomeOtherMessage.cs deleted file mode 100644 index 7c0e3c1..0000000 --- a/src/IntegrationTest/SomeOtherMessage.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace IntegrationTest; - -using NServiceBus; - -public class SomeOtherMessage : ICommand; \ No newline at end of file diff --git a/src/IntegrationTest/SomeOtherMessageHandler.cs b/src/IntegrationTest/SomeOtherMessageHandler.cs deleted file mode 100644 index 317e652..0000000 --- a/src/IntegrationTest/SomeOtherMessageHandler.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace IntegrationTest; - -using System.Threading.Tasks; -using NServiceBus; -using NServiceBus.Logging; - -public class SomeOtherMessageHandler : IHandleMessages -{ - // using static logger here deliberately - static readonly ILog Log = LogManager.GetLogger(); - - public Task Handle(SomeOtherMessage message, IMessageHandlerContext context) - { - Log.Warn($"Handling {nameof(SomeOtherMessage)} in {nameof(SomeOtherMessageHandler)}"); - - return Task.CompletedTask; - } -} \ No newline at end of file diff --git a/src/IntegrationTest/TriggerMessage.cs b/src/IntegrationTest/TriggerMessage.cs deleted file mode 100644 index 708f602..0000000 --- a/src/IntegrationTest/TriggerMessage.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace IntegrationTest; - -using NServiceBus; - -public class TriggerMessage : IMessage; \ No newline at end of file diff --git a/src/IntegrationTest/TriggerMessageHandler.cs b/src/IntegrationTest/TriggerMessageHandler.cs deleted file mode 100644 index d3a77b3..0000000 --- a/src/IntegrationTest/TriggerMessageHandler.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace IntegrationTest; - -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using NServiceBus; - -public class TriggerMessageHandler(ILogger logger) : IHandleMessages -{ - public async Task Handle(TriggerMessage message, IMessageHandlerContext context) - { - logger.LogWarning($"Handling {nameof(TriggerMessage)} in {nameof(TriggerMessageHandler)}"); - - await context.SendLocal(new SomeOtherMessage()).ConfigureAwait(false); - await context.Publish(new SomeEvent()).ConfigureAwait(false); - } -} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.Analyzer/Class1.cs b/src/NServiceBus.AzureFunctions.Analyzer/Class1.cs deleted file mode 100644 index 43a4f26..0000000 --- a/src/NServiceBus.AzureFunctions.Analyzer/Class1.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace NServiceBus.AzureFunctions.Analyzers -{ - public class Class1 - { - - } -} diff --git a/src/NServiceBus.AzureFunctions.Analyzer/CompilationAssemblyDetails.cs b/src/NServiceBus.AzureFunctions.Analyzer/CompilationAssemblyDetails.cs new file mode 100644 index 0000000..6d14cbd --- /dev/null +++ b/src/NServiceBus.AzureFunctions.Analyzer/CompilationAssemblyDetails.cs @@ -0,0 +1,32 @@ +#nullable enable +namespace NServiceBus.AzureFunctions.Analyzer; + +using System.Security.Cryptography; +using System.Text; +using Microsoft.CodeAnalysis; + +record struct CompilationAssemblyDetails(string SimpleName, string Identity) +{ + public static CompilationAssemblyDetails FromAssembly(IAssemblySymbol assembly) => new(assembly.Name, assembly.Identity.GetDisplayName()); + + const string NamePrefix = "GeneratedFunctionRegistrations_"; + const int HashBytesToUse = 10; + + public readonly string ToGenerationClassName() + { + var sb = new StringBuilder(NamePrefix, NamePrefix.Length + SimpleName.Length + 1 + (HashBytesToUse * 2)) + .Append(SimpleName.Replace('.', '_')) + .Append('_'); + + using var sha = SHA256.Create(); + + var identityBytes = Encoding.UTF8.GetBytes(Identity); + var hashBytes = sha.ComputeHash(identityBytes); + for (var i = 0; i < HashBytesToUse; i++) + { + _ = sb.Append(hashBytes[i].ToString("x2")); + } + + return sb.ToString(); + } +} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs b/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs new file mode 100644 index 0000000..4681c44 --- /dev/null +++ b/src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs @@ -0,0 +1,35 @@ +#nullable enable +namespace NServiceBus.AzureFunctions.Analyzer; + +using Microsoft.CodeAnalysis; + +static class DiagnosticIds +{ + public const string ClassMustBePartial = "NSBFUNC001"; + public const string ShouldNotImplementIHandleMessages = "NSBFUNC002"; + public const string MethodMustBePartial = "NSBFUNC003"; + + public static readonly DiagnosticDescriptor ClassMustBePartialDescriptor = new( + id: ClassMustBePartial, + title: "Class containing [NServiceBusFunction] must be partial", + messageFormat: "Class '{0}' must be declared as partial to use [NServiceBusFunction]", + category: "NServiceBus.AzureFunctions", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor ShouldNotImplementIHandleMessagesDescriptor = new( + id: ShouldNotImplementIHandleMessages, + title: "Function class should not implement IHandleMessages", + messageFormat: "Class '{0}' should not implement IHandleMessages; message handlers should be registered separately via IEndpointConfiguration", + category: "NServiceBus.AzureFunctions", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor MethodMustBePartialDescriptor = new( + id: MethodMustBePartial, + title: "Method with [NServiceBusFunction] must be partial", + messageFormat: "Method '{0}' must be declared as partial to use [NServiceBusFunction]", + category: "NServiceBus.AzureFunctions", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); +} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionCompositionGenerator.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionCompositionGenerator.cs new file mode 100644 index 0000000..560c8cf --- /dev/null +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionCompositionGenerator.cs @@ -0,0 +1,201 @@ +#nullable enable +namespace NServiceBus.AzureFunctions.Analyzer; + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; + +[Generator] +public sealed class FunctionCompositionGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var methodsToIntercept = context.SyntaxProvider + .CreateSyntaxProvider(SyntaxLooksLikeAddNServiceBusFunctions, TransformToInterceptor) + .Where(candidate => candidate is not null); + + var registrationTypesFromCompilation = context.CompilationProvider.SelectMany((compilation, ct) => + { + var attributeType = compilation.GetTypeByMetadataName(MarkerAttributeName); + if (attributeType is null) + { + return ImmutableArray.Empty; + } + + return GetAutoGeneratedRegistrationTypes(compilation.GlobalNamespace, attributeType, ct) + .ToImmutableArray(); + }); + + var collectedRegistrationTypes = registrationTypesFromCompilation.Collect(); + var collectedInterceptorLocations = methodsToIntercept.Collect(); + + var currentAssemblyRegistrationTypeByConvention = context.CompilationProvider + .Select((compilation, _) => CompilationAssemblyDetails.FromAssembly(compilation.Assembly)) + .Select((assemblyInfo, _) => assemblyInfo.ToGenerationClassName()) + .Select((className, _) => new GeneratedRegistrationClass($"NServiceBus.Generated.{className}")); + + var allDataForInterceptor = collectedRegistrationTypes + .Combine(collectedInterceptorLocations) + .Combine(currentAssemblyRegistrationTypeByConvention) + .Select((tuple, _) => + { + var ((regClasses, interceptorLocations), currentAssemblyClassName) = tuple; + + if (!interceptorLocations.Any()) + { + return default; + } + + var allClasses = regClasses + .Concat([currentAssemblyClassName]) + .Distinct() + .ToImmutableArray(); + + return new InterceptorData(allClasses, interceptorLocations); + }); + + context.RegisterSourceOutput(allDataForInterceptor, GenerateInterceptorCode); + } + + static bool SyntaxLooksLikeAddNServiceBusFunctions(SyntaxNode node, CancellationToken cancellationToken) => + node is InvocationExpressionSyntax { Expression: MemberAccessExpressionSyntax { Name.Identifier.ValueText: MethodName } }; + + static InterceptDetails? TransformToInterceptor(GeneratorSyntaxContext context, CancellationToken cancellationToken) + { + if (context.Node is not InvocationExpressionSyntax { Expression: MemberAccessExpressionSyntax } invocation) + { + return null; + } + + if (context.SemanticModel.GetOperation(invocation, cancellationToken) is not IInvocationOperation operation) + { + return null; + } + + if (operation.TargetMethod is not + { + Name: MethodName, + ContainingType: + { + Name: ExtensionClassName, + ContainingNamespace: { Name: "NServiceBus", ContainingNamespace.IsGlobalNamespace: true } + } + }) + { + return null; + } + + if (context.SemanticModel.GetInterceptableLocation(invocation, cancellationToken) is not { } location) + { + return null; + } + + return new InterceptDetails(location); + } + + static IEnumerable GetAutoGeneratedRegistrationTypes( + INamespaceSymbol ns, INamedTypeSymbol attributeType, CancellationToken cancellationToken) + { + foreach (var type in ns.GetTypeMembers()) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (type.GetAttributes().Any(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, attributeType))) + { + yield return new GeneratedRegistrationClass(type.ToDisplayString()); + } + } + + foreach (var childNamespace in ns.GetNamespaceMembers()) + { + foreach (var reg in GetAutoGeneratedRegistrationTypes(childNamespace, attributeType, cancellationToken)) + { + yield return reg; + } + } + } + + static void GenerateInterceptorCode(SourceProductionContext spc, InterceptorData data) + { + if (data == default) + { + return; + } + + var (regClasses, interceptLocations) = data; + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + sb.AppendLine("namespace System.Runtime.CompilerServices"); + sb.AppendLine("{"); + sb.AppendLine(" [global::System.Diagnostics.Conditional(\"DEBUG\")]"); + sb.AppendLine(" [global::System.AttributeUsage(global::System.AttributeTargets.Method, AllowMultiple = true)]"); + sb.AppendLine(" sealed file class InterceptsLocationAttribute : global::System.Attribute"); + sb.AppendLine(" {"); + sb.AppendLine(" public InterceptsLocationAttribute(int version, string data)"); + sb.AppendLine(" {"); + sb.AppendLine(" _ = version;"); + sb.AppendLine(" _ = data;"); + sb.AppendLine(" }"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + sb.AppendLine(); + sb.AppendLine("namespace NServiceBus"); + sb.AppendLine("{"); + sb.AppendLine(" static file class NServiceBusFunctionsInterceptors"); + sb.AppendLine(" {"); + + foreach (var location in interceptLocations) + { + if (location is not null) + { + sb.AppendLine($" [global::System.Runtime.CompilerServices.InterceptsLocation({location.Value.Location.Version}, \"{location.Value.Location.Data}\")] // {location.Value.Location.GetDisplayLocation()}"); + } + } + + sb.AppendLine(" public static void AddNServiceBusFunctions("); + sb.AppendLine(" this global::Microsoft.Azure.Functions.Worker.Builder.FunctionsApplicationBuilder builder)"); + sb.AppendLine(" {"); + sb.AppendLine(" global::System.ArgumentNullException.ThrowIfNull(builder);"); + sb.AppendLine(" global::NServiceBus.NServiceBusFunctionsInfrastructure.Initialize(builder);"); + sb.AppendLine(); + + foreach (var regClass in regClasses) + { + sb.AppendLine($" foreach (var m in global::{regClass.FullClassName}.GetFunctionManifests())"); + sb.AppendLine($" global::NServiceBus.FunctionsHostApplicationBuilderExtensions.AddNServiceBusFunction(builder, m);"); + } + + sb.AppendLine(); + + foreach (var regClass in regClasses) + { + sb.AppendLine($" foreach (var m in global::{regClass.FullClassName}.GetSendOnlyManifests())"); + sb.AppendLine($" global::NServiceBus.FunctionsHostApplicationBuilderExtensions.AddSendOnlyNServiceBusEndpoint(builder, m.Name, m.EndpointConfiguration);"); + } + + sb.AppendLine(" }"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + + spc.AddSource("Interception.g.cs", sb.ToString()); + } + + record struct GeneratedRegistrationClass(string FullClassName); + record struct InterceptDetails(InterceptableLocation Location); + record struct InterceptorData( + ImmutableArray RegistrationClasses, + ImmutableArray Locations); + + const string MarkerAttributeName = "NServiceBus.AutoGeneratedFunctionRegistrationsAttribute"; + const string ExtensionClassName = "NServiceBusFunctionsSourceGenExtensions"; + const string MethodName = "AddNServiceBusFunctions"; +} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointAnalyzer.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointAnalyzer.cs new file mode 100644 index 0000000..2adf0ce --- /dev/null +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointAnalyzer.cs @@ -0,0 +1,103 @@ +#nullable enable +namespace NServiceBus.AzureFunctions.Analyzer; + +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class FunctionEndpointAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create( + DiagnosticIds.ClassMustBePartialDescriptor, + DiagnosticIds.ShouldNotImplementIHandleMessagesDescriptor, + DiagnosticIds.MethodMustBePartialDescriptor); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSymbolAction(AnalyzeNamedType, SymbolKind.NamedType); + context.RegisterSymbolAction(AnalyzeMethod, SymbolKind.Method); + } + + static void AnalyzeNamedType(SymbolAnalysisContext context) + { + var type = (INamedTypeSymbol)context.Symbol; + + var hasAttribute = type.GetAttributes() + .Any(a => a.AttributeClass?.ToDisplayString() == "NServiceBus.NServiceBusFunctionAttribute"); + + if (!hasAttribute) + { + return; + } + + // NSBFUNC001: Class must be partial + var declarations = type.DeclaringSyntaxReferences; + var isPartial = declarations.Any(r => + r.GetSyntax(context.CancellationToken) is ClassDeclarationSyntax classDecl && + classDecl.Modifiers.Any(SyntaxKind.PartialKeyword)); + + if (!isPartial) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticIds.ClassMustBePartialDescriptor, + type.Locations.FirstOrDefault(), + type.Name)); + } + + // NSBFUNC002: Should not implement IHandleMessages + var implementsIHandleMessages = type.AllInterfaces + .Any(i => i.OriginalDefinition.ToDisplayString() == "NServiceBus.IHandleMessages"); + + if (implementsIHandleMessages) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticIds.ShouldNotImplementIHandleMessagesDescriptor, + type.Locations.FirstOrDefault(), + type.Name)); + } + } + + static void AnalyzeMethod(SymbolAnalysisContext context) + { + var method = (IMethodSymbol)context.Symbol; + + var hasAttribute = method.GetAttributes() + .Any(a => a.AttributeClass?.ToDisplayString() == "NServiceBus.NServiceBusFunctionAttribute"); + + if (!hasAttribute) + { + return; + } + + // NSBFUNC003: Method must be partial + if (!method.IsPartialDefinition) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticIds.MethodMustBePartialDescriptor, + method.Locations.FirstOrDefault(), + method.Name)); + } + + // NSBFUNC001: Containing class must be partial (when attribute is on method) + var containingType = method.ContainingType; + var declarations = containingType.DeclaringSyntaxReferences; + var isPartial = declarations.Any(r => + r.GetSyntax(context.CancellationToken) is ClassDeclarationSyntax classDecl && + classDecl.Modifiers.Any(SyntaxKind.PartialKeyword)); + + if (!isPartial) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticIds.ClassMustBePartialDescriptor, + containingType.Locations.FirstOrDefault(), + containingType.Name)); + } + } +} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.cs b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.cs new file mode 100644 index 0000000..7db7680 --- /dev/null +++ b/src/NServiceBus.AzureFunctions.Analyzer/FunctionEndpointGenerator.cs @@ -0,0 +1,342 @@ +#nullable enable +namespace NServiceBus.AzureFunctions.Analyzer; + +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +[Generator] +public sealed class FunctionEndpointGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var functionInfos = context.SyntaxProvider + .ForAttributeWithMetadataName( + "NServiceBus.NServiceBusFunctionAttribute", + predicate: static (node, _) => node is ClassDeclarationSyntax or MethodDeclarationSyntax, + transform: static (ctx, ct) => ExtractFunctionInfos(ctx, ct)) + .SelectMany(static (infos, _) => infos); + + var sendOnlyInfos = context.SyntaxProvider + .ForAttributeWithMetadataName( + "NServiceBus.NServiceBusSendOnlyEndpointAttribute", + predicate: static (node, _) => node is ClassDeclarationSyntax, + transform: static (ctx, _) => ExtractSendOnlyInfo(ctx)) + .Where(static info => info is not null) + .Select(static (info, _) => info!.Value); + + var assemblyClassName = context.CompilationProvider + .Select(static (c, _) => CompilationAssemblyDetails.FromAssembly(c.Assembly).ToGenerationClassName()); + + var combined = functionInfos.Collect() + .Combine(sendOnlyInfos.Collect()) + .Combine(assemblyClassName); + + context.RegisterSourceOutput(combined, static (spc, data) => + GenerateSource(spc, data.Left.Left, data.Left.Right, data.Right)); + } + + static ImmutableArray ExtractFunctionInfos(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) + { + if (context.Attributes.Length == 0) + { + return ImmutableArray.Empty; + } + + var attr = context.Attributes[0]; + + // Extract explicit config type from typeof() argument, if provided + INamedTypeSymbol? explicitConfigType = null; + if (attr.ConstructorArguments.Length > 0) + { + explicitConfigType = attr.ConstructorArguments[0].Value as INamedTypeSymbol; + } + + if (context.TargetSymbol is INamedTypeSymbol classSymbol) + { + // Class-level: infer config type from the class itself when no typeof() + var configType = explicitConfigType ?? classSymbol; + var configTypeFullName = configType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + var results = ImmutableArray.CreateBuilder(); + foreach (var member in classSymbol.GetMembers()) + { + cancellationToken.ThrowIfCancellationRequested(); + if (member is IMethodSymbol method) + { + var info = TryExtractFromMethod(method, configTypeFullName); + if (info is not null) + { + results.Add(info.Value); + } + } + } + + return results.ToImmutable(); + } + + if (context.TargetSymbol is IMethodSymbol methodSymbol) + { + // Method-level: infer config type from the containing class when no typeof() + var configType = explicitConfigType ?? methodSymbol.ContainingType; + var configTypeFullName = configType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + var info = TryExtractFromMethod(methodSymbol, configTypeFullName); + return info is not null + ? ImmutableArray.Create(info.Value) + : ImmutableArray.Empty; + } + + return ImmutableArray.Empty; + } + + static SendOnlyEndpointInfo? ExtractSendOnlyInfo(GeneratorAttributeSyntaxContext context) + { + if (context.TargetSymbol is not INamedTypeSymbol classSymbol) + { + return null; + } + + var attr = context.Attributes[0]; + + string? endpointName = null; + if (attr.ConstructorArguments.Length > 0) + { + endpointName = attr.ConstructorArguments[0].Value as string; + } + + // Extract explicit config type from typeof() argument, if provided + INamedTypeSymbol? explicitConfigType = null; + if (attr.ConstructorArguments.Length > 1) + { + explicitConfigType = attr.ConstructorArguments[1].Value as INamedTypeSymbol; + } + + endpointName ??= classSymbol.Name; + + var configType = explicitConfigType ?? classSymbol; + + var configTypeFullName = configType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + return new SendOnlyEndpointInfo(endpointName, configTypeFullName); + } + + static FunctionInfo? TryExtractFromMethod(IMethodSymbol method, string configTypeFullName) + { + var functionAttr = method.GetAttributes() + .FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == "Microsoft.Azure.Functions.Worker.FunctionAttribute"); + if (functionAttr is null || functionAttr.ConstructorArguments.Length == 0) + { + return null; + } + + if (functionAttr.ConstructorArguments[0].Value is not string functionName) + { + return null; + } + + string? queueName = null; + string? connectionName = null; + string? messageParamName = null; + string? functionContextParamName = null; + string? cancellationTokenParamName = null; + + var paramList = new StringBuilder(); + + for (int i = 0; i < method.Parameters.Length; i++) + { + var param = method.Parameters[i]; + if (i > 0) + { + paramList.Append(",\n "); + } + + var paramTypeFqn = param.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + paramList.Append(paramTypeFqn).Append(' ').Append(param.Name); + + foreach (var pAttr in param.GetAttributes()) + { + if (pAttr.AttributeClass?.ToDisplayString() == "Microsoft.Azure.Functions.Worker.ServiceBusTriggerAttribute") + { + if (pAttr.ConstructorArguments.Length > 0) + { + queueName = pAttr.ConstructorArguments[0].Value as string; + } + + foreach (var namedArg in pAttr.NamedArguments) + { + if (namedArg.Key == "Connection") + { + connectionName = namedArg.Value.Value as string; + } + } + + messageParamName = param.Name; + } + } + + if (param.Type.ToDisplayString() == "Microsoft.Azure.Functions.Worker.FunctionContext") + { + functionContextParamName = param.Name; + } + + if (param.Type.ToDisplayString() == "System.Threading.CancellationToken") + { + cancellationTokenParamName = param.Name; + } + } + + if (queueName is null || functionContextParamName is null || messageParamName is null) + { + return null; + } + + connectionName ??= ""; + cancellationTokenParamName ??= "cancellationToken"; + + var containingType = method.ContainingType; + var ns = containingType.ContainingNamespace.ToDisplayString(); + var className = containingType.Name; + var returnType = method.ReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + var accessibility = method.DeclaredAccessibility switch + { + Accessibility.NotApplicable => "public", + Accessibility.Private => "private", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.Protected => "protected", + Accessibility.Internal => "internal", + Accessibility.ProtectedOrInternal => "protected internal", + Accessibility.Public => "public", + _ => "public", + }; + + return new FunctionInfo( + ns, className, accessibility, method.Name, returnType, + paramList.ToString(), messageParamName, functionContextParamName, + cancellationTokenParamName, functionName, queueName, connectionName, + configTypeFullName); + } + + static void GenerateSource(SourceProductionContext spc, ImmutableArray functions, ImmutableArray sendOnlyEndpoints, string assemblyClassName) + { + if (functions.Length > 0) + { + GenerateMethodBodies(spc, functions); + } + + // Always generate the registration class so the composition generator + // can discover it by convention for same-project scenarios + GenerateRegistration(spc, functions, sendOnlyEndpoints, assemblyClassName); + } + + static void GenerateMethodBodies(SourceProductionContext spc, ImmutableArray functions) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("using Microsoft.Extensions.DependencyInjection;"); + + var groups = functions.GroupBy(f => (f.ContainingNamespace, f.ContainingClassName)); + + foreach (var group in groups) + { + sb.AppendLine(); + sb.AppendLine($"namespace {group.Key.ContainingNamespace}"); + sb.AppendLine("{"); + sb.AppendLine($" public partial class {group.Key.ContainingClassName}"); + sb.AppendLine(" {"); + + bool first = true; + foreach (var func in group) + { + if (!first) + { + sb.AppendLine(); + } + + first = false; + + sb.AppendLine($" {func.Accessibility} partial {func.ReturnType} {func.MethodName}("); + sb.AppendLine($" {func.ParameterList})"); + sb.AppendLine(" {"); + sb.AppendLine($" var processor = {func.FunctionContextParamName}.InstanceServices"); + sb.AppendLine($" .GetKeyedService(\"{func.FunctionName}\");"); + sb.AppendLine(" if (processor is null)"); + sb.AppendLine(" {"); + sb.AppendLine($" throw new global::System.InvalidOperationException(\"{func.FunctionName} has not been registered.\");"); + sb.AppendLine(" }"); + sb.AppendLine($" return processor.Process({func.MessageParamName}, {func.FunctionContextParamName}, {func.CancellationTokenParamName});"); + sb.AppendLine(" }"); + } + + sb.AppendLine(" }"); + sb.AppendLine("}"); + } + + spc.AddSource("FunctionMethodBodies.g.cs", sb.ToString()); + } + + static void GenerateRegistration(SourceProductionContext spc, ImmutableArray functions, ImmutableArray sendOnlyEndpoints, string assemblyClassName) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("namespace NServiceBus.Generated"); + sb.AppendLine("{"); + sb.AppendLine(" [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]"); + sb.AppendLine(" [global::NServiceBus.AutoGeneratedFunctionRegistrationsAttribute]"); + sb.AppendLine($" public static class {assemblyClassName}"); + sb.AppendLine(" {"); + sb.AppendLine(" public static global::System.Collections.Generic.IEnumerable"); + sb.AppendLine(" GetFunctionManifests()"); + sb.AppendLine(" {"); + + foreach (var func in functions) + { + sb.AppendLine($" yield return new global::NServiceBus.FunctionManifest("); + sb.AppendLine($" \"{func.FunctionName}\", \"{func.QueueName}\", \"{func.ConnectionName}\","); + sb.AppendLine($" ec=>{func.ConfigTypeFullName}.Configure{func.FunctionName}(ec));"); + } + + sb.AppendLine(" yield break;"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" public static global::System.Collections.Generic.IEnumerable"); + sb.AppendLine(" GetSendOnlyManifests()"); + sb.AppendLine(" {"); + + foreach (var endpoint in sendOnlyEndpoints) + { + sb.AppendLine($" yield return new global::NServiceBus.SendOnlyManifest("); + sb.AppendLine($" \"{endpoint.EndpointName}\", ec=>{endpoint.ConfigTypeFullName}.Configure{endpoint.EndpointName}(ec));"); + } + + sb.AppendLine(" yield break;"); + sb.AppendLine(" }"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + + spc.AddSource("FunctionRegistration.g.cs", sb.ToString()); + } + + record struct FunctionInfo( + string ContainingNamespace, + string ContainingClassName, + string Accessibility, + string MethodName, + string ReturnType, + string ParameterList, + string MessageParamName, + string FunctionContextParamName, + string CancellationTokenParamName, + string FunctionName, + string QueueName, + string ConnectionName, + string ConfigTypeFullName); + + record struct SendOnlyEndpointInfo( + string EndpointName, + string ConfigTypeFullName); +} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.Analyzer/NServiceBus.AzureFunctions.Analyzer.csproj b/src/NServiceBus.AzureFunctions.Analyzer/NServiceBus.AzureFunctions.Analyzer.csproj index 469266e..9703da8 100644 --- a/src/NServiceBus.AzureFunctions.Analyzer/NServiceBus.AzureFunctions.Analyzer.csproj +++ b/src/NServiceBus.AzureFunctions.Analyzer/NServiceBus.AzureFunctions.Analyzer.csproj @@ -12,8 +12,7 @@ - - + diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/AutoGeneratedFunctionRegistrationsAttribute.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/AutoGeneratedFunctionRegistrationsAttribute.cs new file mode 100644 index 0000000..f268fd1 --- /dev/null +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/AutoGeneratedFunctionRegistrationsAttribute.cs @@ -0,0 +1,7 @@ +namespace NServiceBus; + +using System.ComponentModel; + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +[EditorBrowsable(EditorBrowsableState.Never)] +public sealed class AutoGeneratedFunctionRegistrationsAttribute : Attribute { } \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusServerlessTransport.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusServerlessTransport.cs index 267f13b..5227916 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusServerlessTransport.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/AzureServiceBusServerlessTransport.cs @@ -9,31 +9,19 @@ namespace NServiceBus; using Microsoft.Extensions.Azure; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using NServiceBus.AzureFunctions.AzureServiceBus; using NServiceBus.AzureFunctions.AzureServiceBus.Serverless.TransportWrapper; using NServiceBus.Transport; -public class AzureServiceBusServerlessTransport : TransportDefinition +public class AzureServiceBusServerlessTransport(TopicTopology topology) : TransportDefinition(TransportTransactionMode.ReceiveOnly, + supportsDelayedDelivery: true, + supportsPublishSubscribe: true, + supportsTTBR: true) { - readonly AzureServiceBusTransport innerTransport; - - public AzureServiceBusServerlessTransport(TopicTopology topology) - : base(TransportTransactionMode.ReceiveOnly, - supportsDelayedDelivery: true, - supportsPublishSubscribe: true, - supportsTTBR: true) - { - innerTransport = new AzureServiceBusTransport("TransportWillBeInitializedCorrectlyLater", topology) - { - TransportTransactionMode = TransportTransactionMode.ReceiveOnly - }; - } - protected override void ConfigureServicesCore(IServiceCollection services) => innerTransport.ConfigureServices(services); public string ConnectionName { get; set; } = DefaultServiceBusConnectionName; - internal IInternalMessageProcessor MessageProcessor { get; private set; } = null!; + internal PipelineInvokingMessageProcessor? MessageProcessor { get; private set; } public override async Task Initialize( HostSettings hostSettings, @@ -68,15 +56,16 @@ public override async Task Initialize( var isSendOnly = hostSettings.CoreSettings.GetOrDefault(SendOnlyConfigKey); - MessageProcessor = isSendOnly - ? new SendOnlyMessageProcessor() - : (IInternalMessageProcessor)serverlessTransportInfrastructure.Receivers[MainReceiverId]; + if (!isSendOnly) + { + MessageProcessor = (PipelineInvokingMessageProcessor)serverlessTransportInfrastructure.Receivers[MainReceiverId]; + } return serverlessTransportInfrastructure; } - public override IReadOnlyCollection GetSupportedTransactionModes() => supportedTransactionModes; - + public override IReadOnlyCollection GetSupportedTransactionModes() => [TransportTransactionMode.ReceiveOnly]; + static AzureServiceBusTransport ConfigureTransportConnection( string connectionName, IConfiguration configuration, @@ -123,9 +112,9 @@ static AzureServiceBusTransport ConfigureTransportConnection( [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "k__BackingField")] static extern ref TokenCredential GetTokenCredentialRef(AzureServiceBusTransport transport); + readonly AzureServiceBusTransport innerTransport = new("TransportWillBeInitializedCorrectlyLater", topology) { TransportTransactionMode = TransportTransactionMode.ReceiveOnly }; + const string MainReceiverId = "Main"; - const string SendOnlyConfigKey = "Endpoint.SendOnly"; + internal const string SendOnlyConfigKey = "Endpoint.SendOnly"; internal const string DefaultServiceBusConnectionName = "AzureWebJobsServiceBus"; - - readonly TransportTransactionMode[] supportedTransactionModes = [TransportTransactionMode.ReceiveOnly]; } \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/FunctionManifest.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/FunctionManifest.cs new file mode 100644 index 0000000..a951618 --- /dev/null +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/FunctionManifest.cs @@ -0,0 +1,3 @@ +namespace NServiceBus; + +public record FunctionManifest(string Name, string Queue, string ConnectionName, Action EndpointConfiguration); \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/FunctionsHostApplicationBuilderExtensions.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/FunctionsHostApplicationBuilderExtensions.cs index 3e9e22b..053925d 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/FunctionsHostApplicationBuilderExtensions.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/FunctionsHostApplicationBuilderExtensions.cs @@ -2,6 +2,7 @@ namespace NServiceBus; using System; using AzureFunctions.AzureServiceBus; +using Configuration.AdvancedExtensibility; using Microsoft.Azure.Functions.Worker.Builder; using Microsoft.Extensions.Azure; using Microsoft.Extensions.DependencyInjection; @@ -11,6 +12,39 @@ namespace NServiceBus; public static class FunctionsHostApplicationBuilderExtensions { public static void AddNServiceBusFunction( + this FunctionsApplicationBuilder builder, + FunctionManifest functionManifest) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(functionManifest); + + builder.Services.AddAzureClientsCore(); + + var endpointName = functionManifest.Name; + builder.AddNServiceBusEndpoint(endpointName, endpointConfiguration => + { + functionManifest.EndpointConfiguration(endpointConfiguration); + + var settings = endpointConfiguration.GetSettings(); + if (settings.GetOrDefault(AzureServiceBusServerlessTransport.SendOnlyConfigKey)) + { + throw new InvalidOperationException($"Functions can't be send only endpoints, use {nameof(AddSendOnlyNServiceBusEndpoint)}"); + } + + var transport = GetTransport(settings); + + if (functionManifest.Name != functionManifest.Queue) + { + endpointConfiguration.OverrideLocalAddress(functionManifest.Queue); + } + + transport.ConnectionName = functionManifest.ConnectionName; + + builder.Services.AddKeyedSingleton(endpointName, (sp, _) => new MessageProcessor(transport, sp.GetRequiredKeyedService(endpointName))); + }); + } + + public static void AddSendOnlyNServiceBusEndpoint( this FunctionsApplicationBuilder builder, string endpointName, Action configure) @@ -19,16 +53,29 @@ public static void AddNServiceBusFunction( ArgumentNullException.ThrowIfNull(endpointName); ArgumentNullException.ThrowIfNull(configure); - builder.Services.AddAzureClientsCore(); + builder.AddNServiceBusEndpoint(endpointName, endpointConfiguration => + { + configure(endpointConfiguration); - builder.AddNServiceBusEndpoint(endpointName, configure); + endpointConfiguration.SendOnly(); - builder.Services.AddKeyedSingleton(endpointName, (sp, _) => - { - var settings = sp.GetRequiredKeyedService(endpointName); - var transport = settings.Get() as AzureServiceBusServerlessTransport - ?? throw new InvalidOperationException($"Endpoint '{endpointName}' must be configured with an AzureServiceBusServerlessTransport."); - return new MessageProcessor(transport, sp.GetRequiredKeyedService(endpointName)); + // Make sure that the correct transport is used + _ = GetTransport(endpointConfiguration.GetSettings()); }); } + + static AzureServiceBusServerlessTransport GetTransport(SettingsHolder settings) + { + if (!settings.TryGet(out TransportDefinition transport)) + { + throw new InvalidOperationException($"{nameof(AzureServiceBusServerlessTransport)} needs to be configured"); + } + + if (transport is not AzureServiceBusServerlessTransport serverlessTransport) + { + throw new InvalidOperationException($"Endpoint must be configured with an {nameof(AzureServiceBusServerlessTransport)}."); + } + + return serverlessTransport; + } } \ No newline at end of file diff --git a/src/IntegrationTest/InitializeLogger.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/InitializeLogger.cs similarity index 100% rename from src/IntegrationTest/InitializeLogger.cs rename to src/NServiceBus.AzureFunctions.AzureServiceBus/InitializeLogger.cs diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/MessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/MessageProcessor.cs index 1e6201b..c164163 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/MessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/MessageProcessor.cs @@ -6,13 +6,27 @@ namespace NServiceBus.AzureFunctions.AzureServiceBus; using Microsoft.Azure.Functions.Worker; using NServiceBus.MultiHosting; -public class MessageProcessor(AzureServiceBusServerlessTransport transport, EndpointStarter endpointStarter) : IMessageProcessor +public class MessageProcessor(AzureServiceBusServerlessTransport transport, EndpointStarter endpointStarter) { - public async Task Process(ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, - FunctionContext functionContext, CancellationToken cancellationToken = default) + public async Task Process(ServiceBusReceivedMessage message, FunctionContext functionContext, CancellationToken cancellationToken = default) { + var endpointName = endpointStarter.ServiceKey; + + var logger = functionContext.GetLogger("MessageProcessor"); + + //TODO: Should we add things like the MessageId? + using var scope = logger.BeginScope(new Dictionary { ["Endpoint"] = endpointName }); + using var _ = MultiEndpointLoggerFactory.Instance.PushName(endpointStarter.ServiceKey); + await endpointStarter.GetOrStart(cancellationToken).ConfigureAwait(false); - await transport.MessageProcessor.Process(message, messageActions, cancellationToken).ConfigureAwait(false); + + if (transport.MessageProcessor is null) + { + // This should never happen but we need to protect against it anyways + throw new InvalidOperationException($"Endpoint {endpointName} cannot process messages because it is configured in send-only mode."); + } + + await transport.MessageProcessor.Process(message, cancellationToken).ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/NServiceBus.AzureFunctions.AzureServiceBus.csproj b/src/NServiceBus.AzureFunctions.AzureServiceBus/NServiceBus.AzureFunctions.AzureServiceBus.csproj index c391d43..30e6569 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/NServiceBus.AzureFunctions.AzureServiceBus.csproj +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/NServiceBus.AzureFunctions.AzureServiceBus.csproj @@ -16,6 +16,9 @@ + diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/NServiceBusFunctionAttribute.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/NServiceBusFunctionAttribute.cs new file mode 100644 index 0000000..e78586b --- /dev/null +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/NServiceBusFunctionAttribute.cs @@ -0,0 +1,4 @@ +namespace NServiceBus; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public class NServiceBusFunctionAttribute : Attribute; \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/NServiceBusFunctionsInfrastructure.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/NServiceBusFunctionsInfrastructure.cs new file mode 100644 index 0000000..3200160 --- /dev/null +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/NServiceBusFunctionsInfrastructure.cs @@ -0,0 +1,19 @@ +namespace NServiceBus; + +using System.ComponentModel; +using Logging; +using Microsoft.Azure.Functions.Worker.Builder; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.DependencyInjection; +using MultiHosting; + +[EditorBrowsable(EditorBrowsableState.Never)] +public static class NServiceBusFunctionsInfrastructure +{ + public static void Initialize(FunctionsApplicationBuilder builder) + { + LogManager.UseFactory(MultiEndpointLoggerFactory.Instance); + builder.Services.AddHostedService(); + builder.Services.AddAzureClientsCore(); + } +} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/NServiceBusFunctionsSourceGenExtensions.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/NServiceBusFunctionsSourceGenExtensions.cs new file mode 100644 index 0000000..3b2f4c9 --- /dev/null +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/NServiceBusFunctionsSourceGenExtensions.cs @@ -0,0 +1,10 @@ +namespace NServiceBus; + +using Microsoft.Azure.Functions.Worker.Builder; + +public static class NServiceBusFunctionsSourceGenExtensions +{ + public static void AddNServiceBusFunctions(this FunctionsApplicationBuilder builder) + => throw new NotImplementedException( + "Source generators must be enabled to use AddNServiceBusFunctions."); +} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/NServiceBusSendOnlyEndpointAttribute.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/NServiceBusSendOnlyEndpointAttribute.cs new file mode 100644 index 0000000..8ed54df --- /dev/null +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/NServiceBusSendOnlyEndpointAttribute.cs @@ -0,0 +1,7 @@ +namespace NServiceBus; + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public class NServiceBusSendOnlyEndpointAttribute(string? endpointName = null) : Attribute +{ + public string? EndpointName { get; } = endpointName; +} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/SendOnlyManifest.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/SendOnlyManifest.cs new file mode 100644 index 0000000..d364457 --- /dev/null +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/SendOnlyManifest.cs @@ -0,0 +1,3 @@ +namespace NServiceBus; + +public record SendOnlyManifest(string Name, Action EndpointConfiguration); \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/Serverless/TransportWrapper/IMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/Serverless/TransportWrapper/IMessageProcessor.cs deleted file mode 100644 index f8ac6e5..0000000 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/Serverless/TransportWrapper/IMessageProcessor.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace NServiceBus.AzureFunctions.AzureServiceBus; - -using System.Threading; -using System.Threading.Tasks; -using Azure.Messaging.ServiceBus; -using Microsoft.Azure.Functions.Worker; -public interface IMessageProcessor -{ - Task Process(ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, FunctionContext functionContext, CancellationToken cancellationToken = default); -} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/Serverless/TransportWrapper/PipelineInvokingMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/Serverless/TransportWrapper/PipelineInvokingMessageProcessor.cs index ae4f01c..ad95716 100644 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/Serverless/TransportWrapper/PipelineInvokingMessageProcessor.cs +++ b/src/NServiceBus.AzureFunctions.AzureServiceBus/Serverless/TransportWrapper/PipelineInvokingMessageProcessor.cs @@ -4,19 +4,11 @@ namespace NServiceBus.AzureFunctions.AzureServiceBus.Serverless.TransportWrapper using System.Threading; using System.Threading.Tasks; using Azure.Messaging.ServiceBus; -using Microsoft.Azure.Functions.Worker; using NServiceBus.Extensibility; using NServiceBus.Transport; using NServiceBus.Transport.AzureServiceBus; -interface IInternalMessageProcessor -{ - Task Process(ServiceBusReceivedMessage message, - ServiceBusMessageActions messageActions, - CancellationToken cancellationToken = default); -} - -class PipelineInvokingMessageProcessor(IMessageReceiver baseTransportReceiver) : IMessageReceiver, IInternalMessageProcessor +class PipelineInvokingMessageProcessor(IMessageReceiver baseTransportReceiver) : IMessageReceiver { public Task Initialize(PushRuntimeSettings limitations, OnMessage onMessage, OnError onError, CancellationToken cancellationToken = default) @@ -29,9 +21,7 @@ public Task Initialize(PushRuntimeSettings limitations, OnMessage onMessage, OnE cancellationToken) ?? Task.CompletedTask; } - public async Task Process(ServiceBusReceivedMessage message, - ServiceBusMessageActions messageActions, - CancellationToken cancellationToken = default) + public async Task Process(ServiceBusReceivedMessage message, CancellationToken cancellationToken = default) { var messageId = message.GetMessageId(); var body = message.GetBody(); diff --git a/src/NServiceBus.AzureFunctions.AzureServiceBus/Serverless/TransportWrapper/SendOnlyMessageProcessor.cs b/src/NServiceBus.AzureFunctions.AzureServiceBus/Serverless/TransportWrapper/SendOnlyMessageProcessor.cs deleted file mode 100644 index d2f3e83..0000000 --- a/src/NServiceBus.AzureFunctions.AzureServiceBus/Serverless/TransportWrapper/SendOnlyMessageProcessor.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace NServiceBus.AzureFunctions.AzureServiceBus.Serverless.TransportWrapper; - -using System; -using System.Threading; -using System.Threading.Tasks; -using Azure.Messaging.ServiceBus; -using Microsoft.Azure.Functions.Worker; - -class SendOnlyMessageProcessor : IInternalMessageProcessor -{ - public Task Process(ServiceBusReceivedMessage message, - ServiceBusMessageActions messageActions, - CancellationToken cancellationToken = default) => throw new InvalidOperationException( - $"This endpoint cannot process messages because it is configured in send-only mode. Remove the '{nameof(EndpointConfiguration)}.{nameof(EndpointConfiguration.SendOnly)}' configuration.'" - ); -} \ No newline at end of file diff --git a/src/NServiceBus.AzureFunctions.slnx b/src/NServiceBus.AzureFunctions.slnx index 49cadcd..b657399 100644 --- a/src/NServiceBus.AzureFunctions.slnx +++ b/src/NServiceBus.AzureFunctions.slnx @@ -9,6 +9,10 @@ + + + + diff --git a/src/NServiceBus.MultiHosting/NServiceBusHostedService.cs b/src/NServiceBus.MultiHosting/NServiceBusHostedService.cs index 7451a29..66f6bb0 100644 --- a/src/NServiceBus.MultiHosting/NServiceBusHostedService.cs +++ b/src/NServiceBus.MultiHosting/NServiceBusHostedService.cs @@ -5,15 +5,22 @@ namespace NServiceBus.MultiHosting; /// /// Hosted service that starts NServiceBus endpoints in multi-endpoint scenarios. /// -public sealed class NServiceBusHostedService(IEndpointStarter endpointStarter) : IHostedService, IAsyncDisposable +public sealed class NServiceBusHostedService(IEndpointStarter endpointStarter) : IHostedLifecycleService, IAsyncDisposable { - public async Task StartAsync(CancellationToken cancellationToken = default) - => await endpointStarter.GetOrStart(cancellationToken).ConfigureAwait(false); + public async Task StartingAsync(CancellationToken cancellationToken) => await endpointStarter.GetOrStart(cancellationToken).ConfigureAwait(false); - public Task StopAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask; public async ValueTask DisposeAsync() { await endpointStarter.DisposeAsync().ConfigureAwait(false); } + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; }