Skip to main content

open_feature_flagd/resolver/
rest.rs

1//! # REST Flag Resolver
2//!
3//! Evaluates feature flags using the OpenFeature Remote Evaluation Protocol (OFREP) over HTTP.
4//! This module wraps the `open-feature-ofrep` crate.
5//!
6//! ## Example
7//!
8//! ```rust,no_run
9//! use open_feature_flagd::resolver::rest::RestResolver;
10//! use open_feature_flagd::FlagdOptions;
11//! use open_feature::provider::FeatureProvider;
12//! use open_feature::EvaluationContext;
13//!
14//! #[tokio::main]
15//! async fn main() {
16//!     let options = FlagdOptions {
17//!         host: "localhost".to_string(),
18//!         port: 8016,
19//!         ..Default::default()
20//!     };
21//!     let resolver = RestResolver::new(&options).await.unwrap();
22//!     let context = EvaluationContext::default();
23//!
24//!     let result = resolver.resolve_bool_value("my-flag", &context).await.unwrap();
25//!     println!("Flag value: {}", result.value);
26//! }
27//! ```
28
29use async_trait::async_trait;
30use open_feature::provider::{FeatureProvider, ProviderMetadata, ResolutionDetails};
31use open_feature::{EvaluationContext, EvaluationError, StructValue};
32use open_feature_ofrep::{OfrepOptions, OfrepProvider};
33use std::time::Duration;
34use tracing::instrument;
35
36use crate::FlagdOptions;
37use crate::error::FlagdError;
38
39/// REST-based resolver implementing the OpenFeature Remote Evaluation Protocol (OFREP).
40/// This is a wrapper around the `open-feature-ofrep` crate.
41#[derive(Debug)]
42pub struct RestResolver {
43    provider: OfrepProvider,
44}
45
46impl RestResolver {
47    /// Creates a new REST resolver with the specified options.
48    ///
49    /// # Arguments
50    ///
51    /// * `options` - Configuration options including host, port, TLS settings, and certificate path
52    ///
53    /// # Returns
54    ///
55    /// A `Result` containing the new RestResolver instance or an error if initialization fails
56    pub async fn new(options: &FlagdOptions) -> Result<Self, FlagdError> {
57        let scheme = if options.tls { "https" } else { "http" };
58        let base_url = if let Some(uri) = &options.target_uri {
59            // Check if URI already has a scheme
60            if uri.starts_with("http://") || uri.starts_with("https://") {
61                uri.clone()
62            } else {
63                format!("{}://{}", scheme, uri)
64            }
65        } else {
66            format!("{}://{}:{}", scheme, options.host, options.port)
67        };
68
69        let connect_timeout = Duration::from_millis(options.deadline_ms as u64);
70
71        let ofrep_options = OfrepOptions {
72            base_url,
73            headers: Default::default(),
74            connect_timeout,
75            cert_path: options.cert_path.clone(),
76        };
77
78        OfrepProvider::new(ofrep_options)
79            .await
80            .map(|provider| Self { provider })
81            .map_err(|e| FlagdError::Config(e.to_string()))
82    }
83}
84
85#[async_trait]
86impl FeatureProvider for RestResolver {
87    fn metadata(&self) -> &ProviderMetadata {
88        self.provider.metadata()
89    }
90
91    /// Resolves a boolean flag value
92    #[instrument(skip(self, evaluation_context), fields(flag_key = %flag_key))]
93    async fn resolve_bool_value(
94        &self,
95        flag_key: &str,
96        evaluation_context: &EvaluationContext,
97    ) -> Result<ResolutionDetails<bool>, EvaluationError> {
98        self.provider
99            .resolve_bool_value(flag_key, evaluation_context)
100            .await
101    }
102
103    /// Resolves a string flag value
104    #[instrument(skip(self, evaluation_context), fields(flag_key = %flag_key))]
105    async fn resolve_string_value(
106        &self,
107        flag_key: &str,
108        evaluation_context: &EvaluationContext,
109    ) -> Result<ResolutionDetails<String>, EvaluationError> {
110        self.provider
111            .resolve_string_value(flag_key, evaluation_context)
112            .await
113    }
114
115    /// Resolves a float flag value
116    #[instrument(skip(self, evaluation_context), fields(flag_key = %flag_key))]
117    async fn resolve_float_value(
118        &self,
119        flag_key: &str,
120        evaluation_context: &EvaluationContext,
121    ) -> Result<ResolutionDetails<f64>, EvaluationError> {
122        self.provider
123            .resolve_float_value(flag_key, evaluation_context)
124            .await
125    }
126
127    /// Resolves an integer flag value
128    #[instrument(skip(self, evaluation_context), fields(flag_key = %flag_key))]
129    async fn resolve_int_value(
130        &self,
131        flag_key: &str,
132        evaluation_context: &EvaluationContext,
133    ) -> Result<ResolutionDetails<i64>, EvaluationError> {
134        self.provider
135            .resolve_int_value(flag_key, evaluation_context)
136            .await
137    }
138
139    /// Resolves a structured flag value
140    #[instrument(skip(self, evaluation_context), fields(flag_key = %flag_key))]
141    async fn resolve_struct_value(
142        &self,
143        flag_key: &str,
144        evaluation_context: &EvaluationContext,
145    ) -> Result<ResolutionDetails<StructValue>, EvaluationError> {
146        self.provider
147            .resolve_struct_value(flag_key, evaluation_context)
148            .await
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use open_feature::EvaluationContext;
156    use serde_json::json;
157    use test_log::test;
158    use wiremock::matchers::{method, path};
159    use wiremock::{Mock, MockServer, ResponseTemplate};
160
161    async fn setup_mock_server() -> (MockServer, RestResolver) {
162        let mock_server = MockServer::start().await;
163        let options = FlagdOptions {
164            host: mock_server.address().ip().to_string(),
165            port: mock_server.address().port(),
166            target_uri: None,
167            deadline_ms: 5000,
168            ..Default::default()
169        };
170        let resolver = RestResolver::new(&options)
171            .await
172            .expect("Failed to create RestResolver");
173        (mock_server, resolver)
174    }
175
176    #[test(tokio::test)]
177    async fn test_resolve_bool_value() {
178        let (mock_server, resolver) = setup_mock_server().await;
179
180        Mock::given(method("POST"))
181            .and(path("/ofrep/v1/evaluate/flags/test-flag"))
182            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
183                "value": true,
184                "variant": "on",
185                "reason": "STATIC"
186            })))
187            .mount(&mock_server)
188            .await;
189
190        let context = EvaluationContext::default().with_targeting_key("test-user");
191        let result = resolver
192            .resolve_bool_value("test-flag", &context)
193            .await
194            .unwrap();
195
196        assert_eq!(result.value, true);
197        assert_eq!(result.variant, Some("on".to_string()));
198        assert_eq!(result.reason, Some(open_feature::EvaluationReason::Static));
199    }
200
201    #[test(tokio::test)]
202    async fn test_resolve_string_value() {
203        let (mock_server, resolver) = setup_mock_server().await;
204
205        Mock::given(method("POST"))
206            .and(path("/ofrep/v1/evaluate/flags/test-flag"))
207            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
208                "value": "test-value",
209                "variant": "key1",
210                "reason": "STATIC"
211            })))
212            .mount(&mock_server)
213            .await;
214
215        let context = EvaluationContext::default().with_targeting_key("test-user");
216        let result = resolver
217            .resolve_string_value("test-flag", &context)
218            .await
219            .unwrap();
220
221        assert_eq!(result.value, "test-value");
222        assert_eq!(result.variant, Some("key1".to_string()));
223        assert_eq!(result.reason, Some(open_feature::EvaluationReason::Static));
224    }
225
226    #[test(tokio::test)]
227    async fn test_resolve_float_value() {
228        let (mock_server, resolver) = setup_mock_server().await;
229
230        Mock::given(method("POST"))
231            .and(path("/ofrep/v1/evaluate/flags/test-flag"))
232            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
233                "value": 1.23,
234                "variant": "one",
235                "reason": "STATIC"
236            })))
237            .mount(&mock_server)
238            .await;
239
240        let context = EvaluationContext::default().with_targeting_key("test-user");
241        let result = resolver
242            .resolve_float_value("test-flag", &context)
243            .await
244            .unwrap();
245
246        assert_eq!(result.value, 1.23);
247        assert_eq!(result.variant, Some("one".to_string()));
248        assert_eq!(result.reason, Some(open_feature::EvaluationReason::Static));
249    }
250
251    #[test(tokio::test)]
252    async fn test_resolve_int_value() {
253        let (mock_server, resolver) = setup_mock_server().await;
254
255        Mock::given(method("POST"))
256            .and(path("/ofrep/v1/evaluate/flags/test-flag"))
257            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
258                "value": 42,
259                "variant": "one",
260                "reason": "STATIC"
261            })))
262            .mount(&mock_server)
263            .await;
264
265        let context = EvaluationContext::default().with_targeting_key("test-user");
266        let result = resolver
267            .resolve_int_value("test-flag", &context)
268            .await
269            .unwrap();
270
271        assert_eq!(result.value, 42);
272        assert_eq!(result.variant, Some("one".to_string()));
273        assert_eq!(result.reason, Some(open_feature::EvaluationReason::Static));
274    }
275
276    #[test(tokio::test)]
277    async fn test_resolve_struct_value() {
278        let (mock_server, resolver) = setup_mock_server().await;
279
280        Mock::given(method("POST"))
281            .and(path("/ofrep/v1/evaluate/flags/test-flag"))
282            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
283                "value": {
284                    "key": "val",
285                    "number": 42,
286                    "boolean": true,
287                    "nested": {
288                        "inner": "value"
289                    }
290                },
291                "variant": "object1",
292                "reason": "STATIC"
293            })))
294            .mount(&mock_server)
295            .await;
296
297        let context = EvaluationContext::default().with_targeting_key("test-user");
298        let result = resolver
299            .resolve_struct_value("test-flag", &context)
300            .await
301            .unwrap();
302
303        let value = &result.value;
304        assert_eq!(value.fields.get("key").unwrap().as_str().unwrap(), "val");
305        assert_eq!(value.fields.get("number").unwrap().as_i64().unwrap(), 42);
306        assert_eq!(
307            value.fields.get("boolean").unwrap().as_bool().unwrap(),
308            true
309        );
310
311        let nested = value.fields.get("nested").unwrap().as_struct().unwrap();
312        assert_eq!(
313            nested.fields.get("inner").unwrap().as_str().unwrap(),
314            "value"
315        );
316
317        assert_eq!(result.variant, Some("object1".to_string()));
318        assert_eq!(result.reason, Some(open_feature::EvaluationReason::Static));
319    }
320}