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"
+ }
+ ]
+ }
+ }
+ ]
+}
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
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/README.md b/README.md
index f0884f0..be85fb1 100644
--- a/README.md
+++ b/README.md
@@ -1,17 +1,12 @@
# AzureFunctions.DisabledWhen
-Conditionally disable Azure Functions based on configuration values using attributes.
-
-## Packages
+ 
-| 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
@@ -42,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/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 dbd8414..3012f4e 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..3bd9a0a 100644
--- a/sample/SampleFunctionApp/SampleFunctionApp.csproj
+++ b/sample/SampleFunctionApp/SampleFunctionApp.csproj
@@ -7,10 +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/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..3ebf32f 100644
--- a/src/AzureFunctions.DisabledWhen.SourceGenerator/Internal/Emitter.cs
+++ b/src/AzureFunctions.DisabledWhen.SourceGenerator/Internal/Emitter.cs
@@ -25,12 +25,13 @@ 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;
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
{
@@ -40,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)
@@ -57,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 =
@@ -78,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;
@@ -99,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)
@@ -139,12 +141,21 @@ 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}}
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)
+ {
+ global::System.ArgumentNullException.ThrowIfNull(builder);
+ builder.Services.AddSingleton();
+ }
}
}
""";
@@ -164,12 +175,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/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
new file mode 100644
index 0000000..0244af2
--- /dev/null
+++ b/src/AzureFunctions.DisabledWhen/IFunctionsWorkerApplicationBuilderExtensions.cs
@@ -0,0 +1,22 @@
+using AzureFunctions.DisabledWhen.Internal;
+using Microsoft.Azure.Functions.Worker;
+using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
+using Microsoft.Extensions.DependencyInjection;
+
+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());
+ }
}
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/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.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/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"}""");
+ }
+ }
+ }
+}
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();
+ }
}