Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d481dcf
feat: Add environment variable constants for OFREP configuration
askpt Dec 10, 2025
fef833a
feat: Enhance OfrepProviderOptionsValidator to support configuration …
askpt Dec 10, 2025
7c90a41
feat: Implement configuration fallback for BaseUrl in OfrepProvider
askpt Dec 10, 2025
6ab9258
feat: Add environment variable configuration support to OfrepProvider…
askpt Dec 10, 2025
e7f2551
feat: Add unit tests for OfrepOptions environment variable handling
askpt Dec 10, 2025
d2dfe05
feat: Add environment variable configuration support to OFREP provider
askpt Dec 10, 2025
4e3424c
feat: Refactor timeout handling in OfrepOptions and OfrepProviderOpti…
askpt Dec 10, 2025
1bc344b
feat: Refactor endpoint retrieval in OfrepOptions to improve configur…
askpt Dec 10, 2025
8ce5a66
fix: Update ParseHeaders test to handle nullable input correctly
askpt Dec 10, 2025
4ca03a9
refactor: Change GetConfigValue method to internal and update usage i…
askpt Dec 10, 2025
7f4a25a
fix: Ensure default values for endpoint and headers are set to empty …
askpt Dec 10, 2025
b8c7de8
fix: Add missing PackageReference for Microsoft.Extensions.Configurat…
askpt Dec 11, 2025
984c6a7
Merge remote-tracking branch 'origin/main' into askpt/issue520
askpt Dec 14, 2025
4b7ebc0
fix: Update OFREP_TIMEOUT environment variable name to include millis…
askpt Dec 14, 2025
c4c63de
refactor: Update OFREP headers parsing to support URL encoding and im…
askpt Dec 14, 2025
8f7bcee
fix: Update OFREP_TIMEOUT environment variable name to OFREP_TIMEOUT_…
askpt Dec 16, 2025
62ed17d
test: Add collection definition for environment variable tests to ens…
askpt Dec 16, 2025
413d950
Apply suggestions from code review
askpt Dec 16, 2025
39d6d6d
docs: Clarify OFREP_HEADERS environment variable parsing rules and UR…
askpt Dec 17, 2025
961fbae
refactor: Simplify timeout parsing logic and improve logging for inva…
askpt Dec 17, 2025
d837cbc
fix: Handle null timeout string in GetTimeout method to improve nulla…
askpt Dec 17, 2025
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
218 changes: 216 additions & 2 deletions src/OpenFeature.Providers.Ofrep/Configuration/OfrepOptions.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;

namespace OpenFeature.Providers.Ofrep.Configuration;

/// <summary>
/// Configuration options for the OFREP provider.
/// </summary>
public class OfrepOptions
public partial class OfrepOptions
{
/// <summary>
/// Environment variable name for the OFREP endpoint URL.
/// </summary>
public const string EnvVarEndpoint = "OFREP_ENDPOINT";

/// <summary>
/// Environment variable name for the OFREP headers.
/// Format: \"Key1=Value1,Key2=Value2\". Supports URL-encoded values. Commas are always header separators.
/// </summary>
public const string EnvVarHeaders = "OFREP_HEADERS";

/// <summary>
/// Environment variable name for the OFREP timeout in milliseconds.
/// </summary>
public const string EnvVarTimeout = "OFREP_TIMEOUT_MS";

/// <summary>
/// Gets or sets the base URL for the OFREP API.
/// </summary>
Expand All @@ -13,7 +33,8 @@ public class OfrepOptions
/// <summary>
/// Gets or sets the timeout for HTTP requests. Default is 10 seconds.
/// </summary>
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(10);
public TimeSpan Timeout { get; set; } = DefaultTimeout;
internal static TimeSpan DefaultTimeout => TimeSpan.FromSeconds(10);

/// <summary>
/// Gets or sets additional HTTP headers to include in requests.
Expand All @@ -40,4 +61,197 @@ public OfrepOptions(string baseUrl)

this.BaseUrl = baseUrl;
}

/// <summary>
/// Creates an <see cref="OfrepOptions"/> instance from environment variables.
/// </summary>
/// <param name="logger">Optional logger for warnings about malformed values. Defaults to NullLogger.</param>
/// <returns>A new <see cref="OfrepOptions"/> instance configured from environment variables.</returns>
/// <exception cref="ArgumentException">
/// Thrown when the OFREP_ENDPOINT environment variable is not set, empty, or not a valid absolute URI.
/// </exception>
/// <remarks>
/// Reads the following environment variables:
/// <list type="bullet">
/// <item><description>OFREP_ENDPOINT (required): The OFREP server endpoint URL.</description></item>
/// <item><description>OFREP_HEADERS (optional): HTTP headers in format "Key1=Value1,Key2=Value2". Values may be URL-encoded to include special characters.</description></item>
/// <item><description>OFREP_TIMEOUT_MS (optional): Request timeout in milliseconds. Defaults to 10000 (10 seconds).</description></item>
/// </list>
/// </remarks>
public static OfrepOptions FromEnvironment(ILogger? logger = null)
{
logger ??= NullLogger.Instance;

var endpoint = GetEndpointValue();

var options = new OfrepOptions(endpoint);

// Parse timeout
var timeoutStr = Environment.GetEnvironmentVariable(EnvVarTimeout);
options.Timeout = GetTimeout(timeoutStr, logger);

// Parse headers
var headersStr = Environment.GetEnvironmentVariable(EnvVarHeaders);
if (!string.IsNullOrWhiteSpace(headersStr))
{
options.Headers = ParseHeaders(headersStr, logger);
}

return options;
}

/// <summary>
/// Creates an <see cref="OfrepOptions"/> instance from IConfiguration with fallback to environment variables.
/// </summary>
/// <param name="configuration">The configuration to read from. If null, falls back to environment variables only.</param>
/// <param name="logger">Optional logger for warnings about malformed values. Defaults to NullLogger.</param>
/// <returns>A new <see cref="OfrepOptions"/> instance configured from IConfiguration or environment variables.</returns>
/// <exception cref="ArgumentException">
/// Thrown when neither IConfiguration nor environment variables provide a valid OFREP_ENDPOINT value.
/// </exception>
/// <remarks>
/// Reads the following configuration keys (with environment variable fallback):
/// <list type="bullet">
/// <item><description>OFREP_ENDPOINT (required): The OFREP server endpoint URL.</description></item>
/// <item><description>OFREP_HEADERS (optional): HTTP headers in format "Key1=Value1,Key2=Value2". Values may be URL-encoded to include special characters.</description></item>
/// <item><description>OFREP_TIMEOUT_MS (optional): Request timeout in milliseconds. Defaults to 10000 (10 seconds).</description></item>
/// </list>
/// When using IConfiguration, ensure AddEnvironmentVariables() is called in your configuration setup to enable environment variable support.
/// </remarks>
public static OfrepOptions FromConfiguration(IConfiguration? configuration, ILogger? logger = null)
{
logger ??= NullLogger.Instance;

var endpoint = GetEndpointValue(configuration);

var options = new OfrepOptions(endpoint);

// Parse timeout
var timeoutStr = GetConfigValue(configuration, EnvVarTimeout);
options.Timeout = GetTimeout(timeoutStr, logger);

// Parse headers
var headersStr = GetConfigValue(configuration, EnvVarHeaders);
if (!string.IsNullOrWhiteSpace(headersStr))
{
options.Headers = ParseHeaders(headersStr, logger);
}

return options;
}

/// <summary>
/// Gets a configuration value by key, falling back to environment variable if IConfiguration is not available or doesn't contain the key.
/// </summary>
internal static string? GetConfigValue(IConfiguration? configuration, string key)
{
// Try IConfiguration first
var value = configuration?[key];
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}

// Fall back to direct environment variable access
return Environment.GetEnvironmentVariable(key);
}

/// <summary>
/// Gets a required configuration value, throwing if not present. Returns a non-null string.
/// This is a shim for .NET Framework where nullable flow analysis doesn't recognize IsNullOrWhiteSpace guards.
/// </summary>
private static string GetEndpointValue(IConfiguration? configuration = null)
{
var value = configuration != null ? GetConfigValue(configuration, EnvVarEndpoint) : Environment.GetEnvironmentVariable(EnvVarEndpoint);
value ??= string.Empty;
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException($"Configuration key '{EnvVarEndpoint}' or environment variable {EnvVarEndpoint} is required but was not set or is empty.", EnvVarEndpoint);
}
return value;
}

/// <summary>
/// Parses a timeout string in milliseconds, returning a TimeSpan. Logs warnings for invalid values.
/// </summary>
/// <param name="timeoutStr">The timeout string to parse.</param>
/// <param name="logger">The logger to use for warnings.</param>
/// <returns>The parsed TimeSpan, or the default timeout if parsing fails.</returns>
private static TimeSpan GetTimeout(string? timeoutStr, ILogger logger)
{
// Handle null by treating as empty (this fixes nullable flow analysis issues in .NET Framework)
var timeoutStringNotNull = timeoutStr ?? string.Empty;
if (!string.IsNullOrWhiteSpace(timeoutStringNotNull))
{
if (int.TryParse(timeoutStringNotNull, out var timeoutMs) && timeoutMs > 0)
{
return TimeSpan.FromMilliseconds(timeoutMs);
}
else
{
LogInvalidTimeout(logger, timeoutStringNotNull);
}
}

return DefaultTimeout;
}

/// <summary>
/// Parses a header string in the format "Key1=Value1,Key2=Value2" with URL-encoding support.
/// Values may be URL-encoded to include special characters (e.g., use %3D for equals).
/// Note: Commas are always treated as separators and cannot be escaped or encoded into header values.
/// </summary>
/// <param name="headersString">The headers string to parse. Can be null or empty.</param>
/// <param name="logger">Optional logger for warnings about malformed entries.</param>
/// <returns>A dictionary of parsed headers.</returns>
internal static Dictionary<string, string> ParseHeaders(string? headersString, ILogger? logger = null)
{
logger ??= NullLogger.Instance;
var headers = new Dictionary<string, string>();

if (string.IsNullOrWhiteSpace(headersString))
{
return headers;
}

// URL-decode the entire string to support encoded special characters
var decoded = Uri.UnescapeDataString(headersString);

foreach (var pair in decoded.Split(','))
{
if (string.IsNullOrWhiteSpace(pair))
{
continue;
}

var equalsIndex = pair.IndexOf('=');
if (equalsIndex <= 0)
{
LogMalformedHeaderEntry(logger, pair, EnvVarHeaders);
continue;
}

var key = pair.Substring(0, equalsIndex).Trim();
var value = pair.Substring(equalsIndex + 1).Trim();

if (string.IsNullOrEmpty(key))
{
LogEmptyHeaderKey(logger, pair, EnvVarHeaders);
continue;
}

headers[key] = value;
}

return headers;
}

[LoggerMessage(Level = LogLevel.Warning, Message = "Invalid value '{TimeoutValue}'. Using default timeout of 10 seconds.")]
private static partial void LogInvalidTimeout(ILogger logger, string timeoutValue);

[LoggerMessage(Level = LogLevel.Warning, Message = "Malformed header entry '{Entry}' in {EnvVar}. Expected format: Key=Value. Skipping.")]
private static partial void LogMalformedHeaderEntry(ILogger logger, string entry, string envVar);

[LoggerMessage(Level = LogLevel.Warning, Message = "Empty header key in entry '{Entry}' in {EnvVar}. Skipping.")]
private static partial void LogEmptyHeaderKey(ILogger logger, string entry, string envVar);
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using OpenFeature.Hosting;
Expand Down Expand Up @@ -41,12 +42,39 @@ private static OfrepProvider CreateProvider(IServiceProvider sp, string? domain)
var monitor = sp.GetRequiredService<IOptionsMonitor<OfrepProviderOptions>>();
var opts = string.IsNullOrWhiteSpace(domain) ? monitor.Get(OfrepProviderOptions.DefaultName) : monitor.Get(domain);

// Options validation is handled by OfrepProviderOptionsValidator during service registration
var ofrepOptions = new OfrepOptions(opts.BaseUrl)
var loggerFactory = sp.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger<OfrepClient>();

// Options validation is performed by OfrepProviderOptionsValidator when options are retrieved (e.g., via IOptionsMonitor.Get),
// not during service registration. Note: validation may fail if IConfiguration is not registered in the DI container,
// even if environment variables are set.
// If BaseUrl is not set, fall back to IConfiguration/environment variables
OfrepOptions ofrepOptions;
if (string.IsNullOrWhiteSpace(opts.BaseUrl))
{
Timeout = opts.Timeout,
Headers = opts.Headers
};
// Use IConfiguration with environment variable fallback
var configuration = sp.GetService<IConfiguration>();
ofrepOptions = OfrepOptions.FromConfiguration(configuration, logger);

// Apply any DI-configured overrides (timeout, headers) if they differ from defaults
if (opts.Timeout != OfrepOptions.DefaultTimeout)
{
ofrepOptions.Timeout = opts.Timeout;
}

foreach (var header in opts.Headers)
{
ofrepOptions.Headers[header.Key] = header.Value;
}
}
else
{
ofrepOptions = new OfrepOptions(opts.BaseUrl)
{
Timeout = opts.Timeout,
Headers = opts.Headers
};
}

// Resolve or create HttpClient if caller wants to manage it
HttpClient? httpClient = null;
Expand Down Expand Up @@ -83,8 +111,6 @@ private static OfrepProvider CreateProvider(IServiceProvider sp, string? domain)
}

// Build OfrepClient using provided HttpClient and wire into OfrepProvider
var loggerFactory = sp.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger<OfrepClient>();
var timeProvider = sp.GetService<TimeProvider>();
var ofrepClient = new OfrepClient(httpClient, logger, timeProvider);
return new OfrepProvider(ofrepClient);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
using System.Net.Http;
#endif

using OpenFeature.Providers.Ofrep.Configuration;

namespace OpenFeature.Providers.Ofrep.DependencyInjection;

/// <summary>
Expand All @@ -22,7 +24,7 @@ public record OfrepProviderOptions
/// <summary>
/// HTTP request timeout. Defaults to 10 seconds.
/// </summary>
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(10);
public TimeSpan Timeout { get; set; } = OfrepOptions.DefaultTimeout;

/// <summary>
/// Optional additional HTTP headers.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using OpenFeature.Providers.Ofrep.Configuration;

namespace OpenFeature.Providers.Ofrep.DependencyInjection;

Expand All @@ -7,11 +9,44 @@ namespace OpenFeature.Providers.Ofrep.DependencyInjection;
/// </summary>
internal class OfrepProviderOptionsValidator : IValidateOptions<OfrepProviderOptions>
{
private readonly IConfiguration? _configuration;

/// <summary>
/// Creates a new instance of <see cref="OfrepProviderOptionsValidator"/>.
/// </summary>
/// <param name="configuration">Optional configuration for fallback values.</param>
public OfrepProviderOptionsValidator(IConfiguration? configuration = null)
{
this._configuration = configuration;
}

public ValidateOptionsResult Validate(string? name, OfrepProviderOptions options)
{
// If BaseUrl is not set, check if configuration/environment variable is available as fallback
if (string.IsNullOrWhiteSpace(options.BaseUrl))
{
return ValidateOptionsResult.Fail("Ofrep BaseUrl is required. Set it on OfrepProviderOptions.BaseUrl.");
var configEndpoint = OfrepOptions.GetConfigValue(this._configuration, OfrepOptions.EnvVarEndpoint);
if (string.IsNullOrWhiteSpace(configEndpoint))
{
return ValidateOptionsResult.Fail(
$"Ofrep BaseUrl is required. Set it on OfrepProviderOptions.BaseUrl, via IConfiguration key '{OfrepOptions.EnvVarEndpoint}', or via the {OfrepOptions.EnvVarEndpoint} environment variable.");
}

// Validate the configuration value
if (!Uri.TryCreate(configEndpoint, UriKind.Absolute, out var configUri))
{
return ValidateOptionsResult.Fail(
$"Configuration key '{OfrepOptions.EnvVarEndpoint}' must be a valid absolute URI.");
}

if (configUri.Scheme != Uri.UriSchemeHttp && configUri.Scheme != Uri.UriSchemeHttps)
{
return ValidateOptionsResult.Fail(
$"Configuration key '{OfrepOptions.EnvVarEndpoint}' must use HTTP or HTTPS scheme.");
}

// Configuration value is valid, allow fallback
return ValidateOptionsResult.Success;
}

// Validate that it's a valid absolute URI
Expand Down
Loading
Loading