From 0a71d480b5a4b6cd22953b1642c0d04e1a5e865e Mon Sep 17 00:00:00 2001 From: Anders-Toegersen Date: Thu, 22 Jan 2026 10:56:46 +0100 Subject: [PATCH 1/8] chore: added branch protection to publish --- .github/rulesets/publish-branch.json | 40 ++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/rulesets/publish-branch.json diff --git a/.github/rulesets/publish-branch.json b/.github/rulesets/publish-branch.json new file mode 100644 index 0000000..7f85da2 --- /dev/null +++ b/.github/rulesets/publish-branch.json @@ -0,0 +1,40 @@ +{ + "name": "Publish Branch Protection", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["refs/heads/publish"], + "exclude": [] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": true + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "required_status_checks": [ + { + "context": "check-version" + } + ] + } + } + ] +} From 27d32387b445abd8d89343d1382874ee771a73e9 Mon Sep 17 00:00:00 2001 From: Anders-Toegersen Date: Thu, 22 Jan 2026 10:57:06 +0100 Subject: [PATCH 2/8] chore: made sample apps use prerelease package --- .../SampleFunctionApp.SourceGenerated.csproj | 14 ++++++-------- sample/SampleFunctionApp/SampleFunctionApp.csproj | 5 +---- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/sample/SampleFunctionApp.SourceGenerated/SampleFunctionApp.SourceGenerated.csproj b/sample/SampleFunctionApp.SourceGenerated/SampleFunctionApp.SourceGenerated.csproj index dbd8414..a8baf29 100644 --- a/sample/SampleFunctionApp.SourceGenerated/SampleFunctionApp.SourceGenerated.csproj +++ b/sample/SampleFunctionApp.SourceGenerated/SampleFunctionApp.SourceGenerated.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -9,13 +9,11 @@ - - - - - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/sample/SampleFunctionApp/SampleFunctionApp.csproj b/sample/SampleFunctionApp/SampleFunctionApp.csproj index 9d5ac52..da2f3f7 100644 --- a/sample/SampleFunctionApp/SampleFunctionApp.csproj +++ b/sample/SampleFunctionApp/SampleFunctionApp.csproj @@ -7,10 +7,7 @@ - - - - + From 9925a05d6c4674a7b2904403ea2d3993b8ec1f12 Mon Sep 17 00:00:00 2001 From: Anders-Toegersen Date: Thu, 22 Jan 2026 11:16:17 +0100 Subject: [PATCH 3/8] chore: added shields to README --- README.md | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f0884f0..4c63b3b 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,12 @@ # AzureFunctions.DisabledWhen -Conditionally disable Azure Functions based on configuration values using attributes. - -## Packages +![NuGet](https://img.shields.io/nuget/v/AzureFunctions.DisabledWhen?logo=nuget) ![License](https://img.shields.io/github/license/Anders-Toegersen/AzureFunctions.DisabledWhen) -| Package | Description | -|---------|-------------| -| `AzureFunctions.DisabledWhen` | Reflection-based implementation | -| `AzureFunctions.DisabledWhen.SourceGenerator` | Source-generated implementation | +Conditionally disable Azure Functions based on configuration values using attributes. ## Installation -### Reflection-based (simple setup) +### Simple setup ```bash dotnet add package AzureFunctions.DisabledWhen @@ -19,7 +14,7 @@ dotnet add package AzureFunctions.DisabledWhen See [`sample/SampleFunctionApp`](sample/SampleFunctionApp) for a working example. -### Source-generated (AOT-compatible) +### Source generated setup ```bash dotnet add package AzureFunctions.DisabledWhen From 93faf6c979cda31178b4f23114ae1ef3d19a10f5 Mon Sep 17 00:00:00 2001 From: Anders-Toegersen Date: Tue, 17 Feb 2026 16:36:09 +0100 Subject: [PATCH 4/8] feat(IFunctionsWorkerApplicationBuilder): implemented new host builder --- README.md | 18 ++- .../DisabledWhenGenerator.cs | 15 ++- .../Internal/Emitter.cs | 18 ++- .../Internal/InterceptorInfo.cs | 11 +- ...tionsWorkerApplicationBuilderExtensions.cs | 14 +++ .../AppBuilderStartupTests.cs | 92 ++++++++++++++ .../FunctionMetadataAppBuilderTestHost.cs | 117 ++++++++++++++++++ .../AppBuilderStartupTests.cs | 92 ++++++++++++++ .../FunctionMetadataAppBuilderTestHost.cs | 117 ++++++++++++++++++ 9 files changed, 488 insertions(+), 6 deletions(-) create mode 100644 src/AzureFunctions.DisabledWhen/IFunctionsWorkerApplicationBuilderExtensions.cs create mode 100644 tests/AzureFunctions.DisabledWhen.SourceGenerator.Tests/AppBuilderStartupTests.cs create mode 100644 tests/AzureFunctions.DisabledWhen.SourceGenerator.Tests/FunctionMetadataAppBuilderTestHost.cs create mode 100644 tests/AzureFunctions.DisabledWhen.Tests/AppBuilderStartupTests.cs create mode 100644 tests/AzureFunctions.DisabledWhen.Tests/FunctionMetadataAppBuilderTestHost.cs diff --git a/README.md b/README.md index 4c63b3b..be85fb1 100644 --- a/README.md +++ b/README.md @@ -37,14 +37,28 @@ See [`sample/SampleFunctionApp.SourceGenerated`](sample/SampleFunctionApp.Source ### 1. Register the metadata provider -In your `Program.cs`, call `UseDisabledWhen()` on the host builder **after** `ConfigureFunctionsWebApplication()`: +In your `Program.cs`, call `UseDisabledWhen()` on the builder **after** `ConfigureFunctionsWebApplication()`: ```csharp using AzureFunctions.DisabledWhen; +using Microsoft.Azure.Functions.Worker.Builder; +using Microsoft.Extensions.Hosting; +var builder = FunctionsApplication.CreateBuilder(args); + +builder.ConfigureFunctionsWebApplication(); +builder.UseDisabledWhen(); + +var host = builder.Build(); +host.Run(); +``` + +The legacy `IHostBuilder` pattern is also supported: + +```csharp var host = new HostBuilder() .ConfigureFunctionsWebApplication() - .UseDisabledWhen() // Must be called after ConfigureFunctionsWebApplication + .UseDisabledWhen() .Build(); host.Run(); diff --git a/src/AzureFunctions.DisabledWhen.SourceGenerator/DisabledWhenGenerator.cs b/src/AzureFunctions.DisabledWhen.SourceGenerator/DisabledWhenGenerator.cs index faa8866..b517eb3 100644 --- a/src/AzureFunctions.DisabledWhen.SourceGenerator/DisabledWhenGenerator.cs +++ b/src/AzureFunctions.DisabledWhen.SourceGenerator/DisabledWhenGenerator.cs @@ -13,6 +13,7 @@ public sealed class DisabledWhenGenerator : IIncrementalGenerator private const string DisabledWhenLocalAttributeName = "AzureFunctions.DisabledWhen.DisabledWhenLocalAttribute"; private const string DisabledWhenNullOrEmptyAttributeName = "AzureFunctions.DisabledWhen.DisabledWhenNullOrEmptyAttribute"; private const string UseDisabledWhenMethodName = "AzureFunctions.DisabledWhen.IHostBuilderExtensions.UseDisabledWhen"; + private const string UseDisabledWhenAppBuilderMethodName = "AzureFunctions.DisabledWhen.IFunctionsWorkerApplicationBuilderExtensions.UseDisabledWhen"; public void Initialize(IncrementalGeneratorInitializationContext context) { @@ -99,7 +100,17 @@ invocation.Expression is MemberAccessExpressionSyntax memberAccess && } var fullName = $"{methodSymbol.ContainingType.ToDisplayString()}.{methodSymbol.Name}"; - if (fullName != UseDisabledWhenMethodName) + + BuilderType builderType; + if (fullName == UseDisabledWhenMethodName) + { + builderType = BuilderType.HostBuilder; + } + else if (fullName == UseDisabledWhenAppBuilderMethodName) + { + builderType = BuilderType.FunctionsWorkerApplicationBuilder; + } + else { return null; } @@ -112,7 +123,7 @@ invocation.Expression is MemberAccessExpressionSyntax memberAccess && return null; } - return new InterceptorInfo(interceptableLocation.Version, interceptableLocation.Data); + return new InterceptorInfo(interceptableLocation.Version, interceptableLocation.Data, builderType); } private static FunctionDisableInfo? ExtractDisabledWhenInfo(GeneratorAttributeSyntaxContext ctx, CancellationToken ct) diff --git a/src/AzureFunctions.DisabledWhen.SourceGenerator/Internal/Emitter.cs b/src/AzureFunctions.DisabledWhen.SourceGenerator/Internal/Emitter.cs index 73c1fea..c6bbc31 100644 --- a/src/AzureFunctions.DisabledWhen.SourceGenerator/Internal/Emitter.cs +++ b/src/AzureFunctions.DisabledWhen.SourceGenerator/Internal/Emitter.cs @@ -25,6 +25,7 @@ public static SourceText GenerateRegistry( using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; + using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -145,6 +146,9 @@ file static class UseDisabledWhenInterceptor {{interceptorMethods}} private static IHostBuilder UseDisabledWhenGenerated(this IHostBuilder builder) => builder.ConfigureServices(services => services.AddSingleton()); + + private static void UseDisabledWhenGenerated(this IFunctionsWorkerApplicationBuilder builder) + => builder.Services.AddSingleton(); } } """; @@ -164,12 +168,24 @@ private static string GenerateInterceptorMethods(ImmutableArray foreach (var interceptor in interceptors.Distinct()) { - sb.AppendLine($$""" + if (interceptor.BuilderType == BuilderType.FunctionsWorkerApplicationBuilder) + { + sb.AppendLine($$""" + [global::System.Runtime.CompilerServices.InterceptsLocation({{interceptor.Version}}, "{{interceptor.Data}}")] + public static void UseDisabledWhen_{{index}}(this IFunctionsWorkerApplicationBuilder builder) + => builder.UseDisabledWhenGenerated(); + + """); + } + else + { + sb.AppendLine($$""" [global::System.Runtime.CompilerServices.InterceptsLocation({{interceptor.Version}}, "{{interceptor.Data}}")] public static IHostBuilder UseDisabledWhen_{{index}}(this IHostBuilder builder) => builder.UseDisabledWhenGenerated(); """); + } index++; } diff --git a/src/AzureFunctions.DisabledWhen.SourceGenerator/Internal/InterceptorInfo.cs b/src/AzureFunctions.DisabledWhen.SourceGenerator/Internal/InterceptorInfo.cs index d34e81c..8c94ad7 100644 --- a/src/AzureFunctions.DisabledWhen.SourceGenerator/Internal/InterceptorInfo.cs +++ b/src/AzureFunctions.DisabledWhen.SourceGenerator/Internal/InterceptorInfo.cs @@ -1,14 +1,23 @@ namespace AzureFunctions.DisabledWhen.SourceGenerator.Internal; +internal enum BuilderType +{ + HostBuilder, + FunctionsWorkerApplicationBuilder, +} + internal readonly record struct InterceptorInfo { - public InterceptorInfo(int version, string data) + public InterceptorInfo(int version, string data, BuilderType builderType) { Version = version; Data = data; + BuilderType = builderType; } public int Version { get; } public string Data { get; } + + public BuilderType BuilderType { get; } } diff --git a/src/AzureFunctions.DisabledWhen/IFunctionsWorkerApplicationBuilderExtensions.cs b/src/AzureFunctions.DisabledWhen/IFunctionsWorkerApplicationBuilderExtensions.cs new file mode 100644 index 0000000..af665bf --- /dev/null +++ b/src/AzureFunctions.DisabledWhen/IFunctionsWorkerApplicationBuilderExtensions.cs @@ -0,0 +1,14 @@ +using AzureFunctions.DisabledWhen.Internal; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata; +using Microsoft.Extensions.DependencyInjection; + +namespace AzureFunctions.DisabledWhen; + +public static class IFunctionsWorkerApplicationBuilderExtensions +{ + public static void UseDisabledWhen(this IFunctionsWorkerApplicationBuilder builder) + { + builder.Services.AddSingleton(); + } +} \ No newline at end of file diff --git a/tests/AzureFunctions.DisabledWhen.SourceGenerator.Tests/AppBuilderStartupTests.cs b/tests/AzureFunctions.DisabledWhen.SourceGenerator.Tests/AppBuilderStartupTests.cs new file mode 100644 index 0000000..dd828cb --- /dev/null +++ b/tests/AzureFunctions.DisabledWhen.SourceGenerator.Tests/AppBuilderStartupTests.cs @@ -0,0 +1,92 @@ +namespace AzureFunctions.DisabledWhen.SourceGenerator.Tests; + +public class AppBuilderStartupTests +{ + private readonly FunctionMetadataAppBuilderTestHost testHost = new(b => b.UseDisabledWhen()); + + [After(Test)] + public async Task Cleanup() => await testHost.DisposeAsync(); + + [Test] + public async Task Host_UsesGeneratedMetadataProvider() + { + await testHost.StartAsync(); + + var providerTypeName = testHost.GetMetadataProviderTypeName(); + + await Assert.That(providerTypeName).StartsWith(""); + } + + [Test] + public async Task Host_IncludesAllFunctions_WhenNoConditionsMatch() + { + await testHost.StartAsync(new Dictionary + { + ["ENVIRONMENT"] = "Development", + ["FEATURE_FLAG"] = "enabled", + ["API_KEY"] = "test-key", + ["CONNECTION_STRING"] = "test-connection", + ["DEBUG_MODE"] = "false" + }); + + var functions = await testHost.GetFunctionMetadataAsync(); + var functionNames = functions.Select(f => f.Name!).ToList(); + + await Assert.That(functionNames).Contains("AlwaysEnabled"); + await Assert.That(functionNames).Contains("DisabledInProduction"); + await Assert.That(functionNames).Contains("FeatureFlagged"); + await Assert.That(functionNames).Contains("RequiresApiKey"); + await Assert.That(functionNames).Contains("MultipleConditions"); + } + + [Test] + public async Task Host_FiltersFunction_WhenDisabledWhenConditionMatches() + { + await testHost.StartAsync(new Dictionary + { + ["ENVIRONMENT"] = "Production", + ["API_KEY"] = "test-key", + ["CONNECTION_STRING"] = "test-connection" + }); + + var functions = await testHost.GetFunctionMetadataAsync(); + var functionNames = functions.Select(f => f.Name!).ToList(); + + await Assert.That(functionNames).Contains("AlwaysEnabled"); + await Assert.That(functionNames).DoesNotContain("DisabledInProduction"); + await Assert.That(functionNames).DoesNotContain("MultipleConditions"); + } + + [Test] + public async Task Host_FiltersFunction_WhenDisabledWhenLocalInDevelopment() + { + await testHost.StartAsync(new Dictionary + { + ["AZURE_FUNCTIONS_ENVIRONMENT"] = "Development", + ["API_KEY"] = "test-key", + ["CONNECTION_STRING"] = "test-connection" + }); + + var functions = await testHost.GetFunctionMetadataAsync(); + var functionNames = functions.Select(f => f.Name!).ToList(); + + await Assert.That(functionNames).Contains("AlwaysEnabled"); + await Assert.That(functionNames).DoesNotContain("DisabledLocally"); + await Assert.That(functionNames).DoesNotContain("MixedConditions"); + } + + [Test] + public async Task Host_FiltersFunction_WhenDisabledWhenNullOrEmptyConfigMissing() + { + await testHost.StartAsync(new Dictionary + { + ["CONNECTION_STRING"] = "test-connection" + }); + + var functions = await testHost.GetFunctionMetadataAsync(); + var functionNames = functions.Select(f => f.Name!).ToList(); + + await Assert.That(functionNames).Contains("AlwaysEnabled"); + await Assert.That(functionNames).DoesNotContain("RequiresApiKey"); + } +} diff --git a/tests/AzureFunctions.DisabledWhen.SourceGenerator.Tests/FunctionMetadataAppBuilderTestHost.cs b/tests/AzureFunctions.DisabledWhen.SourceGenerator.Tests/FunctionMetadataAppBuilderTestHost.cs new file mode 100644 index 0000000..8e4481e --- /dev/null +++ b/tests/AzureFunctions.DisabledWhen.SourceGenerator.Tests/FunctionMetadataAppBuilderTestHost.cs @@ -0,0 +1,117 @@ +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Builder; +using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; + +namespace AzureFunctions.DisabledWhen.SourceGenerator.Tests; + +public sealed class FunctionMetadataAppBuilderTestHost : IAsyncDisposable +{ + private IHost? host; + private readonly Action configureTestHost; + + public FunctionMetadataAppBuilderTestHost(Action configureTestHost) + => this.configureTestHost = configureTestHost; + + /// + /// Starts the test host with the specified configuration using . + /// + /// A minimal host.json is created at runtime (not build time) to avoid the Azure Functions SDK + /// treating the test project as a runnable Functions app. + /// + /// The WorkerHostedService is removed to prevent gRPC errors since no real Azure Functions host is running. + /// + /// Optional configuration key-value pairs to use for the host. + public async Task StartAsync(Dictionary? configuration = null) + { + EnsureHostJson(); + + var builder = FunctionsApplication.CreateBuilder([]); + + builder.Configuration.AddInMemoryCollection(configuration ?? []); + + configureTestHost(builder); + + // Removes WorkerHostedService to prevent gRPC errors in tests (no real Azure Functions host) + builder.Services.RemoveAll(); + + host = builder.Build(); + + await host.StartAsync(); + } + + /// + /// Retrieves the function metadata from the running host. + /// + /// The collection of function metadata registered with the host. + /// Thrown when the host has not been started. + public async Task> GetFunctionMetadataAsync() + { + if (host is null) + { + throw new InvalidOperationException("Host has not been started. Call StartAsync first."); + } + + return await host.Services + .GetRequiredService() + .GetFunctionMetadataAsync(AppContext.BaseDirectory); + } + + /// + /// Retrieves the name of the function metadata provider. + /// + /// The name of the function metadata provider. + /// Thrown when the host has not been started. + public string GetMetadataProviderTypeName() + { + if (host is null) + { + throw new InvalidOperationException("Host has not been started. Call StartAsync first."); + } + + return host.Services + .GetRequiredService() + .GetType() + .Name; + } + + public async ValueTask DisposeAsync() + { + if (host is not null) + { + await host.StopAsync(); + host.Dispose(); + host = null; + } + + GC.SuppressFinalize(this); + } + + private static readonly object hostJsonLock = new(); + + /// + /// Creates a minimal host.json in the base directory if it doesn't already exist. + /// This file is required by to locate the project root. + /// It is created at runtime rather than included as a build artifact to prevent the Azure Functions SDK + /// from generating a func start RunCommand for the test project. + /// + private static void EnsureHostJson() + { + var hostJsonPath = Path.Combine(AppContext.BaseDirectory, "host.json"); + if (File.Exists(hostJsonPath)) + { + return; + } + + lock (hostJsonLock) + { + if (!File.Exists(hostJsonPath)) + { + File.WriteAllText(hostJsonPath, """{"version": "2.0"}"""); + } + } + } +} diff --git a/tests/AzureFunctions.DisabledWhen.Tests/AppBuilderStartupTests.cs b/tests/AzureFunctions.DisabledWhen.Tests/AppBuilderStartupTests.cs new file mode 100644 index 0000000..7dc7864 --- /dev/null +++ b/tests/AzureFunctions.DisabledWhen.Tests/AppBuilderStartupTests.cs @@ -0,0 +1,92 @@ +namespace AzureFunctions.DisabledWhen.Tests; + +public class AppBuilderStartupTests +{ + private readonly FunctionMetadataAppBuilderTestHost testHost = new(b => b.UseDisabledWhen()); + + [After(Test)] + public async Task Cleanup() => await testHost.DisposeAsync(); + + [Test] + public async Task Host_UsesReflectionMetadataProvider() + { + await testHost.StartAsync(); + + var providerTypeName = testHost.GetMetadataProviderTypeName(); + + await Assert.That(providerTypeName).IsEqualTo("DisabledWhenMetadataProvider"); + } + + [Test] + public async Task Host_IncludesAllFunctions_WhenNoConditionsMatch() + { + await testHost.StartAsync(new Dictionary + { + ["ENVIRONMENT"] = "Development", + ["FEATURE_FLAG"] = "enabled", + ["API_KEY"] = "test-key", + ["CONNECTION_STRING"] = "test-connection", + ["DEBUG_MODE"] = "false" + }); + + var functions = await testHost.GetFunctionMetadataAsync(); + var functionNames = functions.Select(f => f.Name!).ToList(); + + await Assert.That(functionNames).Contains("AlwaysEnabled"); + await Assert.That(functionNames).Contains("DisabledInProduction"); + await Assert.That(functionNames).Contains("FeatureFlagged"); + await Assert.That(functionNames).Contains("RequiresApiKey"); + await Assert.That(functionNames).Contains("MultipleConditions"); + } + + [Test] + public async Task Host_FiltersFunction_WhenDisabledWhenConditionMatches() + { + await testHost.StartAsync(new Dictionary + { + ["ENVIRONMENT"] = "Production", + ["API_KEY"] = "test-key", + ["CONNECTION_STRING"] = "test-connection" + }); + + var functions = await testHost.GetFunctionMetadataAsync(); + var functionNames = functions.Select(f => f.Name!).ToList(); + + await Assert.That(functionNames).Contains("AlwaysEnabled"); + await Assert.That(functionNames).DoesNotContain("DisabledInProduction"); + await Assert.That(functionNames).DoesNotContain("MultipleConditions"); + } + + [Test] + public async Task Host_FiltersFunction_WhenDisabledWhenLocalInDevelopment() + { + await testHost.StartAsync(new Dictionary + { + ["AZURE_FUNCTIONS_ENVIRONMENT"] = "Development", + ["API_KEY"] = "test-key", + ["CONNECTION_STRING"] = "test-connection" + }); + + var functions = await testHost.GetFunctionMetadataAsync(); + var functionNames = functions.Select(f => f.Name!).ToList(); + + await Assert.That(functionNames).Contains("AlwaysEnabled"); + await Assert.That(functionNames).DoesNotContain("DisabledLocally"); + await Assert.That(functionNames).DoesNotContain("MixedConditions"); + } + + [Test] + public async Task Host_FiltersFunction_WhenDisabledWhenNullOrEmptyConfigMissing() + { + await testHost.StartAsync(new Dictionary + { + ["CONNECTION_STRING"] = "test-connection" + }); + + var functions = await testHost.GetFunctionMetadataAsync(); + var functionNames = functions.Select(f => f.Name!).ToList(); + + await Assert.That(functionNames).Contains("AlwaysEnabled"); + await Assert.That(functionNames).DoesNotContain("RequiresApiKey"); + } +} diff --git a/tests/AzureFunctions.DisabledWhen.Tests/FunctionMetadataAppBuilderTestHost.cs b/tests/AzureFunctions.DisabledWhen.Tests/FunctionMetadataAppBuilderTestHost.cs new file mode 100644 index 0000000..0fb9f60 --- /dev/null +++ b/tests/AzureFunctions.DisabledWhen.Tests/FunctionMetadataAppBuilderTestHost.cs @@ -0,0 +1,117 @@ +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Builder; +using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; + +namespace AzureFunctions.DisabledWhen.Tests; + +public sealed class FunctionMetadataAppBuilderTestHost : IAsyncDisposable +{ + private IHost? host; + private readonly Action configureTestHost; + + public FunctionMetadataAppBuilderTestHost(Action configureTestHost) + => this.configureTestHost = configureTestHost; + + /// + /// Starts the test host with the specified configuration using . + /// + /// A minimal host.json is created at runtime (not build time) to avoid the Azure Functions SDK + /// treating the test project as a runnable Functions app. + /// + /// The WorkerHostedService is removed to prevent gRPC errors since no real Azure Functions host is running. + /// + /// Optional configuration key-value pairs to use for the host. + public async Task StartAsync(Dictionary? configuration = null) + { + EnsureHostJson(); + + var builder = FunctionsApplication.CreateBuilder([]); + + builder.Configuration.AddInMemoryCollection(configuration ?? []); + + configureTestHost(builder); + + // Removes WorkerHostedService to prevent gRPC errors in tests (no real Azure Functions host) + builder.Services.RemoveAll(); + + host = builder.Build(); + + await host.StartAsync(); + } + + /// + /// Retrieves the function metadata from the running host. + /// + /// The collection of function metadata registered with the host. + /// Thrown when the host has not been started. + public async Task> GetFunctionMetadataAsync() + { + if (host is null) + { + throw new InvalidOperationException("Host has not been started. Call StartAsync first."); + } + + return await host.Services + .GetRequiredService() + .GetFunctionMetadataAsync(AppContext.BaseDirectory); + } + + /// + /// Retrieves the name of the function metadata provider. + /// + /// The name of the function metadata provider. + /// Thrown when the host has not been started. + public string GetMetadataProviderTypeName() + { + if (host is null) + { + throw new InvalidOperationException("Host has not been started. Call StartAsync first."); + } + + return host.Services + .GetRequiredService() + .GetType() + .Name; + } + + public async ValueTask DisposeAsync() + { + if (host is not null) + { + await host.StopAsync(); + host.Dispose(); + host = null; + } + + GC.SuppressFinalize(this); + } + + private static readonly object hostJsonLock = new(); + + /// + /// Creates a minimal host.json in the base directory if it doesn't already exist. + /// This file is required by to locate the project root. + /// It is created at runtime rather than included as a build artifact to prevent the Azure Functions SDK + /// from generating a func start RunCommand for the test project. + /// + private static void EnsureHostJson() + { + var hostJsonPath = Path.Combine(AppContext.BaseDirectory, "host.json"); + if (File.Exists(hostJsonPath)) + { + return; + } + + lock (hostJsonLock) + { + if (!File.Exists(hostJsonPath)) + { + File.WriteAllText(hostJsonPath, """{"version": "2.0"}"""); + } + } + } +} From 303e662f1264062187366d981a4176dcfbb16255 Mon Sep 17 00:00:00 2001 From: Anders-Toegersen Date: Mon, 23 Feb 2026 14:50:44 +0100 Subject: [PATCH 5/8] fix(Registration): added descriptive exception for wrong registration of DisabledWhenMetadataProvider --- .../Internal/Emitter.cs | 13 +++++++-- .../Internal/DisabledWhenMetadataProvider.cs | 3 +- .../HostStartupTests.cs | 28 +++++++++++++++++++ .../HostStartupTests.cs | 19 +++++++++++++ 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/AzureFunctions.DisabledWhen.SourceGenerator/Internal/Emitter.cs b/src/AzureFunctions.DisabledWhen.SourceGenerator/Internal/Emitter.cs index c6bbc31..f5afbb8 100644 --- a/src/AzureFunctions.DisabledWhen.SourceGenerator/Internal/Emitter.cs +++ b/src/AzureFunctions.DisabledWhen.SourceGenerator/Internal/Emitter.cs @@ -100,7 +100,8 @@ public async Task> GetFunctionMetadataAsync(st { var functionMetadataProvider = this.serviceProvider .GetServices() - .Last(x => x.GetType() != typeof(GeneratedDisabledWhenMetadataProvider)); + .LastOrDefault(x => x.GetType() != typeof(GeneratedDisabledWhenMetadataProvider)) + ?? throw new global::System.InvalidOperationException("No other IFunctionMetadataProvider is registered. Ensure the default Azure Functions metadata provider is available before calling UseDisabledWhen()."); var metaData = await functionMetadataProvider .GetFunctionMetadataAsync(directory) @@ -145,10 +146,16 @@ file static class UseDisabledWhenInterceptor { {{interceptorMethods}} private static IHostBuilder UseDisabledWhenGenerated(this IHostBuilder builder) - => builder.ConfigureServices(services => services.AddSingleton()); + { + global::System.ArgumentNullException.ThrowIfNull(builder); + return builder.ConfigureServices(services => services.AddSingleton()); + } private static void UseDisabledWhenGenerated(this IFunctionsWorkerApplicationBuilder builder) - => builder.Services.AddSingleton(); + { + global::System.ArgumentNullException.ThrowIfNull(builder); + builder.Services.AddSingleton(); + } } } """; diff --git a/src/AzureFunctions.DisabledWhen/Internal/DisabledWhenMetadataProvider.cs b/src/AzureFunctions.DisabledWhen/Internal/DisabledWhenMetadataProvider.cs index 0b13d92..771e81b 100644 --- a/src/AzureFunctions.DisabledWhen/Internal/DisabledWhenMetadataProvider.cs +++ b/src/AzureFunctions.DisabledWhen/Internal/DisabledWhenMetadataProvider.cs @@ -27,7 +27,8 @@ public async Task> GetFunctionMetadataAsync(st { var functionMetadataProvider = serviceProvider .GetServices() - .Last(x => x.GetType() != typeof(DisabledWhenMetadataProvider)); + .LastOrDefault(x => x.GetType() != typeof(DisabledWhenMetadataProvider)) + ?? throw new InvalidOperationException("No other IFunctionMetadataProvider is registered. Ensure the default Azure Functions metadata provider is available before calling UseDisabledWhen()."); var metaData = await functionMetadataProvider .GetFunctionMetadataAsync(directory) diff --git a/tests/AzureFunctions.DisabledWhen.SourceGenerator.Tests/HostStartupTests.cs b/tests/AzureFunctions.DisabledWhen.SourceGenerator.Tests/HostStartupTests.cs index cd88974..19908bf 100644 --- a/tests/AzureFunctions.DisabledWhen.SourceGenerator.Tests/HostStartupTests.cs +++ b/tests/AzureFunctions.DisabledWhen.SourceGenerator.Tests/HostStartupTests.cs @@ -1,3 +1,6 @@ +using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata; +using Microsoft.Extensions.DependencyInjection; + namespace AzureFunctions.DisabledWhen.SourceGenerator.Tests; public class HostStartupTests @@ -278,4 +281,29 @@ await testHost.StartAsync(new Dictionary await Assert.That(functionNames).Contains("NestedConfigDisabled"); await Assert.That(functionNames).DoesNotContain("NestedConfigRequired"); } + + [Test] + public async Task Host_Throws_WhenNoOtherMetadataProviderRegistered() + { + await using var host = new FunctionMetadataTestHost(b => + { + b.UseDisabledWhen(); + b.ConfigureServices((_, services) => + { + var defaultProviders = services + .Where(d => d.ServiceType == typeof(IFunctionMetadataProvider) && + d.ImplementationType?.Name.Contains("DisabledWhen") != true) + .ToList(); + + foreach (var d in defaultProviders) + { + services.Remove(d); + } + }); + }); + + await host.StartAsync(); + + await Assert.That(async () => await host.GetFunctionMetadataAsync()).ThrowsExactly(); + } } diff --git a/tests/AzureFunctions.DisabledWhen.Tests/HostStartupTests.cs b/tests/AzureFunctions.DisabledWhen.Tests/HostStartupTests.cs index f932892..7a1c487 100644 --- a/tests/AzureFunctions.DisabledWhen.Tests/HostStartupTests.cs +++ b/tests/AzureFunctions.DisabledWhen.Tests/HostStartupTests.cs @@ -1,3 +1,8 @@ +using AzureFunctions.DisabledWhen.Internal; +using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + namespace AzureFunctions.DisabledWhen.Tests; public class HostStartupTests @@ -278,4 +283,18 @@ await testHost.StartAsync(new Dictionary await Assert.That(functionNames).Contains("NestedConfigDisabled"); await Assert.That(functionNames).DoesNotContain("NestedConfigRequired"); } + + [Test] + public async Task Host_Throws_WhenNoOtherMetadataProviderRegistered() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().Build()); + services.AddSingleton(); + var serviceProvider = services.BuildServiceProvider(); + + var provider = serviceProvider.GetRequiredService(); + + await Assert.That(() => provider.GetFunctionMetadataAsync(AppContext.BaseDirectory)) + .ThrowsExactly(); + } } From 19507fdc6a8bb65bfa467391fa725ffd09d39a56 Mon Sep 17 00:00:00 2001 From: Anders-Toegersen Date: Mon, 23 Feb 2026 14:51:14 +0100 Subject: [PATCH 6/8] chore: added XML docs to public facing attributes and extension methods. --- .../AzureFunctions.DisabledWhen.csproj | 4 ++++ .../DisabledWhenAttribute.cs | 9 +++++++++ .../DisabledWhenLocalAttribute.cs | 3 +++ .../DisabledWhenNullOrEmptyAttribute.cs | 5 +++++ .../IFunctionsWorkerApplicationBuilderExtensions.cs | 8 ++++++++ .../IHostBuilderExtensions.cs | 13 ++++++++++++- 6 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/AzureFunctions.DisabledWhen/AzureFunctions.DisabledWhen.csproj b/src/AzureFunctions.DisabledWhen/AzureFunctions.DisabledWhen.csproj index d9d4a8a..4fd8b7e 100644 --- a/src/AzureFunctions.DisabledWhen/AzureFunctions.DisabledWhen.csproj +++ b/src/AzureFunctions.DisabledWhen/AzureFunctions.DisabledWhen.csproj @@ -1,5 +1,9 @@ + + true + + diff --git a/src/AzureFunctions.DisabledWhen/DisabledWhenAttribute.cs b/src/AzureFunctions.DisabledWhen/DisabledWhenAttribute.cs index d6791b2..4f0b776 100644 --- a/src/AzureFunctions.DisabledWhen/DisabledWhenAttribute.cs +++ b/src/AzureFunctions.DisabledWhen/DisabledWhenAttribute.cs @@ -1,8 +1,14 @@ namespace AzureFunctions.DisabledWhen; +/// +/// Disables a function when a configuration key matches a value. +/// [AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)] public sealed class DisabledWhenAttribute : Attribute { + /// The configuration key to evaluate. + /// The value that disables the function when matched. + /// The comparison type. Defaults to . public DisabledWhenAttribute(string configKey, string? configValue, StringComparison configValueComparer = StringComparison.Ordinal) { ConfigKey = configKey; @@ -10,9 +16,12 @@ public DisabledWhenAttribute(string configKey, string? configValue, StringCompar ConfigValueComparer = configValueComparer; } + /// The configuration key to evaluate. public string ConfigKey { get; } + /// The value that disables the function when matched. public string? ConfigValue { get; } + /// The comparison type used for matching. public StringComparison ConfigValueComparer { get; } } diff --git a/src/AzureFunctions.DisabledWhen/DisabledWhenLocalAttribute.cs b/src/AzureFunctions.DisabledWhen/DisabledWhenLocalAttribute.cs index bbfbcd8..ccca843 100644 --- a/src/AzureFunctions.DisabledWhen/DisabledWhenLocalAttribute.cs +++ b/src/AzureFunctions.DisabledWhen/DisabledWhenLocalAttribute.cs @@ -1,5 +1,8 @@ namespace AzureFunctions.DisabledWhen; +/// +/// Disables a function when AZURE_FUNCTIONS_ENVIRONMENT is Development. +/// [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] public sealed class DisabledWhenLocalAttribute : Attribute { diff --git a/src/AzureFunctions.DisabledWhen/DisabledWhenNullOrEmptyAttribute.cs b/src/AzureFunctions.DisabledWhen/DisabledWhenNullOrEmptyAttribute.cs index 3676bd9..21e43bd 100644 --- a/src/AzureFunctions.DisabledWhen/DisabledWhenNullOrEmptyAttribute.cs +++ b/src/AzureFunctions.DisabledWhen/DisabledWhenNullOrEmptyAttribute.cs @@ -1,12 +1,17 @@ namespace AzureFunctions.DisabledWhen; +/// +/// Disables a function when a configuration key is missing or empty. +/// [AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)] public sealed class DisabledWhenNullOrEmptyAttribute : Attribute { + /// The configuration key to evaluate. public DisabledWhenNullOrEmptyAttribute(string configKey) { ConfigKey = configKey; } + /// The configuration key to evaluate. public string ConfigKey { get; } } diff --git a/src/AzureFunctions.DisabledWhen/IFunctionsWorkerApplicationBuilderExtensions.cs b/src/AzureFunctions.DisabledWhen/IFunctionsWorkerApplicationBuilderExtensions.cs index af665bf..0244af2 100644 --- a/src/AzureFunctions.DisabledWhen/IFunctionsWorkerApplicationBuilderExtensions.cs +++ b/src/AzureFunctions.DisabledWhen/IFunctionsWorkerApplicationBuilderExtensions.cs @@ -5,10 +5,18 @@ namespace AzureFunctions.DisabledWhen; +/// +/// DisabledWhen extensions for . +/// public static class IFunctionsWorkerApplicationBuilderExtensions { + /// + /// Enables DisabledWhen function filtering based on configuration values. + /// + /// The functions worker application builder. public static void UseDisabledWhen(this IFunctionsWorkerApplicationBuilder builder) { + ArgumentNullException.ThrowIfNull(builder); builder.Services.AddSingleton(); } } \ No newline at end of file diff --git a/src/AzureFunctions.DisabledWhen/IHostBuilderExtensions.cs b/src/AzureFunctions.DisabledWhen/IHostBuilderExtensions.cs index 8bb37b0..ef5a427 100644 --- a/src/AzureFunctions.DisabledWhen/IHostBuilderExtensions.cs +++ b/src/AzureFunctions.DisabledWhen/IHostBuilderExtensions.cs @@ -5,8 +5,19 @@ namespace AzureFunctions.DisabledWhen; +/// +/// DisabledWhen extensions for . +/// public static class IHostBuilderExtensions { + /// + /// Enables DisabledWhen function filtering based on configuration values. + /// + /// The host builder. + /// The for chaining. public static IHostBuilder UseDisabledWhen(this IHostBuilder builder) - => builder.ConfigureServices(services => services.AddSingleton()); + { + ArgumentNullException.ThrowIfNull(builder); + return builder.ConfigureServices(services => services.AddSingleton()); + } } From f80d5369449a71ee4de0b9d02739f0e55b939db4 Mon Sep 17 00:00:00 2001 From: Anders-Toegersen Date: Mon, 23 Feb 2026 15:31:33 +0100 Subject: [PATCH 7/8] publish: prerelease 1.0.1 --- Directory.Build.props | 2 +- sample/SampleFunctionApp.SourceGenerated/Program.cs | 10 +++++----- .../SampleFunctionApp.SourceGenerated.csproj | 4 ++-- sample/SampleFunctionApp/SampleFunctionApp.csproj | 2 +- .../Directory.Build.props | 5 +++++ .../Internal/Emitter.cs | 10 +++++----- 6 files changed, 19 insertions(+), 14 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 808abc1..d138994 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -21,7 +21,7 @@ - 1.0.0 + 1.0.1 prerelease diff --git a/sample/SampleFunctionApp.SourceGenerated/Program.cs b/sample/SampleFunctionApp.SourceGenerated/Program.cs index bc854c9..efb3655 100644 --- a/sample/SampleFunctionApp.SourceGenerated/Program.cs +++ b/sample/SampleFunctionApp.SourceGenerated/Program.cs @@ -1,11 +1,11 @@ using AzureFunctions.DisabledWhen; +using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -var host = new HostBuilder() - .ConfigureFunctionsWebApplication() - .UseDisabledWhen() - .ConfigureLogging(logging => logging.AddConsole()) - .Build(); +var builder = FunctionsApplication.CreateBuilder(args); +builder.UseDisabledWhen(); +builder.ConfigureLogging(logging => logging.AddConsole()); +var host = builder.Build(); host.Run(); diff --git a/sample/SampleFunctionApp.SourceGenerated/SampleFunctionApp.SourceGenerated.csproj b/sample/SampleFunctionApp.SourceGenerated/SampleFunctionApp.SourceGenerated.csproj index a8baf29..3012f4e 100644 --- a/sample/SampleFunctionApp.SourceGenerated/SampleFunctionApp.SourceGenerated.csproj +++ b/sample/SampleFunctionApp.SourceGenerated/SampleFunctionApp.SourceGenerated.csproj @@ -9,8 +9,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/sample/SampleFunctionApp/SampleFunctionApp.csproj b/sample/SampleFunctionApp/SampleFunctionApp.csproj index da2f3f7..3bd9a0a 100644 --- a/sample/SampleFunctionApp/SampleFunctionApp.csproj +++ b/sample/SampleFunctionApp/SampleFunctionApp.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/AzureFunctions.DisabledWhen.SourceGenerator/Directory.Build.props b/src/AzureFunctions.DisabledWhen.SourceGenerator/Directory.Build.props index f830a4e..3603ccf 100644 --- a/src/AzureFunctions.DisabledWhen.SourceGenerator/Directory.Build.props +++ b/src/AzureFunctions.DisabledWhen.SourceGenerator/Directory.Build.props @@ -13,8 +13,13 @@ AzureFunctions.DisabledWhen.SourceGenerator Conditionally disable Azure Functions based on configuration values using attributes and source generation. Azure;Functions;Disable + README.md + + + + false diff --git a/src/AzureFunctions.DisabledWhen.SourceGenerator/Internal/Emitter.cs b/src/AzureFunctions.DisabledWhen.SourceGenerator/Internal/Emitter.cs index f5afbb8..3ebf32f 100644 --- a/src/AzureFunctions.DisabledWhen.SourceGenerator/Internal/Emitter.cs +++ b/src/AzureFunctions.DisabledWhen.SourceGenerator/Internal/Emitter.cs @@ -31,7 +31,7 @@ public static SourceText GenerateRegistry( namespace System.Runtime.CompilerServices { - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("AzureFunctions.DisabledWhen.SourceGenerator", "1.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("AzureFunctions.DisabledWhen.SourceGenerator", "1.0.1")] [global::System.AttributeUsage(global::System.AttributeTargets.Method, AllowMultiple = true)] file sealed class InterceptsLocationAttribute : global::System.Attribute { @@ -41,7 +41,7 @@ public InterceptsLocationAttribute(int version, string data) { } namespace AzureFunctions.DisabledWhen { - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("AzureFunctions.DisabledWhen.SourceGenerator", "1.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("AzureFunctions.DisabledWhen.SourceGenerator", "1.0.1")] file readonly struct DisabledCondition { public DisabledCondition(string configKey, string? configValue, global::System.StringComparison comparer, bool matchNullOrEmpty = false) @@ -58,7 +58,7 @@ public DisabledCondition(string configKey, string? configValue, global::System.S public bool MatchNullOrEmpty { get; } } - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("AzureFunctions.DisabledWhen.SourceGenerator", "1.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("AzureFunctions.DisabledWhen.SourceGenerator", "1.0.1")] file static class DisabledWhenRegistry { private static readonly global::System.Collections.Generic.Dictionary> Conditions = @@ -79,7 +79,7 @@ public static bool TryGetConditions(string entryPoint, out global::System.Collec } } - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("AzureFunctions.DisabledWhen.SourceGenerator", "1.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("AzureFunctions.DisabledWhen.SourceGenerator", "1.0.1")] file sealed class GeneratedDisabledWhenMetadataProvider : IFunctionMetadataProvider { private readonly IServiceProvider serviceProvider; @@ -141,7 +141,7 @@ public async Task> GetFunctionMetadataAsync(st } } - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("AzureFunctions.DisabledWhen.SourceGenerator", "1.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("AzureFunctions.DisabledWhen.SourceGenerator", "1.0.1")] file static class UseDisabledWhenInterceptor { {{interceptorMethods}} From 590614ffb35a46a431b982487b78da11b535bf72 Mon Sep 17 00:00:00 2001 From: Anders-Toegersen Date: Mon, 23 Feb 2026 15:51:12 +0100 Subject: [PATCH 8/8] chore: updated build and release workflows --- .github/workflows/build.yml | 5 +---- .github/workflows/release.yml | 7 ++----- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6e5bfbf..3684647 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,11 +21,8 @@ jobs: 9.0.x 10.0.x - - name: Restore - run: dotnet restore - - name: Build - run: dotnet build --configuration Release --no-restore + run: for proj in src/*/*.csproj tests/*/*.csproj; do dotnet build "$proj" -c Release; done - name: Test run: dotnet test --test-modules "**/bin/Release/**/*.Tests.dll" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 15411e7..94d0ca6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,17 +66,14 @@ jobs: 9.0.x 10.0.x - - name: Restore - run: dotnet restore - - name: Build - run: dotnet build --configuration Release --no-restore + run: for proj in src/*/*.csproj tests/*/*.csproj; do dotnet build "$proj" -c Release; done - name: Test run: dotnet test --test-modules "**/bin/Release/**/*.Tests.dll" - name: Pack - run: dotnet pack --configuration Release --no-build -o ./nupkg + run: for proj in src/*/*.csproj; do dotnet pack "$proj" -c Release --no-build -o ./nupkg; done - name: Push to NuGet run: dotnet nuget push ./nupkg/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate