Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
215 changes: 213 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,194 @@ 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);
if (!string.IsNullOrWhiteSpace(timeoutStr))
{
if (int.TryParse(timeoutStr, out var timeoutMs) && timeoutMs > 0)
{
options.Timeout = TimeSpan.FromMilliseconds(timeoutMs);
}
else
{
LogInvalidTimeoutEnvVar(logger, timeoutStr, EnvVarTimeout);
}
}

// 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);
if (!string.IsNullOrWhiteSpace(timeoutStr))
{
if (int.TryParse(timeoutStr, out var timeoutMs) && timeoutMs > 0)
{
options.Timeout = TimeSpan.FromMilliseconds(timeoutMs);
}
else
{
LogInvalidTimeoutConfig(logger, timeoutStr!, EnvVarTimeout);
}
}

// 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 header string in the format "Key1=Value1,Key2=Value2" with URL-encoding support.
/// Values may be URL-encoded to include special characters (use %2C for comma, %3D for equals).
/// </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}' for environment variable {EnvVar}. Using default timeout of 10 seconds.")]
private static partial void LogInvalidTimeoutEnvVar(ILogger logger, string timeoutValue, string envVar);

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

[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,37 @@ 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);

var loggerFactory = sp.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger<OfrepClient>();

// Options validation is handled by OfrepProviderOptionsValidator during service registration
var ofrepOptions = new OfrepOptions(opts.BaseUrl)
// 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 +109,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 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