Skip to main content

launchdarkly_server_sdk/
config.rs

1use thiserror::Error;
2
3use crate::data_source_builders::{DataSourceFactory, NullDataSourceBuilder};
4
5#[cfg(any(
6    feature = "hyper-rustls-native-roots",
7    feature = "hyper-rustls-webpki-roots",
8    feature = "native-tls"
9))]
10use crate::events::processor_builders::EventProcessorBuilder;
11use crate::events::processor_builders::{EventProcessorFactory, NullEventProcessorBuilder};
12
13use crate::stores::store_builders::{DataStoreFactory, InMemoryDataStoreBuilder};
14use crate::ServiceEndpointsBuilder;
15#[cfg(any(
16    feature = "hyper-rustls-native-roots",
17    feature = "hyper-rustls-webpki-roots",
18    feature = "native-tls"
19))]
20use crate::StreamingDataSourceBuilder;
21
22use std::borrow::Borrow;
23
24#[derive(Debug)]
25struct Tag {
26    key: String,
27    value: String,
28}
29
30impl Tag {
31    fn is_valid(&self) -> Result<(), &str> {
32        if self.value.chars().count() > 64 {
33            return Err("Value was longer than 64 characters and was discarded");
34        }
35
36        if self.key.is_empty() || !self.key.chars().all(Tag::valid_characters) {
37            return Err("Key was empty or contained invalid characters");
38        }
39
40        if self.value.is_empty() || !self.value.chars().all(Tag::valid_characters) {
41            return Err("Value was empty or contained invalid characters");
42        }
43
44        Ok(())
45    }
46
47    fn valid_characters(c: char) -> bool {
48        c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_')
49    }
50}
51
52impl std::fmt::Display for Tag {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        write!(f, "{}/{}", self.key, self.value)
55    }
56}
57
58/// ApplicationInfo allows configuration of application metadata.
59///
60/// If you want to set non-default values for any of these fields, create a new instance with
61/// [ApplicationInfo::new] and pass it to [ConfigBuilder::application_info].
62pub struct ApplicationInfo {
63    tags: Vec<Tag>,
64}
65
66impl ApplicationInfo {
67    /// Create a new default instance of [ApplicationInfo].
68    pub fn new() -> Self {
69        Self { tags: Vec::new() }
70    }
71
72    /// A unique identifier representing the application where the LaunchDarkly SDK is running.
73    ///
74    /// This can be specified as any string value as long as it only uses the following characters:
75    /// ASCII letters, ASCII digits, period, hyphen, underscore. A string containing any other
76    /// characters will be ignored.
77    pub fn application_identifier(&mut self, application_id: impl Into<String>) -> &mut Self {
78        self.add_tag("application-id", application_id)
79    }
80
81    /// A unique identifier representing the version of the application where the LaunchDarkly SDK
82    /// is running.
83    ///
84    /// This can be specified as any string value as long as it only uses the following characters:
85    /// ASCII letters, ASCII digits, period, hyphen, underscore. A string containing any other
86    /// characters will be ignored.
87    pub fn application_version(&mut self, application_version: impl Into<String>) -> &mut Self {
88        self.add_tag("application-version", application_version)
89    }
90
91    fn add_tag(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
92        let tag = Tag {
93            key: key.into(),
94            value: value.into(),
95        };
96
97        match tag.is_valid() {
98            Ok(_) => self.tags.push(tag),
99            Err(e) => {
100                warn!("{e}")
101            }
102        }
103
104        self
105    }
106
107    pub(crate) fn build(&self) -> Option<String> {
108        if self.tags.is_empty() {
109            return None;
110        }
111
112        let mut tags = self
113            .tags
114            .iter()
115            .map(|tag| tag.to_string())
116            .collect::<Vec<String>>();
117
118        tags.sort();
119        tags.dedup();
120
121        Some(tags.join(" "))
122    }
123}
124
125impl Default for ApplicationInfo {
126    fn default() -> Self {
127        Self::new()
128    }
129}
130
131/// Immutable configuration object for [crate::Client].
132///
133/// [Config] instances can be created using a [ConfigBuilder].
134pub struct Config {
135    sdk_key: String,
136    service_endpoints_builder: ServiceEndpointsBuilder,
137    data_store_builder: Box<dyn DataStoreFactory>,
138    data_source_builder: Box<dyn DataSourceFactory>,
139    event_processor_builder: Box<dyn EventProcessorFactory>,
140    application_tag: Option<String>,
141    instance_id: String,
142    offline: bool,
143    daemon_mode: bool,
144}
145
146impl Config {
147    /// Returns the sdk key.
148    pub fn sdk_key(&self) -> &str {
149        &self.sdk_key
150    }
151
152    /// Returns the [ServiceEndpointsBuilder]
153    pub fn service_endpoints_builder(&self) -> &ServiceEndpointsBuilder {
154        &self.service_endpoints_builder
155    }
156
157    /// Returns the DataStoreFactory
158    pub fn data_store_builder(&self) -> &dyn DataStoreFactory {
159        self.data_store_builder.borrow()
160    }
161
162    /// Returns the DataSourceFactory
163    pub fn data_source_builder(&self) -> &dyn DataSourceFactory {
164        self.data_source_builder.borrow()
165    }
166
167    /// Returns the EventProcessorFactory
168    pub fn event_processor_builder(&self) -> &dyn EventProcessorFactory {
169        self.event_processor_builder.borrow()
170    }
171
172    /// Returns the offline status
173    pub fn offline(&self) -> bool {
174        self.offline
175    }
176
177    /// Returns the daemon mode status
178    pub fn daemon_mode(&self) -> bool {
179        self.daemon_mode
180    }
181
182    /// Returns the tag builder if provided
183    pub fn application_tag(&self) -> &Option<String> {
184        &self.application_tag
185    }
186
187    /// Returns the per-SDK-instance identifier. This is a v4 UUID, generated once when the
188    /// [Config] is built, that is included in the `X-LaunchDarkly-Instance-Id` HTTP header
189    /// on outbound requests for the lifetime of the SDK instance.
190    pub fn instance_id(&self) -> &str {
191        &self.instance_id
192    }
193}
194
195/// Error type used to represent failures when building a Config instance.
196#[non_exhaustive]
197#[derive(Debug, Error)]
198pub enum BuildError {
199    /// Error used when a configuration setting is invalid.
200    #[error("config failed to build: {0}")]
201    InvalidConfig(String),
202}
203
204/// Used to create a [Config] struct for creating [crate::Client] instances.
205///
206/// For usage examples see:
207/// - [Creating service endpoints](crate::ServiceEndpointsBuilder)
208/// - [Configuring a persistent data store](crate::PersistentDataStoreBuilder)
209/// - [Configuring the streaming data source](crate::StreamingDataSourceBuilder)
210/// - [Configuring events sent to LaunchDarkly](crate::EventProcessorBuilder)
211pub struct ConfigBuilder {
212    service_endpoints_builder: Option<ServiceEndpointsBuilder>,
213    data_store_builder: Option<Box<dyn DataStoreFactory>>,
214    data_source_builder: Option<Box<dyn DataSourceFactory>>,
215    event_processor_builder: Option<Box<dyn EventProcessorFactory>>,
216    application_info: Option<ApplicationInfo>,
217    offline: bool,
218    daemon_mode: bool,
219    sdk_key: String,
220}
221
222impl ConfigBuilder {
223    /// Create a new instance of the [ConfigBuilder] with the provided `sdk_key`.
224    pub fn new(sdk_key: &str) -> Self {
225        Self {
226            service_endpoints_builder: None,
227            data_store_builder: None,
228            data_source_builder: None,
229            event_processor_builder: None,
230            offline: false,
231            daemon_mode: false,
232            application_info: None,
233            sdk_key: sdk_key.to_string(),
234        }
235    }
236
237    /// Set the URLs to use for this client. For usage see [ServiceEndpointsBuilder]
238    pub fn service_endpoints(mut self, builder: &ServiceEndpointsBuilder) -> Self {
239        self.service_endpoints_builder = Some(builder.clone());
240        self
241    }
242
243    /// Set the data store to use for this client.
244    ///
245    /// By default, the SDK uses an in-memory data store.
246    /// For a persistent store, see [PersistentDataStoreBuilder](crate::stores::persistent_store_builders::PersistentDataStoreBuilder).
247    pub fn data_store(mut self, builder: &dyn DataStoreFactory) -> Self {
248        self.data_store_builder = Some(builder.to_owned());
249        self
250    }
251
252    /// Set the data source to use for this client.
253    /// For the streaming data source, see [StreamingDataSourceBuilder](crate::data_source_builders::StreamingDataSourceBuilder).
254    ///
255    /// If offline mode is enabled, this data source will be ignored.
256    pub fn data_source(mut self, builder: &dyn DataSourceFactory) -> Self {
257        self.data_source_builder = Some(builder.to_owned());
258        self
259    }
260
261    /// Set the event processor to use for this client.
262    /// For usage see [EventProcessorBuilder](crate::EventProcessorBuilder).
263    ///
264    /// If offline mode is enabled, this event processor will be ignored.
265    pub fn event_processor(mut self, builder: &dyn EventProcessorFactory) -> Self {
266        self.event_processor_builder = Some(builder.to_owned());
267        self
268    }
269
270    /// Whether the client should be initialized in offline mode.
271    ///
272    /// In offline mode, default values are returned for all flags and no remote network requests
273    /// are made. By default, this is false.
274    pub fn offline(mut self, offline: bool) -> Self {
275        self.offline = offline;
276        self
277    }
278
279    /// Whether the client should operate in daemon mode.
280    ///
281    /// In daemon mode, the client will not receive updates directly from LaunchDarkly. Instead,
282    /// the client will rely on the data store to provide the latest feature flag values. By
283    /// default, this is false.
284    pub fn daemon_mode(mut self, enable: bool) -> Self {
285        self.daemon_mode = enable;
286        self
287    }
288
289    /// Provides configuration of application metadata.
290    ///
291    /// These properties are optional and informational. They may be used in LaunchDarkly analytics
292    /// or other product features, but they do not affect feature flag evaluations.
293    pub fn application_info(mut self, application_info: ApplicationInfo) -> Self {
294        self.application_info = Some(application_info);
295        self
296    }
297
298    /// Create a new instance of [Config] based on the [ConfigBuilder] configuration.
299    pub fn build(self) -> Result<Config, BuildError> {
300        let service_endpoints_builder = match &self.service_endpoints_builder {
301            None => ServiceEndpointsBuilder::new(),
302            Some(service_endpoints_builder) => service_endpoints_builder.clone(),
303        };
304
305        let data_store_builder = match &self.data_store_builder {
306            None => Box::new(InMemoryDataStoreBuilder::new()),
307            Some(_data_store_builder) => self.data_store_builder.unwrap(),
308        };
309
310        let data_source_builder_result: Result<Box<dyn DataSourceFactory>, BuildError> =
311            match self.data_source_builder {
312                None if self.offline => Ok(Box::new(NullDataSourceBuilder::new())),
313                Some(_) if self.offline => {
314                    warn!("Custom data source builders will be ignored when in offline mode");
315                    Ok(Box::new(NullDataSourceBuilder::new()))
316                }
317                None if self.daemon_mode => Ok(Box::new(NullDataSourceBuilder::new())),
318                Some(_) if self.daemon_mode => {
319                    warn!("Custom data source builders will be ignored when in daemon mode");
320                    Ok(Box::new(NullDataSourceBuilder::new()))
321                }
322                Some(builder) => Ok(builder),
323                #[cfg(any(
324                    feature = "hyper-rustls-native-roots",
325                    feature = "hyper-rustls-webpki-roots",
326                    feature = "native-tls"
327                ))]
328                None => {
329                    let transport = launchdarkly_sdk_transport::HyperTransport::new_https()
330                        .map_err(|e| {
331                            BuildError::InvalidConfig(format!(
332                                "failed to create default transport: {}",
333                                e
334                            ))
335                        })?;
336                    let mut builder = StreamingDataSourceBuilder::new();
337                    builder.transport(transport);
338                    Ok(Box::new(builder))
339                }
340                #[cfg(not(any(
341                    feature = "hyper-rustls-native-roots",
342                    feature = "hyper-rustls-webpki-roots",
343                    feature = "native-tls"
344                )))]
345                None => Err(BuildError::InvalidConfig(
346                    "data source builder required when hyper-rustls-native-roots, hyper-rustls-webpki-roots, or native-tls features are disabled".into(),
347                )),
348            };
349        let data_source_builder = data_source_builder_result?;
350
351        let event_processor_builder_result: Result<Box<dyn EventProcessorFactory>, BuildError> =
352            match self.event_processor_builder {
353                None if self.offline => Ok(Box::new(NullEventProcessorBuilder::new())),
354                Some(_) if self.offline => {
355                    warn!("Custom event processor builders will be ignored when in offline mode");
356                    Ok(Box::new(NullEventProcessorBuilder::new()))
357                }
358                Some(builder) => Ok(builder),
359                #[cfg(any(
360                    feature = "hyper-rustls-native-roots",
361                    feature = "hyper-rustls-webpki-roots",
362                    feature = "native-tls"
363                ))]
364                None => {
365                    let transport = launchdarkly_sdk_transport::HyperTransport::new_https()
366                        .map_err(|e| {
367                            BuildError::InvalidConfig(format!(
368                                "failed to create default transport: {}",
369                                e
370                            ))
371                        })?;
372                    let mut builder = EventProcessorBuilder::new();
373                    builder.transport(transport);
374                    Ok(Box::new(builder))
375                }
376                #[cfg(not(any(
377                    feature = "hyper-rustls-native-roots",
378                    feature = "hyper-rustls-webpki-roots",
379                    feature = "native-tls"
380                )))]
381                None => Err(BuildError::InvalidConfig(
382                    "event processor factory required when hyper-rustls-native-roots, hyper-rustls-webpki-roots, or native-tls features are disabled".into(),
383                )),
384            };
385        let event_processor_builder = event_processor_builder_result?;
386
387        let application_tag = match self.application_info {
388            Some(tb) => tb.build(),
389            _ => None,
390        };
391
392        // Per SCMP-server-connection-minutes-polling, every polling request must carry a
393        // per-SDK-instance v4 UUID. We generate it once here, store it on Config, and pass it
394        // into the data source, feature requester, and event processor so that streaming,
395        // polling, and event requests all carry the same stable identifier for the lifetime
396        // of this client.
397        let instance_id = uuid::Uuid::new_v4().to_string();
398
399        Ok(Config {
400            sdk_key: self.sdk_key,
401            service_endpoints_builder,
402            data_store_builder,
403            data_source_builder,
404            event_processor_builder,
405            application_tag,
406            instance_id,
407            offline: self.offline,
408            daemon_mode: self.daemon_mode,
409        })
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use test_case::test_case;
416
417    use super::*;
418
419    #[test]
420    fn client_configured_with_custom_endpoints() {
421        let builder = ConfigBuilder::new("sdk-key").service_endpoints(
422            ServiceEndpointsBuilder::new().relay_proxy("http://my-relay-hostname:8080"),
423        );
424
425        let endpoints = builder.service_endpoints_builder.unwrap().build().unwrap();
426        assert_eq!(
427            endpoints.streaming_base_url(),
428            "http://my-relay-hostname:8080"
429        );
430        assert_eq!(
431            endpoints.polling_base_url(),
432            "http://my-relay-hostname:8080"
433        );
434        assert_eq!(endpoints.events_base_url(), "http://my-relay-hostname:8080");
435    }
436
437    #[test]
438    #[cfg(any(
439        feature = "hyper-rustls-native-roots",
440        feature = "hyper-rustls-webpki-roots",
441        feature = "native-tls"
442    ))]
443    fn unconfigured_config_builder_handles_application_tags_correctly() {
444        let builder = ConfigBuilder::new("sdk-key");
445        let config = builder.build().expect("config should build");
446
447        assert_eq!(None, config.application_tag);
448    }
449
450    #[test]
451    #[cfg(any(
452        feature = "hyper-rustls-native-roots",
453        feature = "hyper-rustls-webpki-roots",
454        feature = "native-tls"
455    ))]
456    fn instance_id_is_a_uuid_v4() {
457        let config = ConfigBuilder::new("sdk-key")
458            .build()
459            .expect("config should build");
460
461        let parsed = uuid::Uuid::parse_str(config.instance_id())
462            .expect("instance id should be a parseable UUID");
463        assert_eq!(
464            uuid::Version::Random,
465            parsed.get_version().expect("uuid should have a version"),
466            "instance id must be UUID v4"
467        );
468    }
469
470    #[test]
471    #[cfg(any(
472        feature = "hyper-rustls-native-roots",
473        feature = "hyper-rustls-webpki-roots",
474        feature = "native-tls"
475    ))]
476    fn instance_id_is_unique_per_config() {
477        // Each call to ConfigBuilder::build represents a new SDK instance; each must get its own
478        // GUID so connection-minutes accounting on the server side can distinguish them.
479        let c1 = ConfigBuilder::new("sdk-key")
480            .build()
481            .expect("config should build");
482        let c2 = ConfigBuilder::new("sdk-key")
483            .build()
484            .expect("config should build");
485        assert!(!c1.instance_id().is_empty());
486        assert!(!c2.instance_id().is_empty());
487        assert_ne!(
488            c1.instance_id(),
489            c2.instance_id(),
490            "each SDK instance should generate its own instance id"
491        );
492    }
493
494    #[test_case("id", "version", Some("application-id/id application-version/version".to_string()))]
495    #[test_case("Invalid id", "version", Some("application-version/version".to_string()))]
496    #[test_case("id", "Invalid version", Some("application-id/id".to_string()))]
497    #[test_case("Invalid id", "Invalid version", None)]
498    #[cfg(any(
499        feature = "hyper-rustls-native-roots",
500        feature = "hyper-rustls-webpki-roots",
501        feature = "native-tls"
502    ))]
503    fn config_builder_handles_application_tags_appropriately(
504        id: impl Into<String>,
505        version: impl Into<String>,
506        expected: Option<String>,
507    ) {
508        let mut application_info = ApplicationInfo::new();
509        application_info
510            .application_identifier(id)
511            .application_version(version);
512        let builder = ConfigBuilder::new("sdk-key");
513        let config = builder
514            .application_info(application_info)
515            .build()
516            .expect("config should build");
517
518        assert_eq!(expected, config.application_tag);
519    }
520
521    #[test_case("", "abc", Err("Key was empty or contained invalid characters"); "Empty key")]
522    #[test_case(" ", "abc", Err("Key was empty or contained invalid characters"); "Key with whitespace")]
523    #[test_case("/", "abc", Err("Key was empty or contained invalid characters"); "Key with slash")]
524    #[test_case(":", "abc", Err("Key was empty or contained invalid characters"); "Key with colon")]
525    #[test_case("🦀", "abc", Err("Key was empty or contained invalid characters"); "Key with emoji")]
526    #[test_case("abcABC123.-_", "abc", Ok(()); "Valid key")]
527    #[test_case("abc", "", Err("Value was empty or contained invalid characters"); "Empty value")]
528    #[test_case("abc", " ", Err("Value was empty or contained invalid characters"); "Value with whitespace")]
529    #[test_case("abc", "/", Err("Value was empty or contained invalid characters"); "Value with slash")]
530    #[test_case("abc", ":", Err("Value was empty or contained invalid characters"); "Value with colon")]
531    #[test_case("abc", "🦀", Err("Value was empty or contained invalid characters"); "Value with emoji")]
532    #[test_case("abc", "abcABC123.-_", Ok(()); "Valid value")]
533    #[test_case("abc", "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijkl", Ok(()); "64 is the max length")]
534    #[test_case("abc", "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklm", Err("Value was longer than 64 characters and was discarded"); "65 is too far")]
535    fn tag_can_determine_valid_values(key: &str, value: &str, expected_result: Result<(), &str>) {
536        let tag = Tag {
537            key: key.to_string(),
538            value: value.to_string(),
539        };
540        assert_eq!(expected_result, tag.is_valid());
541    }
542
543    #[test_case(vec![], None; "No tags returns None")]
544    #[test_case(vec![("application-id".into(), "gonfalon-be".into()), ("application-sha".into(), "abcdef".into())], Some("application-id/gonfalon-be application-sha/abcdef".into()); "Tags are formatted correctly")]
545    #[test_case(vec![("key".into(), "xyz".into()), ("key".into(), "abc".into())], Some("key/abc key/xyz".into()); "Keys are ordered correctly")]
546    #[test_case(vec![("key".into(), "abc".into()), ("key".into(), "abc".into())], Some("key/abc".into()); "Tags are deduped")]
547    #[test_case(vec![("XYZ".into(), "xyz".into()), ("abc".into(), "abc".into())], Some("XYZ/xyz abc/abc".into()); "Keys are ascii sorted correctly")]
548    #[test_case(vec![("abc".into(), "XYZ".into()), ("abc".into(), "abc".into())], Some("abc/XYZ abc/abc".into()); "Values are ascii sorted correctly")]
549    #[test_case(vec![("".into(), "XYZ".into()), ("abc".into(), "xyz".into())], Some("abc/xyz".into()); "Invalid tags are filtered")]
550    #[test_case(Vec::new(), None; "Empty tags returns None")]
551    fn application_tag_builder_can_create_tag_string_correctly(
552        tags: Vec<(String, String)>,
553        expected_value: Option<String>,
554    ) {
555        let mut application_info = ApplicationInfo::new();
556
557        tags.into_iter().for_each(|(key, value)| {
558            application_info.add_tag(key, value);
559        });
560
561        assert_eq!(expected_value, application_info.build());
562    }
563}