Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a51e2b0
First approach with incorectly reused generator
andreasohlund Jun 3, 2026
5858056
Fixup
andreasohlund Jun 3, 2026
12c835b
Add separate SendOnlyEndpointGenerator
andreasohlund Jun 3, 2026
bc00999
Separate generator for send only
andreasohlund Jun 3, 2026
6fec1dd
Fix formatting issues in approved test files and ensure proper closur…
andreasohlund Jun 3, 2026
65a2564
Fix function generator file formatting
andreasohlund Jun 3, 2026
334a3b6
Refactor usings in tests and remove global usings for clarity
andreasohlund Jun 3, 2026
b87c5eb
Refactor attribute handling and diagnostics in endpoint generators
danielmarbach Jun 3, 2026
5ee24c7
Implement IDiagnosticsSpec interface and refactor diagnostic collecti…
danielmarbach Jun 3, 2026
e33aa99
Add tests for SendOnlyEndpointGenerator and validate endpoint generat…
danielmarbach Jun 3, 2026
2393be0
Add AssertRunsAreEqual to all generator approval tests
danielmarbach Jun 3, 2026
a4e76b3
Remove unused using directive from FunctionEndpointGenerator
danielmarbach Jun 3, 2026
0c7f320
Inline SendOnlyEndpointDefinition into static Select lambda
danielmarbach Jun 3, 2026
129707c
Hardcode registration method name in SendOnlyEndpointDefinition
danielmarbach Jun 3, 2026
d68f79c
Remove file specification and unnecessary using
danielmarbach Jun 3, 2026
32ed16d
Remove the send-only API name
danielmarbach Jun 3, 2026
c340d96
Inline trigger definition to have a pure incremental pipeline
danielmarbach Jun 3, 2026
18f5c88
Comment for ourselves in the future
danielmarbach Jun 3, 2026
020fb12
Reduce complexity in the configuration analyzer because now that we h…
danielmarbach Jun 3, 2026
9f1395f
fix connection setting assignment
danielmarbach Jun 3, 2026
496f20d
Approve
danielmarbach Jun 3, 2026
135bcb9
Support inferring the connection automatically
danielmarbach Jun 3, 2026
41cc7cd
Trigger
danielmarbach Jun 3, 2026
3684839
Rename NServiceBusSendOnlyEndpointAttribute to NServiceBusSendOnlyFun…
danielmarbach Jun 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/IntegrationTestApp/ClientEndpoint.cs
Original file line number Diff line number Diff line change
@@ -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<SystemJsonSerializer>();
}
}
12 changes: 0 additions & 12 deletions src/IntegrationTestApp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SystemJsonSerializer>();
});

var host = builder.Build();

await host.RunAsync();
Original file line number Diff line number Diff line change
Expand Up @@ -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}";
}
}
291 changes: 45 additions & 246 deletions src/NServiceBus.AzureFunctions.Analyzer/ConfigurationAnalyzer.cs

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions src/NServiceBus.AzureFunctions.Analyzer/DiagnosticIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}' is not a valid NServiceBus send-only endpoint: {1}",
Comment on lines +105 to +106
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DavidBoike this needs some editorial love

category: "NServiceBus.AzureFunctions",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
}
9 changes: 9 additions & 0 deletions src/NServiceBus.AzureFunctions.Analyzer/DiagnosticsSpec.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace NServiceBus.AzureFunctions.Analyzer;

using Core.Analyzer;
using Microsoft.CodeAnalysis;

interface IDiagnosticsSpec
{
ImmutableEquatableArray<Diagnostic> Diagnostics { get; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace NServiceBus.AzureFunctions.Analyzer;

using System.Collections.Immutable;
using Core.Analyzer;
using Microsoft.CodeAnalysis;

static class DiagnosticsSpecExtensions
{
public static IEnumerable<Diagnostic> ToDiagnostics<T>(this ImmutableArray<T> 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<Diagnostic>();
foreach (var result in results)
{
diagnostics.UnionWith(result.Diagnostics);
}

return diagnostics.ToImmutableEquatableArray();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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)
Expand All @@ -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<GeneratedRegistrationClassSpec> 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<GeneratedRegistrationClassSpec> 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<GeneratedRegistrationClassSpec> RegistrationClasses, string? RootNamespace);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -22,5 +16,8 @@ public sealed partial class FunctionEndpointGenerator
ParameterRole.TriggerMessage,
MessageActions,
ParameterRole.FunctionContext,
ParameterRole.CancellationToken));
}
ParameterRole.CancellationToken))
{
static readonly ParameterRole MessageActions = new("MessageActions");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 ??= "";
Expand Down Expand Up @@ -473,7 +473,7 @@ static string FormatShape(ImmutableEquatableArray<ParameterRole> 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;
}

Expand Down Expand Up @@ -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)
{
Expand All @@ -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<string> ParameterTypeNames);
Expand All @@ -592,7 +575,7 @@ internal sealed record FunctionSpec(
string ProcessCallExpression,
ConfigureMethodSpec ConfigureMethod);

internal readonly record struct FunctionSpecs(ImmutableEquatableArray<FunctionSpec> Functions, ImmutableEquatableArray<Diagnostic> Diagnostics)
internal readonly record struct FunctionSpecs(ImmutableEquatableArray<FunctionSpec> Functions, ImmutableEquatableArray<Diagnostic> Diagnostics) : IDiagnosticsSpec
{
public static FunctionSpecs Empty { get; } = new(ImmutableEquatableArray<FunctionSpec>.Empty, ImmutableEquatableArray<Diagnostic>.Empty);
}
Expand Down
Loading