open-feature-ofrep 0.1.2

The official OpenFeature Remote Evaluation Protocol (OFREP) provider for OpenFeature.
Documentation
//! [Generated by cargo-readme: `cargo readme --no-title --no-license > README.md`]::
//!  # OFREP Provider for OpenFeature
//!
//! A Rust implementation of the OpenFeature OFREP provider, enabling dynamic
//! feature flag evaluation in your applications.
//!
//! This provider allows to connect to any feature flag management system that supports OFREP.
//!
//! ### Installation
//! Add the dependency in your `Cargo.toml`:
//! ```bash
//! cargo add open-feature-ofrep
//! cargo add open-feature
//! ```
//! Then integrate it into your application:
//! ```rust,no_run
//! use std::time::Duration;
//! use open_feature::provider::FeatureProvider;
//! use open_feature::EvaluationContext;
//! use open_feature_ofrep::{OfrepProvider, OfrepOptions};
//! use reqwest::header::{HeaderMap, HeaderValue};
//!
//! #[tokio::main]
//! async fn main() {
//!     let mut headers = HeaderMap::new();
//!     headers.insert("color", HeaderValue::from_static("yellow"));
//!
//!     let provider = OfrepProvider::new(OfrepOptions {
//!         base_url: "http://localhost:8016".to_string(),
//!         headers: headers.clone(),
//!         connect_timeout: Duration::from_secs(4),
//!         ..Default::default()
//!     }).await.unwrap();
//!
//!     let context = EvaluationContext::default()
//!                     .with_targeting_key("user-123")
//!                     .with_custom_field("color", "yellow");
//!
//!     let result = provider.resolve_bool_value("isColorYellow", &context).await.unwrap();
//!     println!("Flag value: {}", result.value);
//! }
//! ```
//!
//! ### Configuration Options
//! Configurations can be provided as constructor options. The following options are supported:
//!
//! | Option                                  | Type / Supported Value            | Default                             |
//! |-----------------------------------------|-----------------------------------|-------------------------------------|
//! | base_url                                | string                            | http://localhost:8016               |
//! | headers                                 | HeaderMap                         | Empty Map                           |
//! | connect_timeout                         | Duration                          | 10 seconds                          |
//!
//! ### License
//! Apache 2.0 - See [LICENSE](./../../LICENSE) for more information.

mod error;
mod resolver;

use error::OfrepError;
use open_feature::provider::{FeatureProvider, ProviderMetadata, ResolutionDetails};
use open_feature::{EvaluationContext, EvaluationError, StructValue};
use reqwest::header::HeaderMap;
use resolver::Resolver;
use std::fmt;
use std::sync::Arc;
use std::time::Duration;
use tracing::debug;
use tracing::instrument;
use url::Url;

use async_trait::async_trait;

const DEFAULT_BASE_URL: &str = "http://localhost:8016";
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);

#[derive(Debug, Clone)]
pub struct OfrepOptions {
    pub base_url: String,
    pub headers: HeaderMap,
    pub connect_timeout: Duration,
    /// Path to a PEM-encoded CA certificate file for TLS connections.
    /// If provided along with https scheme, the certificate is loaded and used as the trusted CA.
    pub cert_path: Option<String>,
}

impl Default for OfrepOptions {
    fn default() -> Self {
        OfrepOptions {
            base_url: DEFAULT_BASE_URL.to_string(),
            headers: HeaderMap::new(),
            connect_timeout: DEFAULT_CONNECT_TIMEOUT,
            cert_path: None,
        }
    }
}

pub struct OfrepProvider {
    provider: Arc<dyn FeatureProvider + Send + Sync>,
}

impl fmt::Debug for OfrepProvider {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("OfrepProvider")
            .field("provider", &"<FeatureProvider>")
            .finish()
    }
}

impl OfrepProvider {
    #[instrument(skip(options))]
    pub async fn new(options: OfrepOptions) -> Result<Self, OfrepError> {
        debug!("Initializing OfrepProvider with options: {:?}", options);

        let url = Url::parse(&options.base_url).map_err(|e| {
            OfrepError::Config(format!("Invalid base url: '{}' ({})", options.base_url, e))
        })?;

        if !matches!(url.scheme(), "http" | "https") {
            return Err(OfrepError::Config(format!(
                "Invalid base url: '{}' (unsupported scheme)",
                url.scheme()
            )));
        }

        Ok(Self {
            provider: Arc::new(Resolver::new(&options)?),
        })
    }
}

#[async_trait]
impl FeatureProvider for OfrepProvider {
    fn metadata(&self) -> &ProviderMetadata {
        self.provider.metadata()
    }

    async fn resolve_bool_value(
        &self,
        flag_key: &str,
        context: &EvaluationContext,
    ) -> Result<ResolutionDetails<bool>, EvaluationError> {
        self.provider.resolve_bool_value(flag_key, context).await
    }

    async fn resolve_int_value(
        &self,
        flag_key: &str,
        context: &EvaluationContext,
    ) -> Result<ResolutionDetails<i64>, EvaluationError> {
        self.provider.resolve_int_value(flag_key, context).await
    }

    async fn resolve_float_value(
        &self,
        flag_key: &str,
        context: &EvaluationContext,
    ) -> Result<ResolutionDetails<f64>, EvaluationError> {
        self.provider.resolve_float_value(flag_key, context).await
    }

    async fn resolve_string_value(
        &self,
        flag_key: &str,
        context: &EvaluationContext,
    ) -> Result<ResolutionDetails<String>, EvaluationError> {
        self.provider.resolve_string_value(flag_key, context).await
    }

    async fn resolve_struct_value(
        &self,
        flag_key: &str,
        context: &EvaluationContext,
    ) -> Result<ResolutionDetails<StructValue>, EvaluationError> {
        self.provider.resolve_struct_value(flag_key, context).await
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use test_log::test;

    #[test(tokio::test)]
    async fn test_ofrep_options_validation() {
        let provider_with_empty_host = OfrepProvider::new(OfrepOptions {
            base_url: "http://".to_string(),
            ..Default::default()
        })
        .await;

        let provider_with_invalid_scheme = OfrepProvider::new(OfrepOptions {
            base_url: "invalid://".to_string(),
            ..Default::default()
        })
        .await;

        assert!(provider_with_empty_host.is_err());
        assert!(provider_with_invalid_scheme.is_err());

        assert_eq!(
            provider_with_empty_host.unwrap_err(),
            OfrepError::Config("Invalid base url: 'http://' (empty host)".to_string())
        );
        assert_eq!(
            provider_with_invalid_scheme.unwrap_err(),
            OfrepError::Config("Invalid base url: 'invalid' (unsupported scheme)".to_string())
        );
    }

    #[test(tokio::test)]
    async fn test_ofrep_provider_with_https_missing_cert_fails() {
        let provider = OfrepProvider::new(OfrepOptions {
            base_url: "https://localhost:8016".to_string(),
            cert_path: Some("/nonexistent/path/to/cert.pem".to_string()),
            ..Default::default()
        })
        .await;

        assert_eq!(
            provider.unwrap_err(),
            OfrepError::Config(
                "Failed to read certificate file '/nonexistent/path/to/cert.pem': No such file or directory (os error 2)"
                    .to_string()
            )
        );
    }
}