Skip to main content

open_feature_ofrep/
lib.rs

1//! [Generated by cargo-readme: `cargo readme --no-title --no-license > README.md`]::
2//!  # OFREP Provider for OpenFeature
3//!
4//! A Rust implementation of the OpenFeature OFREP provider, enabling dynamic
5//! feature flag evaluation in your applications.
6//!
7//! This provider allows to connect to any feature flag management system that supports OFREP.
8//!
9//! ### Installation
10//! Add the dependency in your `Cargo.toml`:
11//! ```bash
12//! cargo add open-feature-ofrep
13//! cargo add open-feature
14//! ```
15//! Then integrate it into your application:
16//! ```rust,no_run
17//! use std::time::Duration;
18//! use open_feature::provider::FeatureProvider;
19//! use open_feature::EvaluationContext;
20//! use open_feature_ofrep::{OfrepProvider, OfrepOptions};
21//! use reqwest::header::{HeaderMap, HeaderValue};
22//!
23//! #[tokio::main]
24//! async fn main() {
25//!     let mut headers = HeaderMap::new();
26//!     headers.insert("color", HeaderValue::from_static("yellow"));
27//!
28//!     let provider = OfrepProvider::new(OfrepOptions {
29//!         base_url: "http://localhost:8016".to_string(),
30//!         headers: headers.clone(),
31//!         connect_timeout: Duration::from_secs(4),
32//!         ..Default::default()
33//!     }).await.unwrap();
34//!
35//!     let context = EvaluationContext::default()
36//!                     .with_targeting_key("user-123")
37//!                     .with_custom_field("color", "yellow");
38//!
39//!     let result = provider.resolve_bool_value("isColorYellow", &context).await.unwrap();
40//!     println!("Flag value: {}", result.value);
41//! }
42//! ```
43//!
44//! ### Configuration Options
45//! Configurations can be provided as constructor options. The following options are supported:
46//!
47//! | Option                                  | Type / Supported Value            | Default                             |
48//! |-----------------------------------------|-----------------------------------|-------------------------------------|
49//! | base_url                                | string                            | http://localhost:8016               |
50//! | headers                                 | HeaderMap                         | Empty Map                           |
51//! | connect_timeout                         | Duration                          | 10 seconds                          |
52//!
53//! ### License
54//! Apache 2.0 - See [LICENSE](./../../LICENSE) for more information.
55
56mod error;
57mod resolver;
58
59use error::OfrepError;
60use open_feature::provider::{FeatureProvider, ProviderMetadata, ResolutionDetails};
61use open_feature::{EvaluationContext, EvaluationError, StructValue};
62use reqwest::header::HeaderMap;
63use resolver::Resolver;
64use std::fmt;
65use std::sync::Arc;
66use std::time::Duration;
67use tracing::debug;
68use tracing::instrument;
69use url::Url;
70
71use async_trait::async_trait;
72
73const DEFAULT_BASE_URL: &str = "http://localhost:8016";
74const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
75
76#[derive(Debug, Clone)]
77pub struct OfrepOptions {
78    pub base_url: String,
79    pub headers: HeaderMap,
80    pub connect_timeout: Duration,
81    /// Path to a PEM-encoded CA certificate file for TLS connections.
82    /// If provided along with https scheme, the certificate is loaded and used as the trusted CA.
83    pub cert_path: Option<String>,
84}
85
86impl Default for OfrepOptions {
87    fn default() -> Self {
88        OfrepOptions {
89            base_url: DEFAULT_BASE_URL.to_string(),
90            headers: HeaderMap::new(),
91            connect_timeout: DEFAULT_CONNECT_TIMEOUT,
92            cert_path: None,
93        }
94    }
95}
96
97pub struct OfrepProvider {
98    provider: Arc<dyn FeatureProvider + Send + Sync>,
99}
100
101impl fmt::Debug for OfrepProvider {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        f.debug_struct("OfrepProvider")
104            .field("provider", &"<FeatureProvider>")
105            .finish()
106    }
107}
108
109impl OfrepProvider {
110    #[instrument(skip(options))]
111    pub async fn new(options: OfrepOptions) -> Result<Self, OfrepError> {
112        debug!("Initializing OfrepProvider with options: {:?}", options);
113
114        let url = Url::parse(&options.base_url).map_err(|e| {
115            OfrepError::Config(format!("Invalid base url: '{}' ({})", options.base_url, e))
116        })?;
117
118        if !matches!(url.scheme(), "http" | "https") {
119            return Err(OfrepError::Config(format!(
120                "Invalid base url: '{}' (unsupported scheme)",
121                url.scheme()
122            )));
123        }
124
125        Ok(Self {
126            provider: Arc::new(Resolver::new(&options)?),
127        })
128    }
129}
130
131#[async_trait]
132impl FeatureProvider for OfrepProvider {
133    fn metadata(&self) -> &ProviderMetadata {
134        self.provider.metadata()
135    }
136
137    async fn resolve_bool_value(
138        &self,
139        flag_key: &str,
140        context: &EvaluationContext,
141    ) -> Result<ResolutionDetails<bool>, EvaluationError> {
142        self.provider.resolve_bool_value(flag_key, context).await
143    }
144
145    async fn resolve_int_value(
146        &self,
147        flag_key: &str,
148        context: &EvaluationContext,
149    ) -> Result<ResolutionDetails<i64>, EvaluationError> {
150        self.provider.resolve_int_value(flag_key, context).await
151    }
152
153    async fn resolve_float_value(
154        &self,
155        flag_key: &str,
156        context: &EvaluationContext,
157    ) -> Result<ResolutionDetails<f64>, EvaluationError> {
158        self.provider.resolve_float_value(flag_key, context).await
159    }
160
161    async fn resolve_string_value(
162        &self,
163        flag_key: &str,
164        context: &EvaluationContext,
165    ) -> Result<ResolutionDetails<String>, EvaluationError> {
166        self.provider.resolve_string_value(flag_key, context).await
167    }
168
169    async fn resolve_struct_value(
170        &self,
171        flag_key: &str,
172        context: &EvaluationContext,
173    ) -> Result<ResolutionDetails<StructValue>, EvaluationError> {
174        self.provider.resolve_struct_value(flag_key, context).await
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use test_log::test;
182
183    #[test(tokio::test)]
184    async fn test_ofrep_options_validation() {
185        let provider_with_empty_host = OfrepProvider::new(OfrepOptions {
186            base_url: "http://".to_string(),
187            ..Default::default()
188        })
189        .await;
190
191        let provider_with_invalid_scheme = OfrepProvider::new(OfrepOptions {
192            base_url: "invalid://".to_string(),
193            ..Default::default()
194        })
195        .await;
196
197        assert!(provider_with_empty_host.is_err());
198        assert!(provider_with_invalid_scheme.is_err());
199
200        assert_eq!(
201            provider_with_empty_host.unwrap_err(),
202            OfrepError::Config("Invalid base url: 'http://' (empty host)".to_string())
203        );
204        assert_eq!(
205            provider_with_invalid_scheme.unwrap_err(),
206            OfrepError::Config("Invalid base url: 'invalid' (unsupported scheme)".to_string())
207        );
208    }
209
210    #[test(tokio::test)]
211    async fn test_ofrep_provider_with_https_missing_cert_fails() {
212        let provider = OfrepProvider::new(OfrepOptions {
213            base_url: "https://localhost:8016".to_string(),
214            cert_path: Some("/nonexistent/path/to/cert.pem".to_string()),
215            ..Default::default()
216        })
217        .await;
218
219        assert_eq!(
220            provider.unwrap_err(),
221            OfrepError::Config(
222                "Failed to read certificate file '/nonexistent/path/to/cert.pem': No such file or directory (os error 2)"
223                    .to_string()
224            )
225        );
226    }
227}