diff --git a/crates/flagd/flagd-testbed b/crates/flagd/flagd-testbed index 0bada4f..bde8977 160000 --- a/crates/flagd/flagd-testbed +++ b/crates/flagd/flagd-testbed @@ -1 +1 @@ -Subproject commit 0bada4f3139ae9d5ad347b3642f893d65e6c0a04 +Subproject commit bde8977a4fa2b59ba4359bcf902e9adf4555d085 diff --git a/crates/flagd/schemas b/crates/flagd/schemas index e840a03..58d7327 160000 --- a/crates/flagd/schemas +++ b/crates/flagd/schemas @@ -1 +1 @@ -Subproject commit e840a037dc49801185217f45cc404fc8ff2cd0d4 +Subproject commit 58d732724359b272001ee6a5b8b7a96c549397e4 diff --git a/crates/flagd/src/lib.rs b/crates/flagd/src/lib.rs index 3af9324..02abcea 100644 --- a/crates/flagd/src/lib.rs +++ b/crates/flagd/src/lib.rs @@ -261,6 +261,9 @@ pub struct FlagdOptions { pub stream_deadline_ms: u32, /// Offline polling interval in milliseconds pub offline_poll_interval_ms: Option, + /// Provider ID for identifying this provider instance to flagd + /// Used in in-process resolver for sync requests + pub provider_id: Option, } /// Type of resolver to use for flag evaluation #[derive(Debug, Clone, PartialEq)] @@ -336,9 +339,12 @@ impl Default for FlagdOptions { .and_then(|s| s.parse().ok()) .unwrap_or(5000), ), + provider_id: std::env::var("FLAGD_PROVIDER_ID").ok(), }; - if options.source_configuration.is_some() && options.resolver_type != ResolverType::Rpc { + let resolver_env_set = std::env::var("FLAGD_RESOLVER").is_ok(); + if options.source_configuration.is_some() && !resolver_env_set { + // Only override to File if FLAGD_RESOLVER wasn't explicitly set options.resolver_type = ResolverType::File; } @@ -360,6 +366,13 @@ impl FlagdProvider { pub async fn new(options: FlagdOptions) -> Result { debug!("Initializing FlagdProvider with options: {:?}", options); + // Validate File resolver configuration + if options.resolver_type == ResolverType::File && options.source_configuration.is_none() { + return Err(FlagdError::Config( + "File resolver requires 'source_configuration' (FLAGD_OFFLINE_FLAG_SOURCE_PATH) to be set".to_string() + )); + } + let provider: Arc = match options.resolver_type { ResolverType::Rpc => { debug!("Using RPC resolver"); @@ -377,7 +390,9 @@ impl FlagdProvider { debug!("Using file resolver"); Arc::new( FileResolver::new( - options.source_configuration.unwrap(), + options + .source_configuration + .expect("source_configuration validated above"), options.cache_settings.clone(), ) .await?, diff --git a/crates/flagd/src/resolver/in_process/storage/connector/grpc.rs b/crates/flagd/src/resolver/in_process/storage/connector/grpc.rs index 7feda95..7451db0 100644 --- a/crates/flagd/src/resolver/in_process/storage/connector/grpc.rs +++ b/crates/flagd/src/resolver/in_process/storage/connector/grpc.rs @@ -28,7 +28,8 @@ pub struct GrpcStreamConnector { retry_backoff_max_ms: u32, retry_grace_period: u32, stream_deadline_ms: u32, - authority: String, // desired authority, e.g. "b-features-api.service" + authority: String, // desired authority, e.g. "b-features-api.service" + provider_id: String, // provider identifier for sync requests } impl GrpcStreamConnector { @@ -52,6 +53,10 @@ impl GrpcStreamConnector { retry_grace_period: options.retry_grace_period, stream_deadline_ms: options.stream_deadline_ms, authority, + provider_id: options + .provider_id + .clone() + .unwrap_or_else(|| "rust-flagd-provider".to_string()), } } @@ -119,7 +124,7 @@ impl GrpcStreamConnector { // Create the gRPC client with no interceptor because the endpoint already carries the desired authority. let mut client = FlagSyncServiceClient::new(channel); let request = tonic::Request::new(SyncFlagsRequest { - provider_id: "rust-flagd-provider".to_string(), + provider_id: self.provider_id.clone(), selector: self.selector.clone().unwrap_or_default(), }); debug!("Sending sync request with selector: {:?}", self.selector); @@ -227,24 +232,17 @@ mod tests { drop(listener); // Create options configured for a failing connection. - let options = FlagdOptions { - host: addr.ip().to_string(), - resolver_type: crate::ResolverType::InProcess, - port: addr.port(), - target_uri: None, - deadline_ms: 100, // Short timeout for fast failures - retry_backoff_ms: 100, - retry_backoff_max_ms: 400, - retry_grace_period: 3, - stream_deadline_ms: 500, - tls: false, - cert_path: None, - selector: None, - socket_path: None, - cache_settings: None, - source_configuration: None, - offline_poll_interval_ms: None, - }; + let mut options = FlagdOptions::default(); + options.host = addr.ip().to_string(); + options.resolver_type = crate::ResolverType::InProcess; + options.port = addr.port(); + options.deadline_ms = 100; // Short timeout for fast failures + options.retry_backoff_ms = 100; + options.retry_backoff_max_ms = 400; + options.retry_grace_period = 3; + options.stream_deadline_ms = 500; + options.tls = false; + options.cache_settings = None; let target = format!("{}:{}", addr.ip(), addr.port()); let connector = diff --git a/crates/flagd/src/resolver/rpc.rs b/crates/flagd/src/resolver/rpc.rs index 556c7b4..caf2718 100644 --- a/crates/flagd/src/resolver/rpc.rs +++ b/crates/flagd/src/resolver/rpc.rs @@ -85,6 +85,27 @@ fn convert_proto_metadata(metadata: prost_types::Struct) -> FlagMetadata { FlagMetadata { values } } +/// Maps gRPC status codes to OpenFeature error codes +/// +/// This ensures consistent error handling across different resolver types +/// and proper conformance with the OpenFeature specification. +fn map_grpc_status_to_error_code(status: &tonic::Status) -> EvaluationErrorCode { + use tonic::Code; + match status.code() { + Code::NotFound => EvaluationErrorCode::FlagNotFound, + Code::InvalidArgument => EvaluationErrorCode::InvalidContext, + Code::Unauthenticated | Code::PermissionDenied => { + EvaluationErrorCode::General("authentication/authorization error".to_string()) + } + Code::FailedPrecondition => EvaluationErrorCode::TypeMismatch, + Code::DeadlineExceeded | Code::Cancelled => { + EvaluationErrorCode::General("request timeout or cancelled".to_string()) + } + Code::Unavailable => EvaluationErrorCode::General("service unavailable".to_string()), + _ => EvaluationErrorCode::General(format!("{:?}", status.code())), + } +} + pub struct RpcResolver { client: ClientType, metadata: OnceLock, @@ -214,7 +235,7 @@ impl FeatureProvider for RpcResolver { Err(status) => { error!(flag_key, error = %status, "failed to resolve boolean flag"); Err(EvaluationError { - code: EvaluationErrorCode::General(status.code().to_string()), + code: map_grpc_status_to_error_code(&status), message: Some(status.message().to_string()), }) } @@ -247,7 +268,7 @@ impl FeatureProvider for RpcResolver { Err(status) => { error!(flag_key, error = %status, "failed to resolve string flag"); Err(EvaluationError { - code: EvaluationErrorCode::General(status.code().to_string()), + code: map_grpc_status_to_error_code(&status), message: Some(status.message().to_string()), }) } @@ -280,7 +301,7 @@ impl FeatureProvider for RpcResolver { Err(status) => { error!(flag_key, error = %status, "failed to resolve float flag"); Err(EvaluationError { - code: EvaluationErrorCode::General(status.code().to_string()), + code: map_grpc_status_to_error_code(&status), message: Some(status.message().to_string()), }) } @@ -313,7 +334,7 @@ impl FeatureProvider for RpcResolver { Err(status) => { error!(flag_key, error = %status, "failed to resolve integer flag"); Err(EvaluationError { - code: EvaluationErrorCode::General(status.code().to_string()), + code: map_grpc_status_to_error_code(&status), message: Some(status.message().to_string()), }) } @@ -346,7 +367,7 @@ impl FeatureProvider for RpcResolver { Err(status) => { error!(flag_key, error = %status, "failed to resolve struct flag"); Err(EvaluationError { - code: EvaluationErrorCode::General(status.code().to_string()), + code: map_grpc_status_to_error_code(&status), message: Some(status.message().to_string()), }) } @@ -765,4 +786,44 @@ mod tests { // Clean shutdown server_handle.abort(); } + + #[test] + fn test_grpc_error_code_mapping() { + use tonic::Code; + + // Test NOT_FOUND -> FlagNotFound + let status = tonic::Status::new(Code::NotFound, "Flag not found"); + let error_code = map_grpc_status_to_error_code(&status); + assert!(matches!(error_code, EvaluationErrorCode::FlagNotFound)); + + // Test INVALID_ARGUMENT -> InvalidContext + let status = tonic::Status::new(Code::InvalidArgument, "Invalid context"); + let error_code = map_grpc_status_to_error_code(&status); + assert!(matches!(error_code, EvaluationErrorCode::InvalidContext)); + + // Test UNAUTHENTICATED -> General + let status = tonic::Status::new(Code::Unauthenticated, "Not authenticated"); + let error_code = map_grpc_status_to_error_code(&status); + assert!(matches!(error_code, EvaluationErrorCode::General(_))); + + // Test PERMISSION_DENIED -> General + let status = tonic::Status::new(Code::PermissionDenied, "Access denied"); + let error_code = map_grpc_status_to_error_code(&status); + assert!(matches!(error_code, EvaluationErrorCode::General(_))); + + // Test FAILED_PRECONDITION -> TypeMismatch + let status = tonic::Status::new(Code::FailedPrecondition, "Type mismatch"); + let error_code = map_grpc_status_to_error_code(&status); + assert!(matches!(error_code, EvaluationErrorCode::TypeMismatch)); + + // Test DEADLINE_EXCEEDED -> General + let status = tonic::Status::new(Code::DeadlineExceeded, "Timeout"); + let error_code = map_grpc_status_to_error_code(&status); + assert!(matches!(error_code, EvaluationErrorCode::General(_))); + + // Test UNAVAILABLE -> General + let status = tonic::Status::new(Code::Unavailable, "Service unavailable"); + let error_code = map_grpc_status_to_error_code(&status); + assert!(matches!(error_code, EvaluationErrorCode::General(_))); + } } diff --git a/crates/flagd/tests/gherkin_config_test.rs b/crates/flagd/tests/gherkin_config_test.rs index 1737a90..0404d12 100644 --- a/crates/flagd/tests/gherkin_config_test.rs +++ b/crates/flagd/tests/gherkin_config_test.rs @@ -24,17 +24,24 @@ impl ConfigWorld { fn clear(&mut self) { // SAFETY: Removing environment variables is safe here because: - // 1. We're only removing variables that were set during this specific test scenario - // 2. The test is protected by #[serial_test::serial] - // 3. This prevents test pollution between scenarios - // 4. All variables being removed are tracked in world.env_vars + // 1. The test is protected by #[serial_test::serial] + // 2. This prevents test pollution between scenarios + // 3. We clear env vars that were set in previous scenarios + + // Clear env vars that were set in the previous scenario for key in self.env_vars.keys() { unsafe { std::env::remove_var(key); } } - self.env_vars.clear(); + // Also explicitly clear FLAGD_OFFLINE_FLAG_SOURCE_PATH because it can affect resolver type + // via FlagdOptions::default() logic, even if not tracked in world.env_vars + unsafe { + std::env::remove_var("FLAGD_OFFLINE_FLAG_SOURCE_PATH"); + } + + self.env_vars.clear(); self.options = FlagdOptions::default(); self.provider = None; self.option_values.clear(); @@ -106,10 +113,14 @@ async fn env_with_value(world: &mut ConfigWorld, env: String, value: String) { #[when(expr = "a config was initialized")] async fn initialize_config(world: &mut ConfigWorld) { + // Start with defaults (which reads from environment variables) let mut options = FlagdOptions::default(); + let mut resolver_explicitly_set = false; - // Handle resolver type first - if let Some(resolver) = world.option_values.get("resolver") { + // Apply env vars from world.env_vars to ensure they take precedence + // This handles cases where env vars were set in test steps but timing issues + // prevent FlagdOptions::default() from reading them correctly + if let Some(resolver) = world.env_vars.get("FLAGD_RESOLVER") { options.resolver_type = match resolver.to_uppercase().as_str() { "RPC" => ResolverType::Rpc, "REST" => ResolverType::Rest, @@ -117,7 +128,17 @@ async fn initialize_config(world: &mut ConfigWorld) { "FILE" | "OFFLINE" => ResolverType::File, _ => ResolverType::Rpc, }; - } else if let Ok(resolver) = std::env::var("FLAGD_RESOLVER") { + resolver_explicitly_set = true; + // Update port based on resolver type when set via env var + options.port = match options.resolver_type { + ResolverType::Rpc => 8013, + ResolverType::InProcess => 8015, + _ => options.port, + }; + } + + // Handle explicit options - these override env vars + if let Some(resolver) = world.option_values.get("resolver") { options.resolver_type = match resolver.to_uppercase().as_str() { "RPC" => ResolverType::Rpc, "REST" => ResolverType::Rest, @@ -125,24 +146,27 @@ async fn initialize_config(world: &mut ConfigWorld) { "FILE" | "OFFLINE" => ResolverType::File, _ => ResolverType::Rpc, }; + resolver_explicitly_set = true; + // Update port based on resolver type when explicitly set + options.port = match options.resolver_type { + ResolverType::Rpc => 8013, + ResolverType::InProcess => 8015, + _ => options.port, + }; } - // Set default port based on resolver type - options.port = match options.resolver_type { - ResolverType::Rpc => 8013, - ResolverType::InProcess => 8015, - _ => options.port, - }; - - // Handle source configuration after resolver type + // Handle source configuration - may override resolver type for backwards compatibility + // BUT only if resolver wasn't explicitly set to "rpc" if let Some(source) = world.option_values.get("offlineFlagSourcePath") { options.source_configuration = Some(source.clone()); - if options.resolver_type != ResolverType::Rpc { + // For backwards compatibility: if offline path is set, switch to File resolver + // UNLESS resolver was explicitly set to "rpc" (in which case keep it as "rpc") + if !resolver_explicitly_set || options.resolver_type != ResolverType::Rpc { options.resolver_type = ResolverType::File; } } - // Handle remaining explicit options + // Handle remaining explicit options (these override env vars) if let Some(host) = world.option_values.get("host") { options.host = host.clone(); } @@ -217,6 +241,9 @@ async fn initialize_config(world: &mut ConfigWorld) { if let Some(selector) = world.option_values.get("selector") { options.selector = Some(selector.clone()); } + if let Some(provider_id) = world.option_values.get("providerId") { + options.provider_id = Some(provider_id.clone()); + } if let Some(max_size) = world .option_values .get("maxCacheSize") @@ -269,12 +296,30 @@ async fn check_option_value( "retryBackoffMaxMs" => Some(world.options.retry_backoff_max_ms.to_string()), "retryGracePeriod" => Some(world.options.retry_grace_period.to_string()), "selector" => world.options.selector.clone(), + "providerId" => world.options.provider_id.clone(), "socketPath" => world.options.socket_path.clone(), "streamDeadlineMs" => Some(world.options.stream_deadline_ms.to_string()), _ => None, }; let expected = convert_type(&option_type, &value); - assert_eq!(actual, expected, "Option '{}' value mismatch", option); + + // For resolver type, do case-insensitive comparison since enum normalizes to lowercase + let actual_normalized = if option == "resolver" { + actual.as_ref().map(|v| v.to_lowercase()) + } else { + actual.clone() + }; + let expected_normalized = if option == "resolver" { + expected.as_ref().map(|v| v.to_lowercase()) + } else { + expected.clone() + }; + + assert_eq!( + actual_normalized, expected_normalized, + "Option '{}' value mismatch", + option + ); } #[test(tokio::test)] diff --git a/crates/flagd/tests/gherkin_evaluation_test.rs b/crates/flagd/tests/gherkin_evaluation_test.rs new file mode 100644 index 0000000..6691338 --- /dev/null +++ b/crates/flagd/tests/gherkin_evaluation_test.rs @@ -0,0 +1,718 @@ +use cucumber::{World, given, then, when}; +use open_feature::provider::FeatureProvider; +use open_feature::{EvaluationContext, EvaluationReason}; +use open_feature_flagd::{CacheSettings, CacheType, FlagdOptions, FlagdProvider, ResolverType}; +use serde_json; +use test_log::test; +use testcontainers::runners::AsyncRunner; + +mod common; +use common::{FLAGD_OFREP_PORT, FLAGD_PORT, FLAGD_SYNC_PORT, Flagd}; + +// Merged flag configuration for evaluation testing +const EVALUATION_FLAGS: &str = r#"{ + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "boolean-flag": { + "state": "ENABLED", + "variants": { + "on": true, + "off": false + }, + "defaultVariant": "on" + }, + "string-flag": { + "state": "ENABLED", + "variants": { + "greeting": "hi", + "parting": "bye" + }, + "defaultVariant": "greeting" + }, + "integer-flag": { + "state": "ENABLED", + "variants": { + "one": 1, + "ten": 10 + }, + "defaultVariant": "ten" + }, + "float-flag": { + "state": "ENABLED", + "variants": { + "tenth": 0.1, + "half": 0.5 + }, + "defaultVariant": "half" + }, + "object-flag": { + "state": "ENABLED", + "variants": { + "empty": {}, + "template": { + "showImages": true, + "title": "Check out these pics!", + "imagesPerPage": 100 + } + }, + "defaultVariant": "template" + }, + "boolean-zero-flag": { + "state": "ENABLED", + "variants": { + "zero": false, + "non-zero": true + }, + "defaultVariant": "zero" + }, + "string-zero-flag": { + "state": "ENABLED", + "variants": { + "zero": "", + "non-zero": "str" + }, + "defaultVariant": "zero" + }, + "integer-zero-flag": { + "state": "ENABLED", + "variants": { + "zero": 0, + "non-zero": 1 + }, + "defaultVariant": "zero" + }, + "float-zero-flag": { + "state": "ENABLED", + "variants": { + "zero": 0.0, + "non-zero": 1.0 + }, + "defaultVariant": "zero" + }, + "boolean-targeted-zero-flag": { + "state": "ENABLED", + "variants": { + "zero": false, + "non-zero": true + }, + "targeting": { + "if": [ + { + "$ref": "is_ballmer" + }, + "zero" + ] + }, + "defaultVariant": "zero" + }, + "string-targeted-zero-flag": { + "state": "ENABLED", + "variants": { + "zero": "", + "non-zero": "str" + }, + "targeting": { + "if": [ + { + "$ref": "is_ballmer" + }, + "zero" + ] + }, + "defaultVariant": "zero" + }, + "integer-targeted-zero-flag": { + "state": "ENABLED", + "variants": { + "zero": 0, + "non-zero": 1 + }, + "targeting": { + "if": [ + { + "$ref": "is_ballmer" + }, + "zero" + ] + }, + "defaultVariant": "zero" + }, + "float-targeted-zero-flag": { + "state": "ENABLED", + "variants": { + "zero": 0.0, + "non-zero": 1.0 + }, + "targeting": { + "if": [ + { + "$ref": "is_ballmer" + }, + "zero" + ] + }, + "defaultVariant": "zero" + }, + "null-default-flag": { + "state": "ENABLED", + "variants": { + "on": true, + "off": false + }, + "defaultVariant": null + }, + "undefined-default-flag": { + "state": "ENABLED", + "variants": { + "small": 10, + "big": 1000 + } + }, + "no-default-flag-null-targeting-variant": { + "state": "ENABLED", + "variants": { + "normal": "CFO", + "special": "CEO" + }, + "targeting": { + "if": [ + { + "==": [ + "jobs@orange.com", + { + "var": ["email"] + } + ] + }, + "special", + null + ] + } + }, + "no-default-flag-undefined-targeting-variant": { + "state": "ENABLED", + "variants": { + "normal": "CFO", + "special": "CEO" + }, + "targeting": { + "if": [ + { + "==": [ + "jobs@orange.com", + { + "var": ["email"] + } + ] + }, + "special" + ] + } + } + }, + "$evaluators": { + "is_ballmer": { + "==": [ + "ballmer@macrosoft.com", + { + "var": [ + "email" + ] + } + ] + } + } +}"#; + +#[derive(Debug, World)] +#[world(init = Self::new)] +struct EvaluationWorld { + options: FlagdOptions, + provider: Option, + flag_key: String, + flag_type: String, + default_value: String, + context: EvaluationContext, + resolved_value: Option, + resolved_reason: Option, + resolved_error_code: Option, + resolved_variant: Option, +} + +// Global static container shared across all scenarios +static FLAGD_CONTAINER: std::sync::OnceLock< + std::sync::Arc>>>, +> = std::sync::OnceLock::new(); +static FLAGD_PORTS: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +impl EvaluationWorld { + fn new() -> Self { + Self { + options: FlagdOptions::default(), + provider: None, + flag_key: String::new(), + flag_type: String::new(), + default_value: String::new(), + context: EvaluationContext::default(), + resolved_value: None, + resolved_reason: None, + resolved_error_code: None, + resolved_variant: None, + } + } + + async fn clear(&mut self) { + // Clean up provider (but not the shared container) + self.provider = None; + + // Reset state + self.options = FlagdOptions::default(); + self.flag_key.clear(); + self.flag_type.clear(); + self.default_value.clear(); + self.context = EvaluationContext::default(); + self.resolved_value = None; + self.resolved_reason = None; + self.resolved_error_code = None; + self.resolved_variant = None; + } + + // Initialize the shared flagd container (called once for all scenarios) + async fn ensure_flagd_started() -> (u16, u16, u16) { + let container_lock = + FLAGD_CONTAINER.get_or_init(|| std::sync::Arc::new(tokio::sync::RwLock::new(None))); + + let ports_lock = + FLAGD_PORTS.get_or_init(|| std::sync::Arc::new(tokio::sync::RwLock::new((0, 0, 0)))); + + let mut container_guard = container_lock.write().await; + + if container_guard.is_none() { + // Start the flagd container once + let flagd = Flagd::new() + .with_config(EVALUATION_FLAGS) + .start() + .await + .expect("Failed to start flagd container"); + + let rpc_port = flagd.get_host_port_ipv4(FLAGD_PORT).await.unwrap(); + let sync_port = flagd.get_host_port_ipv4(FLAGD_SYNC_PORT).await.unwrap(); + let ofrep_port = flagd.get_host_port_ipv4(FLAGD_OFREP_PORT).await.unwrap(); + + *ports_lock.write().await = (rpc_port, sync_port, ofrep_port); + *container_guard = Some(flagd); + + // Give the container extra time to fully initialize + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + } + + *ports_lock.read().await + } +} + +impl Default for EvaluationWorld { + fn default() -> Self { + Self::new() + } +} + +// Helper function to convert EvaluationReason to a string representation +fn reason_to_string(reason: EvaluationReason) -> String { + match reason { + EvaluationReason::Static => "STATIC".to_string(), + EvaluationReason::TargetingMatch => "TARGETING_MATCH".to_string(), + EvaluationReason::Default => "DEFAULT".to_string(), + EvaluationReason::Cached => "CACHED".to_string(), + EvaluationReason::Error => "ERROR".to_string(), + EvaluationReason::Other(s) => s.to_uppercase(), + _ => "UNKNOWN".to_string(), + } +} + +// Helper function to convert open_feature::Value to serde_json::Value +fn value_to_json(value: &open_feature::Value) -> serde_json::Value { + match value { + open_feature::Value::Bool(b) => serde_json::Value::Bool(*b), + open_feature::Value::String(s) => serde_json::Value::String(s.clone()), + open_feature::Value::Int(i) => serde_json::Value::Number(serde_json::Number::from(*i)), + open_feature::Value::Float(f) => serde_json::Number::from_f64(*f) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), + open_feature::Value::Array(arr) => { + let vec: Vec = arr.iter().map(value_to_json).collect(); + serde_json::Value::Array(vec) + } + open_feature::Value::Struct(s) => { + let map: serde_json::Map = s + .fields + .iter() + .map(|(k, v)| (k.clone(), value_to_json(v))) + .collect(); + serde_json::Value::Object(map) + } + } +} + +#[given(expr = r#"an option {string} of type {string} with value {string}"#)] +async fn option_with_value( + world: &mut EvaluationWorld, + option: String, + _option_type: String, + value: String, +) { + match option.as_str() { + "cache" => { + world.options.cache_settings = Some(CacheSettings { + cache_type: match value.to_lowercase().as_str() { + "lru" => CacheType::Lru, + "disabled" => CacheType::Disabled, + _ => CacheType::Lru, + }, + ..Default::default() + }); + } + "resolver" => { + world.options.resolver_type = match value.to_uppercase().as_str() { + "RPC" => ResolverType::Rpc, + "IN-PROCESS" | "INPROCESS" => ResolverType::InProcess, + "FILE" => ResolverType::File, + "REST" => ResolverType::Rest, + _ => ResolverType::Rpc, + }; + } + "deadlineMs" => { + if let Ok(deadline) = value.parse::() { + world.options.deadline_ms = deadline; + } + } + _ => {} + } +} + +#[given(expr = "a stable flagd provider")] +async fn stable_flagd_provider(world: &mut EvaluationWorld) { + // Ensure the shared flagd container is started + let (rpc_port, sync_port, ofrep_port) = EvaluationWorld::ensure_flagd_started().await; + + // Get the appropriate port based on resolver type + let port = match world.options.resolver_type { + ResolverType::Rpc => rpc_port, + ResolverType::InProcess => sync_port, + ResolverType::Rest => ofrep_port, + ResolverType::File => { + // For file mode, we don't need to connect to flagd + 0 + } + }; + + world.options.host = "localhost".to_string(); + world.options.port = port; + + // Create provider with retry logic + let mut retry_count = 0; + let max_retries = 3; + let provider = loop { + match FlagdProvider::new(world.options.clone()).await { + Ok(p) => break p, + Err(e) if retry_count < max_retries => { + retry_count += 1; + eprintln!( + "Provider creation failed (attempt {}/{}): {:?}, retrying...", + retry_count, max_retries, e + ); + tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; + } + Err(e) => panic!( + "Failed to create provider after {} retries: {:?}", + max_retries, e + ), + } + }; + + // Give provider time to fully initialize + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + world.provider = Some(provider); +} + +#[given(regex = r#"^a ([A-Za-z]+)-flag with key "([^"]+)" and a default value "([^"]*)"$"#)] +async fn flag_with_key_and_default( + world: &mut EvaluationWorld, + flag_type: String, + key: String, + default: String, +) { + world.flag_type = flag_type; + world.flag_key = key; + world.default_value = default; +} + +#[given( + expr = r#"a context containing a key {string}, with type {string} and with value {string}"# +)] +async fn context_with_key( + world: &mut EvaluationWorld, + key: String, + _type_name: String, + value: String, +) { + world.context = world.context.clone().with_custom_field(key, value); +} + +#[when(expr = "the flag was evaluated with details")] +async fn evaluate_flag_with_details(world: &mut EvaluationWorld) { + let provider = world.provider.as_ref().expect("Provider not initialized"); + + match world.flag_type.as_str() { + "Boolean" => { + let default_bool = world.default_value.to_lowercase() == "true"; + let result = provider + .resolve_bool_value(&world.flag_key, &world.context) + .await; + + match result { + Ok(details) => { + world.resolved_value = Some(details.value.to_string()); + world.resolved_reason = details.reason.map(reason_to_string); + world.resolved_variant = details.variant; + world.resolved_error_code = None; + } + Err(err) => { + world.resolved_value = Some(default_bool.to_string()); + world.resolved_reason = Some("ERROR".to_string()); + world.resolved_error_code = Some(format!("{:?}", err.code)); + } + } + } + "String" => { + let result = provider + .resolve_string_value(&world.flag_key, &world.context) + .await; + + match result { + Ok(details) => { + world.resolved_value = Some(details.value.clone()); + world.resolved_reason = details.reason.map(reason_to_string); + world.resolved_variant = details.variant; + world.resolved_error_code = None; + } + Err(err) => { + world.resolved_value = Some(world.default_value.clone()); + world.resolved_reason = Some("ERROR".to_string()); + world.resolved_error_code = Some(format!("{:?}", err.code)); + } + } + } + "Integer" => { + let default_int = world.default_value.trim().parse::().unwrap_or(0); + let result = provider + .resolve_int_value(&world.flag_key, &world.context) + .await; + + match result { + Ok(details) => { + world.resolved_value = Some(details.value.to_string()); + world.resolved_reason = details.reason.map(reason_to_string); + world.resolved_variant = details.variant; + world.resolved_error_code = None; + } + Err(err) => { + world.resolved_value = Some(default_int.to_string()); + world.resolved_reason = Some("ERROR".to_string()); + world.resolved_error_code = Some(format!("{:?}", err.code)); + } + } + } + "Float" => { + let default_float = world.default_value.trim().parse::().unwrap_or(0.0); + let result = provider + .resolve_float_value(&world.flag_key, &world.context) + .await; + + match result { + Ok(details) => { + world.resolved_value = Some(details.value.to_string()); + world.resolved_reason = details.reason.map(reason_to_string); + world.resolved_variant = details.variant; + world.resolved_error_code = None; + } + Err(err) => { + world.resolved_value = Some(default_float.to_string()); + world.resolved_reason = Some("ERROR".to_string()); + world.resolved_error_code = Some(format!("{:?}", err.code)); + } + } + } + "Object" => { + let result = provider + .resolve_struct_value(&world.flag_key, &world.context) + .await; + + match result { + Ok(details) => { + // Convert StructValue to JSON by converting fields + let json_obj: serde_json::Map = details + .value + .fields + .iter() + .map(|(k, v)| (k.clone(), value_to_json(v))) + .collect(); + let json_str = + serde_json::to_string(&json_obj).unwrap_or_else(|_| "{}".to_string()); + world.resolved_value = Some(json_str); + world.resolved_reason = details.reason.map(reason_to_string); + world.resolved_variant = details.variant; + world.resolved_error_code = None; + } + Err(err) => { + world.resolved_value = Some(world.default_value.clone()); + world.resolved_reason = Some("ERROR".to_string()); + world.resolved_error_code = Some(format!("{:?}", err.code)); + } + } + } + _ => panic!("Unknown flag type: {}", world.flag_type), + } +} + +#[then(expr = r#"the resolved details value should be {string}"#)] +async fn resolved_value_should_be(world: &mut EvaluationWorld, expected: String) { + let actual = world + .resolved_value + .as_ref() + .expect("No resolved value found"); + + // Handle different types of comparisons + match world.flag_type.as_str() { + "Boolean" => { + let expected_bool = expected.to_lowercase() == "true"; + let actual_bool = actual.to_lowercase() == "true"; + assert_eq!( + actual_bool, expected_bool, + "Boolean value mismatch: expected {}, got {}", + expected, actual + ); + } + "String" => { + assert_eq!( + actual, &expected, + "String value mismatch: expected '{}', got '{}'", + expected, actual + ); + } + "Integer" => { + let expected_int = expected.trim().parse::().unwrap_or(0); + let actual_int = actual.trim().parse::().unwrap_or(0); + assert_eq!( + actual_int, expected_int, + "Integer value mismatch: expected {}, got {}", + expected, actual + ); + } + "Float" => { + let expected_float = expected.trim().parse::().unwrap_or(0.0); + let actual_float = actual.trim().parse::().unwrap_or(0.0); + assert!( + (actual_float - expected_float).abs() < 0.0001, + "Float value mismatch: expected {}, got {}", + expected, + actual + ); + } + "Object" => { + // Normalize JSON strings for comparison + let expected_value: serde_json::Value = + serde_json::from_str(&expected).unwrap_or(serde_json::json!({})); + let actual_value: serde_json::Value = + serde_json::from_str(actual).unwrap_or(serde_json::json!({})); + assert_eq!( + actual_value, expected_value, + "Object value mismatch: expected {}, got {}", + expected, actual + ); + } + _ => { + assert_eq!( + actual, &expected, + "Value mismatch: expected '{}', got '{}'", + expected, actual + ); + } + } +} + +#[then(expr = r#"the reason should be {string}"#)] +async fn reason_should_be(world: &mut EvaluationWorld, expected: String) { + let actual = world + .resolved_reason + .as_ref() + .expect("No resolved reason found"); + + assert_eq!( + actual.to_uppercase(), + expected.to_uppercase(), + "Reason mismatch: expected {}, got {}", + expected, + actual + ); +} + +#[then(expr = r#"the error-code should be {string}"#)] +async fn error_code_should_be(world: &mut EvaluationWorld, expected: String) { + if expected.is_empty() { + assert!( + world.resolved_error_code.is_none(), + "Expected no error code, but got: {:?}", + world.resolved_error_code + ); + } else { + let actual = world + .resolved_error_code + .as_ref() + .expect("No error code found"); + + // Convert expected format (e.g., "FLAG_NOT_FOUND") to enum format (e.g., "FlagNotFound") + let expected_normalized = expected.replace("_", "").to_uppercase(); + let actual_normalized = actual.replace("_", "").to_uppercase(); + + assert!( + actual_normalized.contains(&expected_normalized), + "Error code mismatch: expected '{}' (normalized: '{}'), got '{}' (normalized: '{}')", + expected, + expected_normalized, + actual, + actual_normalized + ); + } +} + +#[then(expr = r#"the variant should be {string}"#)] +async fn variant_should_be(world: &mut EvaluationWorld, expected: String) { + let actual = world + .resolved_variant + .as_ref() + .expect("No resolved variant found"); + + assert_eq!( + actual, &expected, + "Variant mismatch: expected '{}', got '{}'", + expected, actual + ); +} + +#[test(tokio::test)] +#[serial_test::serial] +async fn evaluation_test() { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + let feature_path = format!("{}/flagd-testbed/gherkin/evaluation.feature", manifest_dir); + + EvaluationWorld::cucumber() + .before(|_feature, _rule, _scenario, world| { + Box::pin(async move { + world.clear().await; + }) + }) + .run(feature_path) + .await; +} diff --git a/crates/flagd/tests/offline_test.rs b/crates/flagd/tests/offline_test.rs index ffc5aa8..c91210b 100644 --- a/crates/flagd/tests/offline_test.rs +++ b/crates/flagd/tests/offline_test.rs @@ -162,3 +162,28 @@ async fn test_file_resolver_all_types() { Value::String("value".to_string()) ); } + +#[test(tokio::test)] +async fn test_file_resolver_requires_source_configuration() { + // Test that File resolver without source_configuration returns proper error + let result = FlagdProvider::new(FlagdOptions { + resolver_type: ResolverType::File, + source_configuration: None, // Missing required configuration + ..Default::default() + }) + .await; + + assert!( + result.is_err(), + "Expected error when source_configuration is missing" + ); + + let err = result.unwrap_err(); + let err_msg = format!("{}", err); + assert!( + err_msg.contains("source_configuration") + || err_msg.contains("FLAGD_OFFLINE_FLAG_SOURCE_PATH"), + "Error message should mention source_configuration or FLAGD_OFFLINE_FLAG_SOURCE_PATH, got: {}", + err_msg + ); +}