Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Environment Variable Provider #312

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@
"src/OpenFeature.Contrib.Providers.ConfigCat": "0.1.1",
"src/OpenFeature.Contrib.Providers.FeatureManagement": "0.1.0",
"src/OpenFeature.Contrib.Providers.Statsig": "0.1.0",
"src/OpenFeature.Contrib.Providers.Flipt": "0.0.5"
"src/OpenFeature.Contrib.Providers.Flipt": "0.0.5",
"src/OpenFeature.Contrib.Providers.EnvVar": "0.0.1"
}
14 changes: 14 additions & 0 deletions DotnetSdkContrib.sln
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenFeature.Contrib.Provide
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenFeature.Contrib.Providers.Flipt.Test", "test\OpenFeature.Contrib.Providers.Flipt.Test\OpenFeature.Contrib.Providers.Flipt.Test.csproj", "{B446D481-B5A3-4509-8933-C4CF6DA9B147}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenFeature.Contrib.Providers.EnvVar", "src\OpenFeature.Contrib.Providers.EnvVar\OpenFeature.Contrib.Providers.EnvVar.csproj", "{F7C6368F-29DC-4F70-AA0E-B3C340F9E1AB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenFeature.Contrib.Providers.EnvVar.Test", "test\OpenFeature.Contrib.Providers.EnvVar.Test\OpenFeature.Contrib.Providers.EnvVar.Test.csproj", "{282AD5C5-099A-403D-B415-29AA88A701EC}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -127,6 +131,14 @@ Global
{B446D481-B5A3-4509-8933-C4CF6DA9B147}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B446D481-B5A3-4509-8933-C4CF6DA9B147}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B446D481-B5A3-4509-8933-C4CF6DA9B147}.Release|Any CPU.Build.0 = Release|Any CPU
{F7C6368F-29DC-4F70-AA0E-B3C340F9E1AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F7C6368F-29DC-4F70-AA0E-B3C340F9E1AB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F7C6368F-29DC-4F70-AA0E-B3C340F9E1AB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F7C6368F-29DC-4F70-AA0E-B3C340F9E1AB}.Release|Any CPU.Build.0 = Release|Any CPU
{282AD5C5-099A-403D-B415-29AA88A701EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{282AD5C5-099A-403D-B415-29AA88A701EC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{282AD5C5-099A-403D-B415-29AA88A701EC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{282AD5C5-099A-403D-B415-29AA88A701EC}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -151,5 +163,7 @@ Global
{F3080350-B0AB-4D59-B416-50CC38C99087} = {B6D3230B-5E4D-4FF1-868E-2F4E325C84FE}
{5ECF7DBF-FE64-40A2-BF39-239DE173DA4B} = {0E563821-BD08-4B7F-BF9D-395CAD80F026}
{B446D481-B5A3-4509-8933-C4CF6DA9B147} = {B6D3230B-5E4D-4FF1-868E-2F4E325C84FE}
{F7C6368F-29DC-4F70-AA0E-B3C340F9E1AB} = {0E563821-BD08-4B7F-BF9D-395CAD80F026}
{282AD5C5-099A-403D-B415-29AA88A701EC} = {B6D3230B-5E4D-4FF1-868E-2F4E325C84FE}
EndGlobalSection
EndGlobal
102 changes: 102 additions & 0 deletions src/OpenFeature.Contrib.Providers.EnvVar/EnvVarProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using OpenFeature.Constant;
using OpenFeature.Error;
using OpenFeature.Model;

namespace OpenFeature.Contrib.Providers.EnvVar
{
/// <summary>
/// An OpenFeature provider using environment variables.
/// </summary>
public class EnvVarProvider : FeatureProvider
{
private const string Name = "Environment Variable Provider";
private readonly string _prefix;
private delegate bool TryConvert<TResult>(string value, out TResult result);

/// <summary>
/// Creates a new instance of <see cref="EnvVarProvider"/>
/// </summary>
public EnvVarProvider() : this("")
{
}

/// <summary>
/// Creates a new instance of <see cref="EnvVarProvider"/>
/// </summary>
/// <param name="prefix">A prefix which will be used when evaluating environment variables</param>
public EnvVarProvider(string prefix)
{
_prefix = prefix;
}

/// <inheritdoc/>
public override Metadata GetMetadata()
{
return new Metadata(Name);
}

private Task<ResolutionDetails<T>> Resolve<T>(string flagKey, T defaultValue, TryConvert<T> tryConvert)
{
var envVarName = $"{_prefix}{flagKey}";
var value = Environment.GetEnvironmentVariable(envVarName);

if (value == null)
return Task.FromResult(new ResolutionDetails<T>(flagKey, defaultValue, ErrorType.None, Reason.Default));

if (!tryConvert(value, out var convertedValue))
throw new FeatureProviderException(ErrorType.TypeMismatch, $"Could not convert the value of environment variable '{envVarName}' to {typeof(T)}");

return Task.FromResult(new ResolutionDetails<T>(flagKey, convertedValue, ErrorType.None, Reason.Static));
}

/// <inheritdoc/>
public override Task<ResolutionDetails<bool>> ResolveBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext context = null,
CancellationToken cancellationToken = new CancellationToken())
{
return Resolve(flagKey, defaultValue, bool.TryParse);
}

/// <inheritdoc/>
public override Task<ResolutionDetails<string>> ResolveStringValueAsync(string flagKey, string defaultValue, EvaluationContext context = null,
CancellationToken cancellationToken = new CancellationToken())
{
return Resolve(flagKey, defaultValue, NoopTryParse);

bool NoopTryParse(string value, out string result)
{
result = value;
return true;
}
}

/// <inheritdoc/>
public override Task<ResolutionDetails<int>> ResolveIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext context = null,
CancellationToken cancellationToken = new CancellationToken())
{
return Resolve(flagKey, defaultValue, int.TryParse);
}

/// <inheritdoc/>
public override Task<ResolutionDetails<double>> ResolveDoubleValueAsync(string flagKey, double defaultValue, EvaluationContext context = null,
CancellationToken cancellationToken = new CancellationToken())
{
return Resolve(flagKey, defaultValue, double.TryParse);
}

/// <inheritdoc/>
public override Task<ResolutionDetails<Value>> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext context = null,
CancellationToken cancellationToken = new CancellationToken())
{
return Resolve(flagKey, defaultValue, ConvertStringToValue);

bool ConvertStringToValue(string s, out Value value)
{
value = new Value(s);
return true;
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<PackageId>OpenFeature.Contrib.Providers.EnvVar</PackageId>
<VersionNumber>0.0.1</VersionNumber> <!--x-release-please-version -->
<VersionPrefix>$(VersionNumber)</VersionPrefix>
<AssemblyVersion>$(VersionNumber)</AssemblyVersion>
<FileVersion>$(VersionNumber)</FileVersion>
<Description>Environment Variable Provider for .NET</Description>
<Authors>Octopus Deploy</Authors>
</PropertyGroup>

</Project>
42 changes: 42 additions & 0 deletions src/OpenFeature.Contrib.Providers.EnvVar/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# .NET Environment Variable Provider

This provider supports using the OpenFeature SDK to evaluate feature flags backed by environment variables.

## Installation

### .NET CLI

```shell
dotnet add package OpenFeature.Contrib.Providers.EnvVar
```

## Using the ConfigCat Provider with the OpenFeature SDK

The following example shows how to use the Environment Variable provider with the OpenFeature SDK.

```csharp
using System;
using ConfigCat.Client;
using OpenFeature.Contrib.EnvVar;


// If you want to use a prefix for your environment variables, you can supply it in the constructor below.
// For example, if you all your feature flag environment variables will be prefixed with feature-flag- then
// you would use:
// var envVarProvider = new EnvVarProvider("feature-flag-");
var envVarProvider = new EnvVarProvider();

// Set the Environment Variable provider as the provider for the OpenFeature SDK
await OpenFeature.Api.Instance.SetProviderAsync(envVarProvider);
var client = OpenFeature.Api.Instance.GetClient();

var isAwesomeFeatureEnabled = await client.GetBooleanValueAsync("isAwesomeFeatureEnabled", false);
if (isAwesomeFeatureEnabled)
{
doTheNewThing();
}
else
{
doTheOldThing();
}
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
using System;
using System.Threading.Tasks;
using AutoFixture.Xunit2;
using OpenFeature.Constant;
using OpenFeature.Error;
using OpenFeature.Model;
using Xunit;

namespace OpenFeature.Contrib.Providers.EnvVar.Test
{
public class EnvVarProviderTests
{
[Theory]
[AutoData]
public async Task ResolveBooleanValueAsync_WhenEnvironmentVariablePresent_ShouldReturnValue(string prefix,
string flagKey)
{
var value = true;
Environment.SetEnvironmentVariable(prefix + flagKey, value.ToString());

await ExecuteResolveValueTest(prefix, flagKey, false, value, Reason.Static,
(provider, key, defaultValue) => provider.ResolveBooleanValueAsync(key, defaultValue));
}

[Theory]
[AutoData]
public async Task ResolveBooleanValueAsync_WhenEnvironmentVariableMissing_ShouldReturnDefault(string prefix,
string flagKey, bool defaultValue)
{
// No matching environment value set
await ExecuteResolveValueTest(prefix, flagKey, defaultValue, defaultValue, Reason.Default,
(provider, key, @default) => provider.ResolveBooleanValueAsync(key, @default));
}

[Theory]
[AutoData]
public async Task ResolveBooleanValueAsync_WhenEnvironmentVariableContainsInvalidValue_ShouldError(
string prefix, string flagKey, bool defaultValue)
{
var value = "xxxx"; // This value cannot be converted to a bool
Environment.SetEnvironmentVariable(prefix + flagKey, value);

await ExecuteResolveErrorTest(prefix, flagKey, defaultValue, ErrorType.TypeMismatch,
(provider, key, @default) => provider.ResolveBooleanValueAsync(key, @default));

}

[Theory]
[AutoData]
public async Task ResolveStringValueAsync_WhenEnvironmentVariablePresent_ShouldReturnValue(string prefix,
string flagKey, string value, string defaultValue)
{
Environment.SetEnvironmentVariable(prefix + flagKey, value);

await ExecuteResolveValueTest(prefix, flagKey, defaultValue, value, Reason.Static,
(provider, key, @default) => provider.ResolveStringValueAsync(key, defaultValue));
}

[Theory]
[AutoData]
public async Task ResolveStringValueAsync_WhenEnvironmentVariableMissing_ShouldReturnDefault(string prefix,
string flagKey, string defaultValue)
{
// No matching environment value set
await ExecuteResolveValueTest(prefix, flagKey, defaultValue, defaultValue, Reason.Default,
(provider, key, @default) => provider.ResolveStringValueAsync(key, @default));
}

[Theory]
[AutoData]
public async Task ResolveIntegerValueAsync_WhenEnvironmentVariablePresent_ShouldReturnValue(string prefix,
string flagKey, int value, int defaultValue)
{
Environment.SetEnvironmentVariable(prefix + flagKey, value.ToString());

await ExecuteResolveValueTest(prefix, flagKey, defaultValue, value, Reason.Static,
(provider, key, @default) => provider.ResolveIntegerValueAsync(key, @defaultValue));
}

[Theory]
[AutoData]
public async Task ResolveIntegerValueAsync_WhenEnvironmentVariableMissing_ShouldReturnDefault(string prefix,
string flagKey, int defaultValue)
{
// No matching environment value set
await ExecuteResolveValueTest(prefix, flagKey, defaultValue, defaultValue, Reason.Default,
(provider, key, @default) => provider.ResolveIntegerValueAsync(key, @default));
}

[Theory]
[AutoData]
public async Task ResolveIntegerValueAsync_WhenEnvironmentVariableContainsInvalidValue_ShouldError(
string prefix, string flagKey, int defaultValue)
{
var value = "xxxx"; // This value cannot be converted to an int
Environment.SetEnvironmentVariable(prefix + flagKey, value);

await ExecuteResolveErrorTest(prefix, flagKey, defaultValue, ErrorType.TypeMismatch,
(provider, key, @default) => provider.ResolveIntegerValueAsync(key, @default));

}

[Theory]
[AutoData]
public async Task ResolveDoubleValueAsync_WhenEnvironmentVariablePresent_ShouldReturnValue(string prefix,
string flagKey, double value, double defaultValue)
{
Environment.SetEnvironmentVariable(prefix + flagKey, value.ToString());

await ExecuteResolveValueTest(prefix, flagKey, defaultValue, value, Reason.Static,
(provider, key, @default) => provider.ResolveDoubleValueAsync(key, @defaultValue));
}

[Theory]
[AutoData]
public async Task ResolveDoubleValueAsync_WhenEnvironmentVariableMissing_ShouldReturnDefault(string prefix,
string flagKey, double defaultValue)
{
// No matching environment value set
await ExecuteResolveValueTest(prefix, flagKey, defaultValue, defaultValue, Reason.Default,
(provider, key, @default) => provider.ResolveDoubleValueAsync(key, @default));
}

[Theory]
[AutoData]
public async Task ResolveDoubleValueAsync_WhenEnvironmentVariableContainsInvalidValue_ShouldError(string prefix,
string flagKey, double defaultValue)
{
var value = "xxxx"; // This value cannot be converted to a double
Environment.SetEnvironmentVariable(prefix + flagKey, value);

await ExecuteResolveErrorTest(prefix, flagKey, defaultValue, ErrorType.TypeMismatch,
(provider, key, @default) => provider.ResolveDoubleValueAsync(key, @default));

}

[Theory]
[AutoData]
public async Task ResolveStructureValueAsync_WhenEnvironmentVariablePresent_ShouldReturnValue(string prefix,
string flagKey, string value, string defaultValue)
{
Environment.SetEnvironmentVariable(prefix + flagKey, value);

var provider = new EnvVarProvider(prefix);
var resolutionDetails = await provider.ResolveStructureValueAsync(flagKey, new Value(defaultValue));

Assert.Equal(value, resolutionDetails.Value.AsString);
Assert.Equal(Reason.Static, resolutionDetails.Reason);
Assert.Equal(ErrorType.None, resolutionDetails.ErrorType);
}

[Theory]
[AutoData]
public async Task ResolveValueFromClient_WhenProviderConfigured_ShouldReturnValue(string prefix, string flagKey)
{
Environment.SetEnvironmentVariable(prefix + flagKey, true.ToString());

var provider = new EnvVarProvider(prefix);
await OpenFeature.Api.Instance.SetProviderAsync(provider);
var client = OpenFeature.Api.Instance.GetClient();

var receivedValue = await client.GetBooleanValueAsync(flagKey, false);

Assert.True(receivedValue);
}

private async Task ExecuteResolveValueTest<T>(string prefix, string flagKey, T defaultValue, T expectedValue,
string expectedReason, Func<EnvVarProvider, string, T, Task<ResolutionDetails<T>>> resolve)
{
var provider = new EnvVarProvider(prefix);
var resolutionDetails = await resolve(provider, flagKey, defaultValue);

Assert.Equal(expectedValue, resolutionDetails.Value);
Assert.Equal(expectedReason, resolutionDetails.Reason);
Assert.Equal(ErrorType.None, resolutionDetails.ErrorType);
}

private async Task ExecuteResolveErrorTest<T>(string prefix, string flagKey, T defaultValue,
ErrorType expectedErrorType, Func<EnvVarProvider, string, T, Task<ResolutionDetails<T>>> resolve)
{
var provider = new EnvVarProvider(prefix);
var exception =
await Assert.ThrowsAsync<FeatureProviderException>(() => resolve(provider, flagKey, defaultValue));

Assert.Equal(expectedErrorType, exception.ErrorType);
}
}
}
Loading
Loading