From 1fedf6c3984f5165bed6bcef8cee152a24a3fccd Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Fri, 7 Jun 2024 09:33:28 -0500 Subject: [PATCH] chore: moving dotnet-client-sdk files into dotnet-core repo --- pkgs/sdk/client/.gitignore | 49 + pkgs/sdk/client/.sdk_metadata.json | 12 + pkgs/sdk/client/CHANGELOG.md | 246 +++++ pkgs/sdk/client/CODEOWNERS | 2 + pkgs/sdk/client/CONTRIBUTING.md | 66 ++ pkgs/sdk/client/LaunchDarkly.ClientSdk.sln | 100 ++ pkgs/sdk/client/LaunchDarkly.pk | Bin 0 -> 160 bytes pkgs/sdk/client/License.txt | 13 + pkgs/sdk/client/Makefile | 28 + pkgs/sdk/client/README.md | 62 ++ pkgs/sdk/client/SECURITY.md | 5 + pkgs/sdk/client/contract-tests/README.md | 9 + .../client/contract-tests/Representations.cs | 159 +++ .../client/contract-tests/SdkClientEntity.cs | 377 +++++++ pkgs/sdk/client/contract-tests/TestService.cs | 137 +++ .../client/contract-tests/TestService.csproj | 26 + .../sdk/client/contract-tests/TestService.sln | 31 + pkgs/sdk/client/docfx.json | 46 + pkgs/sdk/client/index.md | 11 + .../src/LaunchDarkly.ClientSdk/Components.cs | 340 +++++++ .../LaunchDarkly.ClientSdk/Configuration.cs | 218 ++++ .../ConfigurationBuilder.cs | 456 +++++++++ .../src/LaunchDarkly.ClientSdk/DataModel.cs | 173 ++++ .../ILdClientExtensions.cs | 130 +++ .../Integrations/EventProcessorBuilder.cs | 235 +++++ .../Integrations/HttpConfigurationBuilder.cs | 289 ++++++ .../LoggingConfigurationBuilder.cs | 202 ++++ .../PersistenceConfigurationBuilder.cs | 101 ++ .../Integrations/PollingDataSourceBuilder.cs | 132 +++ .../Integrations/ServiceEndpointsBuilder.cs | 263 +++++ .../StreamingDataSourceBuilder.cs | 144 +++ .../Integrations/TestData.cs | 690 +++++++++++++ .../Interfaces/DataSourceStatus.cs | 344 +++++++ .../Interfaces/IDataSourceStatusProvider.cs | 121 +++ .../Interfaces/IFlagTracker.cs | 124 +++ .../Interfaces/ILdClient.cs | 427 ++++++++ .../Interfaces/ServiceEndpoints.cs | 29 + .../Internal/AnonymousKeyContextDecorator.cs | 72 ++ .../Internal/AutoEnvContextDecorator.cs | 235 +++++ .../LaunchDarkly.ClientSdk/Internal/Base64.cs | 22 + .../Internal/ComponentsImpl.cs | 54 + .../Internal/Constants.cs | 16 + .../Internal/DataModelSerialization.cs | 114 +++ .../Internal/DataSources/ConnectionManager.cs | 337 ++++++ .../DataSourceStatusProviderImpl.cs | 32 + .../DataSources/DataSourceUpdateSinkImpl.cs | 198 ++++ .../DefaultBackgroundModeManager.cs | 21 + .../DefaultConnectivityStateManager.cs | 38 + .../DataSources/FeatureFlagRequestor.cs | 147 +++ .../Internal/DataSources/PollingDataSource.cs | 139 +++ .../DataSources/StreamingDataSource.cs | 308 ++++++ .../Internal/DataStores/ContextIndex.cs | 129 +++ .../Internal/DataStores/FlagDataManager.cs | 186 ++++ .../DataStores/NullPersistentDataStore.cs | 23 + .../DataStores/PersistenceConfiguration.cs | 19 + .../DataStores/PersistentDataStoreWrapper.cs | 143 +++ .../Internal/Events/ClientDiagnosticStore.cs | 54 + .../Events/DefaultEventProcessorWrapper.cs | 66 ++ .../Internal/Events/DiagnosticDisablerImpl.cs | 36 + .../Internal/Events/EventFactory.cs | 87 ++ .../Internal/Factory.cs | 14 + .../Internal/FlagTrackerImpl.cs | 24 + .../Interfaces/IBackgroundModeManager.cs | 10 + .../Interfaces/IConnectivityStateManager.cs | 10 + .../Internal/JsonUtils.cs | 24 + .../Internal/LockUtils.cs | 47 + .../Internal/LogNames.cs | 14 + .../Internal/SdkPackage.cs | 58 ++ .../Internal/StandardEndpoints.cs | 51 + .../LaunchDarkly.ClientSdk.csproj | 127 +++ .../src/LaunchDarkly.ClientSdk/LdClient.cs | 958 ++++++++++++++++++ .../PlatformSpecific/AppInfo.maui.cs | 14 + .../PlatformSpecific/AppInfo.netstandard.cs | 7 + .../AsyncScheduler.android.cs | 19 + .../PlatformSpecific/AsyncScheduler.ios.cs | 13 + .../AsyncScheduler.netstandard.cs | 13 + .../PlatformSpecific/AsyncScheduler.shared.cs | 17 + .../BackgroundDetection.android.cs | 58 ++ .../BackgroundDetection.ios.cs | 34 + .../BackgroundDetection.netstandard.cs | 19 + .../BackgroundDetection.shared.cs | 58 ++ .../PlatformSpecific/Connectivity.maui.cs | 64 ++ .../Connectivity.netstandard.cs | 29 + .../PlatformSpecific/Connectivity.shared.cs | 49 + .../PlatformSpecific/DeviceInfo.maui.cs | 48 + .../DeviceInfo.netstandard.cs | 11 + .../PlatformSpecific/Http.android.cs | 30 + .../PlatformSpecific/Http.ios.cs | 32 + .../PlatformSpecific/Http.netstandard.cs | 18 + .../PlatformSpecific/Http.shared.cs | 43 + .../PlatformSpecific/LocalStorage.android.cs | 43 + .../PlatformSpecific/LocalStorage.ios.cs | 56 + .../LocalStorage.netstandard.cs | 89 ++ .../PlatformSpecific/LocalStorage.shared.cs | 17 + .../PlatformSpecific/Logging.android.cs | 121 +++ .../PlatformSpecific/Logging.ios.cs | 84 ++ .../PlatformSpecific/Logging.netstandard.cs | 9 + .../PlatformSpecific/Logging.shared.cs | 9 + .../Properties/AssemblyInfo.cs | 11 + .../Subsystems/DataStoreTypes.cs | 94 ++ .../Subsystems/EventProcessorTypes.cs | 125 +++ .../Subsystems/HttpConfiguration.cs | 188 ++++ .../Subsystems/IComponentConfigurer.cs | 20 + .../Subsystems/IDataSource.cs | 33 + .../Subsystems/IDataSourceUpdateSink.cs | 68 ++ .../Subsystems/IDiagnosticDescription.cs | 30 + .../Subsystems/IEventProcessor.cs | 73 ++ .../Subsystems/IPersistentDataStore.cs | 75 ++ .../Subsystems/LdClientContext.cs | 259 +++++ .../Subsystems/LoggingConfiguration.cs | 37 + .../Subsystems/PlatformAttributes.cs | 21 + .../Subsystems/SdkAttributes.cs | 16 + ...LaunchDarkly.ClientSdk.Device.Tests.csproj | 78 ++ .../LaunchDarkly.ClientSdk.Device.Tests.sln | 25 + .../LdClientContextTests.cs | 56 + .../MauiProgram.cs | 20 + .../Platforms/Android/AndroidManifest.xml | 6 + .../Platforms/Android/AndroidSpecificTests.cs | 34 + .../Platforms/Android/MainActivity.cs | 11 + .../Platforms/Android/MainApplication.cs | 16 + .../Android/Resources/values/colors.xml | 6 + .../Platforms/MacCatalyst/AppDelegate.cs | 10 + .../Platforms/MacCatalyst/Info.plist | 30 + .../Platforms/MacCatalyst/Program.cs | 16 + .../Platforms/Tizen/Main.cs | 17 + .../Platforms/Tizen/tizen-manifest.xml | 15 + .../Platforms/Windows/App.xaml | 8 + .../Platforms/Windows/App.xaml.cs | 25 + .../Platforms/Windows/Package.appxmanifest | 46 + .../Platforms/Windows/app.manifest | 15 + .../Platforms/iOS/AppDelegate.cs | 10 + .../Platforms/iOS/IOsSpecificTests.cs | 34 + .../Platforms/iOS/Info.plist | 32 + .../Platforms/iOS/Program.cs | 16 + .../Resources/AppIcon/appicon.svg | 4 + .../Resources/AppIcon/appiconfg.svg | 8 + .../Resources/Fonts/OpenSans-Regular.ttf | Bin 0 -> 107168 bytes .../Resources/Fonts/OpenSans-Semibold.ttf | Bin 0 -> 111060 bytes .../Resources/Images/dotnet_bot.svg | 93 ++ .../Resources/Raw/AboutAssets.txt | 15 + .../Resources/Splash/splash.svg | 8 + .../Resources/Styles/Colors.xaml | 44 + .../Resources/Styles/Styles.xaml | 405 ++++++++ .../AssertHelpers.cs | 52 + .../LaunchDarkly.ClientSdk.Tests/BaseTest.cs | 56 + .../ConfigurationTest.cs | 148 +++ .../ILdClientExtensionsTest.cs | 187 ++++ .../Integrations/EventProcessorBuilderTest.cs | 69 ++ .../HttpConfigurationBuilderTest.cs | 124 +++ .../LoggingConfigurationBuilderTest.cs | 74 ++ .../PollingDataSourceBuilderTest.cs | 33 + .../ServiceEndpointsBuilderTest.cs | 82 ++ .../StreamingDataSourceBuilderTest.cs | 29 + .../Integrations/TestDataTest.cs | 252 +++++ .../Integrations/TestDataWithClientTest.cs | 116 +++ .../AnonymousKeyContextDecoratorTest.cs | 178 ++++ .../Internal/AutoEnvContextDecoratorTest.cs | 142 +++ .../Internal/Base64Test.cs | 22 + .../Internal/DataModelSerializationTest.cs | 97 ++ .../DataSourceUpdateSinkImplTest.cs | 258 +++++ .../DataSources/FeatureFlagRequestorTests.cs | 122 +++ .../DataSources/PollingDataSourceTest.cs | 275 +++++ .../DataSources/StreamingDataSourceTest.cs | 350 +++++++ .../Internal/DataStores/ContextIndexTest.cs | 116 +++ .../DataStores/FlagDataManagerTest.cs | 205 ++++ .../FlagDataManagerWithPersistenceTest.cs | 253 +++++ .../PersistentDataStoreWrapperTest.cs | 118 +++ .../DataStores/PlatformLocalStorageTest.cs | 100 ++ .../LDClientEndToEndTests.cs | 600 +++++++++++ .../LaunchDarkly.ClientSdk.Tests.csproj | 33 + .../LdClientDataSourceStatusTests.cs | 280 +++++ .../LdClientDiagnosticEventTest.cs | 400 ++++++++ .../LdClientEvaluationTests.cs | 294 ++++++ .../LdClientEventTests.cs | 343 +++++++ .../LdClientListenersTest.cs | 75 ++ .../LdClientServiceEndpointsTests.cs | 195 ++++ .../LdClientTests.cs | 605 +++++++++++ .../MockComponents.cs | 422 ++++++++ .../MockResponses.cs | 63 ++ .../ModelBuilders.cs | 128 +++ .../TestHttpUtils.cs | 195 ++++ .../TestLogging.cs | 40 + .../LaunchDarkly.ClientSdk.Tests/TestUtil.cs | 165 +++ .../xunit-to-junit.xslt | 65 ++ pkgs/sdk/client/toc.yml | 2 + 185 files changed, 20130 insertions(+) create mode 100644 pkgs/sdk/client/.gitignore create mode 100644 pkgs/sdk/client/.sdk_metadata.json create mode 100644 pkgs/sdk/client/CHANGELOG.md create mode 100644 pkgs/sdk/client/CODEOWNERS create mode 100644 pkgs/sdk/client/CONTRIBUTING.md create mode 100644 pkgs/sdk/client/LaunchDarkly.ClientSdk.sln create mode 100644 pkgs/sdk/client/LaunchDarkly.pk create mode 100644 pkgs/sdk/client/License.txt create mode 100644 pkgs/sdk/client/Makefile create mode 100644 pkgs/sdk/client/README.md create mode 100644 pkgs/sdk/client/SECURITY.md create mode 100644 pkgs/sdk/client/contract-tests/README.md create mode 100644 pkgs/sdk/client/contract-tests/Representations.cs create mode 100644 pkgs/sdk/client/contract-tests/SdkClientEntity.cs create mode 100644 pkgs/sdk/client/contract-tests/TestService.cs create mode 100644 pkgs/sdk/client/contract-tests/TestService.csproj create mode 100644 pkgs/sdk/client/contract-tests/TestService.sln create mode 100644 pkgs/sdk/client/docfx.json create mode 100644 pkgs/sdk/client/index.md create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Components.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Configuration.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/ConfigurationBuilder.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/DataModel.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/ILdClientExtensions.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/EventProcessorBuilder.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/HttpConfigurationBuilder.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/LoggingConfigurationBuilder.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/PersistenceConfigurationBuilder.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/PollingDataSourceBuilder.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/ServiceEndpointsBuilder.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/StreamingDataSourceBuilder.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/TestData.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Interfaces/DataSourceStatus.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Interfaces/IDataSourceStatusProvider.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Interfaces/IFlagTracker.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Interfaces/ILdClient.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Interfaces/ServiceEndpoints.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/AnonymousKeyContextDecorator.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/AutoEnvContextDecorator.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Base64.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/ComponentsImpl.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Constants.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataModelSerialization.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/ConnectionManager.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/DataSourceStatusProviderImpl.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/DataSourceUpdateSinkImpl.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/DefaultBackgroundModeManager.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/DefaultConnectivityStateManager.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/FeatureFlagRequestor.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/PollingDataSource.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/StreamingDataSource.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataStores/ContextIndex.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataStores/FlagDataManager.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataStores/NullPersistentDataStore.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataStores/PersistenceConfiguration.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataStores/PersistentDataStoreWrapper.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Events/ClientDiagnosticStore.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Events/DefaultEventProcessorWrapper.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Events/DiagnosticDisablerImpl.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Events/EventFactory.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Factory.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/FlagTrackerImpl.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Interfaces/IBackgroundModeManager.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Interfaces/IConnectivityStateManager.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/JsonUtils.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/LockUtils.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/LogNames.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/SdkPackage.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/StandardEndpoints.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/LaunchDarkly.ClientSdk.csproj create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/LdClient.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/AppInfo.maui.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/AppInfo.netstandard.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/AsyncScheduler.android.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/AsyncScheduler.ios.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/AsyncScheduler.netstandard.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/AsyncScheduler.shared.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/BackgroundDetection.android.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/BackgroundDetection.ios.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/BackgroundDetection.netstandard.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/BackgroundDetection.shared.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Connectivity.maui.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Connectivity.netstandard.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Connectivity.shared.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/DeviceInfo.maui.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/DeviceInfo.netstandard.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Http.android.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Http.ios.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Http.netstandard.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Http.shared.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/LocalStorage.android.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/LocalStorage.ios.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/LocalStorage.netstandard.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/LocalStorage.shared.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Logging.android.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Logging.ios.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Logging.netstandard.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Logging.shared.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Properties/AssemblyInfo.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/DataStoreTypes.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/EventProcessorTypes.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/HttpConfiguration.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/IComponentConfigurer.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/IDataSource.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/IDataSourceUpdateSink.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/IDiagnosticDescription.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/IEventProcessor.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/IPersistentDataStore.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/LdClientContext.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/LoggingConfiguration.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/PlatformAttributes.cs create mode 100644 pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/SdkAttributes.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/LaunchDarkly.ClientSdk.Device.Tests.csproj create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/LaunchDarkly.ClientSdk.Device.Tests.sln create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/LdClientContextTests.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/MauiProgram.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Android/AndroidManifest.xml create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Android/AndroidSpecificTests.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Android/MainActivity.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Android/MainApplication.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Android/Resources/values/colors.xml create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/MacCatalyst/AppDelegate.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/MacCatalyst/Info.plist create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/MacCatalyst/Program.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Tizen/Main.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Tizen/tizen-manifest.xml create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Windows/App.xaml create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Windows/App.xaml.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Windows/Package.appxmanifest create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Windows/app.manifest create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/iOS/AppDelegate.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/iOS/IOsSpecificTests.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/iOS/Info.plist create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/iOS/Program.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/AppIcon/appicon.svg create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/AppIcon/appiconfg.svg create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/Fonts/OpenSans-Regular.ttf create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/Fonts/OpenSans-Semibold.ttf create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/Images/dotnet_bot.svg create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/Raw/AboutAssets.txt create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/Splash/splash.svg create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/Styles/Colors.xaml create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/Styles/Styles.xaml create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/AssertHelpers.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/BaseTest.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/ConfigurationTest.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/ILdClientExtensionsTest.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/EventProcessorBuilderTest.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/HttpConfigurationBuilderTest.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/LoggingConfigurationBuilderTest.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/PollingDataSourceBuilderTest.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/ServiceEndpointsBuilderTest.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/StreamingDataSourceBuilderTest.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/TestDataTest.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/TestDataWithClientTest.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/AnonymousKeyContextDecoratorTest.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/AutoEnvContextDecoratorTest.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/Base64Test.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataModelSerializationTest.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataSources/DataSourceUpdateSinkImplTest.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataSources/FeatureFlagRequestorTests.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataSources/PollingDataSourceTest.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataSources/StreamingDataSourceTest.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataStores/ContextIndexTest.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataStores/FlagDataManagerTest.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataStores/FlagDataManagerWithPersistenceTest.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataStores/PersistentDataStoreWrapperTest.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataStores/PlatformLocalStorageTest.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LDClientEndToEndTests.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LaunchDarkly.ClientSdk.Tests.csproj create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientDataSourceStatusTests.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientDiagnosticEventTest.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientEvaluationTests.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientEventTests.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientListenersTest.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientServiceEndpointsTests.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientTests.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/MockComponents.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/MockResponses.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/ModelBuilders.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/TestHttpUtils.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/TestLogging.cs create mode 100644 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/TestUtil.cs create mode 100755 pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/xunit-to-junit.xslt create mode 100644 pkgs/sdk/client/toc.yml diff --git a/pkgs/sdk/client/.gitignore b/pkgs/sdk/client/.gitignore new file mode 100644 index 00000000..f00bdc2d --- /dev/null +++ b/pkgs/sdk/client/.gitignore @@ -0,0 +1,49 @@ +# Autosave files +*~ + +# build +[Oo]bj/ +[Bb]in/ +packages/ +TestResults/ +test-packages/ + +# globs +Makefile.in +*.DS_Store +*.sln.cache +*.suo +*.cache +*.pidb +*.userprefs +*.usertasks +config.log +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.user +*.tar.gz +tarballs/ +test-results/ +Thumbs.db +.vs/ + +# Mac bundle stuff +*.dmg +*.app + +# resharper +*_Resharper.* +*.Resharper +.idea + +# dotCover +*.dotCover + +# private key file +*.snk + +docs/build/ +launchSettings.json diff --git a/pkgs/sdk/client/.sdk_metadata.json b/pkgs/sdk/client/.sdk_metadata.json new file mode 100644 index 00000000..d62cd8e8 --- /dev/null +++ b/pkgs/sdk/client/.sdk_metadata.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "sdks": { + "dotnet-client-sdk": { + "name": ".NET Client SDK", + "type": "client-side", + "languages": [ + "C#" + ] + } + } +} diff --git a/pkgs/sdk/client/CHANGELOG.md b/pkgs/sdk/client/CHANGELOG.md new file mode 100644 index 00000000..b8b474b1 --- /dev/null +++ b/pkgs/sdk/client/CHANGELOG.md @@ -0,0 +1,246 @@ +# Change log + +All notable changes to the LaunchDarkly Client-Side SDK for .NET will be documented in this file. +This project adheres to [Semantic Versioning](http://semver.org). + +## [5.2.1](https://github.com/launchdarkly/dotnet-client-sdk/compare/5.2.0...5.2.1) (2024-06-05) + + +### Bug Fixes + +* fixes issue where first flag listener callback was not triggered… ([#97](https://github.com/launchdarkly/dotnet-client-sdk/issues/97)) ([6bf8ec1](https://github.com/launchdarkly/dotnet-client-sdk/commit/6bf8ec160ee29984928bb7320ddd1f6f8580d7a9)) + +## [5.2.0](https://github.com/launchdarkly/dotnet-client-sdk/compare/5.1.0...5.2.0) (2024-05-08) + + +### Features + +* adds init async with timeout and deprecated non-timeout init functions ([#95](https://github.com/launchdarkly/dotnet-client-sdk/issues/95)) ([41e70f2](https://github.com/launchdarkly/dotnet-client-sdk/commit/41e70f2c49e864da13648bd85c2c427111e502cc)) + +## [5.1.0](https://github.com/launchdarkly/dotnet-client-sdk/compare/5.0.0...5.1.0) (2024-03-14) + + +### Features + +* Always inline contexts for feature events ([c658bee](https://github.com/launchdarkly/dotnet-client-sdk/commit/c658beee27cd871c8ad91942ac5a04b29b8338bd)) +* Redact anonymous attributes within feature events ([c658bee](https://github.com/launchdarkly/dotnet-client-sdk/commit/c658beee27cd871c8ad91942ac5a04b29b8338bd)) + + +### Bug Fixes + +* Bump LaunchDarkly.InternalSdk to 3.4.0 ([#91](https://github.com/launchdarkly/dotnet-client-sdk/issues/91)) ([c658bee](https://github.com/launchdarkly/dotnet-client-sdk/commit/c658beee27cd871c8ad91942ac5a04b29b8338bd)) + +## [5.0.0](https://github.com/launchdarkly/dotnet-client-sdk/compare/4.0.0...5.0.0) (2024-02-13) + + +### Features + +* adds MAUI support ([d01a865](https://github.com/launchdarkly/dotnet-client-sdk/commit/d01a865aa83c6cc699c3d2ff528ce256f169ecdc)) +* adds MAUI support ([#66](https://github.com/launchdarkly/dotnet-client-sdk/issues/66)) ([112c2fb](https://github.com/launchdarkly/dotnet-client-sdk/commit/112c2fb7d54c31d88c3a1ffdd9aec88911f149de)) + + +### Bug Fixes + +* updating deprecated AndroidClientHandler to AndroidMessageHandler ([973b38c](https://github.com/launchdarkly/dotnet-client-sdk/commit/973b38ccd59a232bf47384b20d8d8bbda6017a6e)) +* updating deprecated AndroidClientHandler to AndroidMessageHandler ([#69](https://github.com/launchdarkly/dotnet-client-sdk/issues/69)) ([3dc9dba](https://github.com/launchdarkly/dotnet-client-sdk/commit/3dc9dbaac918555691281322ea15ea94bbe29e5a)) + + +### Miscellaneous Chores + +* release 5.0.0 ([#83](https://github.com/launchdarkly/dotnet-client-sdk/issues/83)) ([de859bc](https://github.com/launchdarkly/dotnet-client-sdk/commit/de859bc63555488a6361df3a3e28cdf253df3b45)) + +## [4.0.0] - 2023-10-18 +### Added: +- Added Automatic Mobile Environment Attributes functionality which makes it simpler to target your mobile customers based on application name or version, or on device characteristics including manufacturer, model, operating system, locale, and so on. To learn more, read [Automatic environment attributes](https://docs.launchdarkly.com/sdk/features/environment-attributes). + +## [3.1.0] - 2023-10-11 +### Added: +- `Configuration.Builder("myKey").ApplicationInfo()`, for configuration of application metadata that may be used in LaunchDarkly analytics or other product features. + +## [3.0.2] - 2023-04-04 +When using multi-contexts, then this update can change the `FullyQualifiedKey` for a given context. This can cause a cache miss in the local cache for a given context, requiring a connection to LaunchDarkly to populate that cache for the new `FullyQualifiedKey`. + +### Fixed: +- Fixed an issue with generating the FullyQualifiedKey. The key generation was not sorted by the kind, so the key was not stable depending on the order of the context construction. This affects how flags are locally cached, as they are cached by the FullyQualifiedKey. + +## [3.0.1] - 2023-03-08 +### Changed: +- Update to `LaunchDarkly.InternalSdk` `3.1.1` + +### Fixed: +- (From LaunchDarkly.InternalSdk) Fixed an issue where calling FlushAndWait with TimeSpan.Zero would never complete if there were no events to flush. + +## [3.0.0] - 2022-12-21 +The latest version of this SDK supports LaunchDarkly's new custom contexts feature. Contexts are an evolution of a previously-existing concept, "users." Contexts let you create targeting rules for feature flags based on a variety of different information, including attributes pertaining to users, organizations, devices, and more. You can even combine contexts to create "multi-contexts." + +For detailed information about this version, please refer to the list below. For information on how to upgrade from the previous version, please read the [migration guide](https://docs.launchdarkly.com/sdk/client-side/dotnet/migration-2-to-3). + +### Added: +- In `LaunchDarkly.Sdk`, the types `Context` and `ContextKind` define the new context model. +- For all SDK methods that took a `User` parameter, there is now a method that takes a `Context`. The corresponding `User` methods are defined as extension methods. The SDK still supports `User` for now, but `Context` is the preferred model and `User` may be removed in a future version. +- `ConfigurationBuilder.GenerateAnonymousKeys` is the new way of enabling the "generate a key for anonymous users" behavior that was previously enabled by setting the user key to null. If you set `GenerateAnonymousKeys` to `true`, all anonymous contexts will have their keys replaced by generated keys; if you do not set it, anonymous contexts will keep whatever placeholder keys you gave them. +- The `TestData` flag builder methods have been extended to support now context-related options, such as matching a key for a specific context type other than "user". +- `LdClient.FlushAndWait()` and `FlushAndWaitAsync()` are equivalent to `Flush()` but will wait for the events to actually be delivered. + +### Changed _(breaking changes from 2.x)_: +- It was previously allowable to set a user key to an empty string. In the new context model, the key is not allowed to be empty. Trying to use an empty key will cause evaluations to fail and return the default value. +- There is no longer such a thing as a `Secondary` meta-attribute that affects percentage rollouts. If you set an attribute with that name in a `Context`, it will simply be a custom attribute like any other. +- The `Anonymous` attribute in `User` is now a simple boolean, with no distinction between a false state and a null state. +- Types such as `IPersistentDataStore`, which define the low-level interfaces of LaunchDarkly SDK components and allow implementation of custom components, have been moved out of the `Interfaces` namespace into a new `Subsystems` namespace. Application code normally does not refer to these types except possibly to hold a value for a configuration property such as `ConfigurationBuilder.DataStore`, so this change is likely to only affect configuration-related logic. + +### Changed (behavioral changes): +- Analytics event data now uses a new JSON schema due to differences between the context model and the old user model. +- The SDK no longer adds `device` and `os` values to the user attributes. Applications that wish to use device/OS information in feature flag rules must explicitly add such information. + +### Changed (requirements/dependencies/build): +- There is no longer a dependency on `LaunchDarkly.JsonStream`. This package existed because some platforms did not support the `System.Text.Json` API, but that is no longer the case and the SDK now uses `System.Text.Json` directly for all of its JSON operations. +- If you are using the package `LaunchDarkly.CommonSdk.JsonNet` for interoperability with the Json.NET library, you must update this to the latest major version. + +### Removed: +- Removed all types, fields, and methods that were deprecated as of the most recent 2.x release. +- Removed the `Secondary` meta-attribute in `User` and `UserBuilder`. +- The `Alias` method no longer exists because alias events are not needed in the new context model. +- The `AutoAliasingOptOut` and `InlineUsersInEvents` options no longer exist because they are not relevant in the new context model. +- `LaunchDarkly.Sdk.Json.JsonException`: this type is no longer necessary because the SDK now always uses `System.Text.Json`, so any error when deserializing an object from JSON will throw a `System.Text.Json.JsonException`. + +## [2.0.2] - 2022-11-28 +### Fixed: +- One of the SDK's dependencies, `LaunchDarkly.Logging`, had an Authenticode signature without a timestamp. The dependency has been updated to a new version with a valid signature. There are no other changes. + +## [2.0.1] - 2022-02-08 +### Fixed: +- Analytics events generated by `LdClient.Alias` did not have correct timestamps, although this was unlikely to affect how LaunchDarkly processed them. +- The type `LaunchDarkly.Sdk.UnixMillisecondTime` now serializes and deserializes correctly with `System.Text.Json`. + +## [2.0.0] - 2022-01-07 +This is a major rewrite that introduces a cleaner API design, adds new features, and makes the SDK code easier to maintain and extend. See the [Xamarin 1.x to client-side .NET 2.0 migration guide](https://docs.launchdarkly.com/sdk/client-side/dotnet/migration-1-to-2) for an in-depth look at the changes in 2.0; the following is a summary. + +The LaunchDarkly client-side .NET SDK was formerly known as the LaunchDarkly Xamarin SDK. Xamarin for Android and iOS are _among_ its supported platforms, but it can also be used on any platform that supports .NET Core 2+, .NET Standard 2, or .NET 5+. On those platforms, it does not use any Xamarin-specific runtime libraries. To learn more about the distinction between the client-side .NET SDK and the server-side .NET SDK, read: [Client-side and server-side SDKs](https://docs.launchdarkly.com/sdk/concepts/client-side-server-side) + +### Added: +- `LdClient.FlagTracker` provides the ability to get notifications when flag values have changed. +- `LdClient.DataSourceStatusProvider` provides information on the status of the SDK's data source (which normally means the streaming connection to the LaunchDarkly service). +- `LdClient.DoubleVariation` and `DoubleVariationDetail` return a numeric flag variation using double-precision floating-point. +- `ConfigurationBuilder.ServiceEndpoints` allows you to override the regular service URIs— as you may want to do if you are using the LaunchDarkly Relay Proxy, for instance— in a single place. Previously, the URIs had to be specified individually for each service (`StreamingDataSource().BaseURI`, `SendEvents().BaseURI`, etc.). +- `HttpConfigurationBuilder.UseReport` tells the SDK to make HTTP `REPORT` requests rather than `GET` requests to the LaunchDarkly service endpoints, which may be desirable in rare circumstances but is not available on all platforms. +- `ConfigurationBuilder.Persistence` and `PersistenceConfigurationBuilder.MaxCachedUsers` allow setting a limit on how many users' flag data can be saved in persistent local storage, or turning off persistence. +- The `LaunchDarkly.Sdk.Json` namespace provides methods for converting types like `User` and `FeatureFlagsState` to and from JSON. +- The `LaunchDarkly.Sdk.UserAttribute` type provides a less error-prone way to refer to user attribute names in configuration, and can also be used to get an arbitrary attribute from a user. +- The `LaunchDarkly.Sdk.UnixMillisecondTime` type provides convenience methods for converting to and from the Unix epoch millisecond time format that LaunchDarkly uses for all timestamp values. +- The SDK now periodically sends diagnostic data to LaunchDarkly, describing the version and configuration of the SDK, the architecture and version of the runtime platform, and performance statistics. No credentials, hostnames, or other identifiable values are included. This behavior can be disabled with `ConfigurationBuilder.DiagnosticOptOut` or configured with `ConfigurationBuilder.DiagnosticRecordingInterval`. + +### Changed (requirements/dependencies/build): +- .NET Standard 1.6 is no longer supported. +- The SDK no longer has a dependency on `Common.Logging`. Instead, it uses a similar but simpler logging facade, the [`LaunchDarkly.Logging`](https://github.com/launchdarkly/dotnet-logging) package, which has adapters for various logging destinations. +- The SDK no longer has a dependency on the Json.NET library (a.k.a. `Newtonsoft.Json`), but instead uses a lightweight custom JSON serializer and deserializer. This removes the potential for dependency version conflicts in applications that use Json.NET for their own purposes, and reduces the number of dependencies in applications that do not use Json.NET. If you do use Json.NET and you want to use it with SDK data types like `User` and `LdValue`, see [`LaunchDarkly.CommonSdk.JsonNet`](https://github.com/launchdarkly/dotnet-sdk-common/tree/master/src/LaunchDarkly.CommonSdk.JsonNet). Those types also serialize/deserialize correctly with the `System.Text.Json` API on platforms where that API is available. + +### Changed (API changes): +- The base namespace has changed: types that were previously in `LaunchDarkly.Client` are now in `LaunchDarkly.Sdk`, and types that were previously in `LaunchDarkly.Xamarin` are now in `LaunchDarkly.Sdk.Client`. The `LaunchDarkly.Sdk` namespace contains types that are not specific to the _client-side_ .NET SDK (that is, they are also used by the server-side .NET SDK): `EvaluationDetail`, `LdValue`, `User`, and `UserBuilder`. Types that are specific to the client-side .NET SDK, such as `Configuration` and `LdClient`, are in `LaunchDarkly.Sdk.Client`. +- `User` and `Configuration` objects are now immutable. To specify properties for these classes, you must now use `User.Builder` and `Configuration.Builder`. +- `Configuration.Builder` now returns a concrete type rather than an interface. +- `EvaluationDetail` is now a struct type rather than a class. +- `EvaluationReason` is now a single struct type rather than a base class with subclasses. +- `EvaluationReasonKind` and `EvaluationErrorKind` constants now use .Net-style naming (`RuleMatch`) rather than Java-style naming (`RULE_MATCH`). Their JSON representations are unchanged. +- The `ILdClient` interface is now in `LaunchDarkly.Sdk.Client.Interfaces` instead of the main namespace. +- The `ILdClientExtensions` methods `EnumVariation` and `EnumVariationDetail` now have type constraints to enforce that `T` really is an `enum` type. + +### Changed (behavioral changes): +- The default event flush interval is now 30 seconds on mobile platforms, instead of 5 seconds. This is consistent with the other mobile SDKs and is intended to reduce network traffic. +- Logging now uses a simpler, more stable set of logger names instead of using the names of specific implementation classes that are subject to change. General messages are logged under `LaunchDarkly.Sdk`, while messages about specific areas of functionality are logged under that name plus `.DataSource` (streaming, polling, file data, etc.), `.DataStore` (database integrations), `.Evaluation` (unexpected errors during flag evaluations), or `.Events` (analytics event processing). + +### Removed: +- All types and methods that were deprecated as of the last 1.x release have been removed. + +## [2.0.0-rc.1] - 2021-11-19 +This is the first release candidate version of the LaunchDarkly client-side .NET SDK 2.0-- a major rewrite that introduces a cleaner API design, adds new features, and makes the SDK code easier to maintain and extend. See the [Xamarin 1.x to client-side .NET 2.0 migration guide](https://docs.launchdarkly.com/sdk/client-side/dotnet/migration-1-to-2) for an in-depth look at the changes in 2.0; the following is a summary. + +The LaunchDarkly client-side .NET SDK was formerly known as the LaunchDarkly Xamarin SDK. Xamarin for Android and iOS are _among_ its supported platforms, but it can also be used on any platform that supports .NET Core 2+, .NET Standard 2, or .NET 5+. On those platforms, it does not use any Xamarin-specific runtime libraries. To learn more about the distinction between the client-side .NET SDK and the server-side .NET SDK, read: [Client-side and server-side SDKs](https://docs.launchdarkly.com/sdk/concepts/client-side-server-side) + +### Added: +- `LdClient.FlagTracker` provides the ability to get notifications when flag values have changed. +- `LdClient.DataSourceStatusProvider` provides information on the status of the SDK's data source (which normally means the streaming connection to the LaunchDarkly service). +- `LdClient.DoubleVariation` and `DoubleVariationDetail` return a numeric flag variation using double-precision floating-point. +- `HttpConfigurationBuilder.UseReport` tells the SDK to make HTTP `REPORT` requests rather than `GET` requests to the LaunchDarkly service endpoints, which may be desirable in rare circumstances but is not available on all platforms. +- `ConfigurationBuilder.Persistence` and `PersistenceConfigurationBuilder.MaxCachedUsers` allow setting a limit on how many users' flag data can be saved in persistent local storage, or turning off persistence. +- The `LaunchDarkly.Sdk.Json` namespace provides methods for converting types like `User` and `FeatureFlagsState` to and from JSON. +- The `LaunchDarkly.Sdk.UserAttribute` type provides a less error-prone way to refer to user attribute names in configuration, and can also be used to get an arbitrary attribute from a user. +- The `LaunchDarkly.Sdk.UnixMillisecondTime` type provides convenience methods for converting to and from the Unix epoch millisecond time format that LaunchDarkly uses for all timestamp values. +- The SDK now periodically sends diagnostic data to LaunchDarkly, describing the version and configuration of the SDK, the architecture and version of the runtime platform, and performance statistics. No credentials, hostnames, or other identifiable values are included. This behavior can be disabled with `ConfigurationBuilder.DiagnosticOptOut` or configured with `ConfigurationBuilder.DiagnosticRecordingInterval`. + +### Changed (requirements/dependencies/build): +- .NET Standard 1.6 is no longer supported. +- The SDK no longer has a dependency on `Common.Logging`. Instead, it uses a similar but simpler logging facade, the [`LaunchDarkly.Logging`](https://github.com/launchdarkly/dotnet-logging) package, which has adapters for various logging destinations. +- The SDK no longer has a dependency on the Json.NET library (a.k.a. `Newtonsoft.Json`), but instead uses a lightweight custom JSON serializer and deserializer. This removes the potential for dependency version conflicts in applications that use Json.NET for their own purposes, and reduces the number of dependencies in applications that do not use Json.NET. If you do use Json.NET and you want to use it with SDK data types like `User` and `LdValue`, see [`LaunchDarkly.CommonSdk.JsonNet`](https://github.com/launchdarkly/dotnet-sdk-common/tree/master/src/LaunchDarkly.CommonSdk.JsonNet). Those types also serialize/deserialize correctly with the `System.Text.Json` API on platforms where that API is available. + +### Changed (API changes): +- The base namespace has changed: types that were previously in `LaunchDarkly.Client` are now in `LaunchDarkly.Sdk`, and types that were previously in `LaunchDarkly.Xamarin` are now in `LaunchDarkly.Sdk.Client`. The `LaunchDarkly.Sdk` namespace contains types that are not specific to the _client-side_ .NET SDK (that is, they are also used by the server-side .NET SDK): `EvaluationDetail`, `LdValue`, `User`, and `UserBuilder`. Types that are specific to the client-side .NET SDK, such as `Configuration` and `LdClient`, are in `LaunchDarkly.Sdk.Client`. +- `User` and `Configuration` objects are now immutable. To specify properties for these classes, you must now use `User.Builder` and `Configuration.Builder`. +- `Configuration.Builder` now returns a concrete type rather than an interface. +- `EvaluationDetail` is now a struct type rather than a class. +- `EvaluationReason` is now a single struct type rather than a base class with subclasses. +- `EvaluationReasonKind` and `EvaluationErrorKind` constants now use .Net-style naming (`RuleMatch`) rather than Java-style naming (`RULE_MATCH`). Their JSON representations are unchanged. +- The `ILdClient` interface is now in `LaunchDarkly.Sdk.Client.Interfaces` instead of the main namespace. +- The `ILdClientExtensions` methods `EnumVariation` and `EnumVariationDetail` now have type constraints to enforce that `T` really is an `enum` type. + +### Changed (behavioral changes): +- The default event flush interval is now 30 seconds on mobile platforms, instead of 5 seconds. This is consistent with the other mobile SDKs and is intended to reduce network traffic. +- Logging now uses a simpler, more stable set of logger names instead of using the names of specific implementation classes that are subject to change. General messages are logged under `LaunchDarkly.Sdk.Xamarin.LdClient`, while messages about specific areas of functionality are logged under that name plus `.DataSource` (streaming, polling, file data, etc.), `.DataStore` (database integrations), `.Evaluation` (unexpected errors during flag evaluations), or `.Events` (analytics event processing). + +### Fixed: +- The SDK was deciding whether to send analytics events based on the `Offline` property of the _original_ SDK configuration, rather than whether the SDK is _currently_ in offline mode or not. + +### Removed: +- All types and methods that were shown as deprecated/`Obsolete` in the last 1.x release have been removed. + +## [1.2.2] - 2021-04-06 +### Fixed: +- The SDK was failing to get flags in streaming mode when connecting to a LaunchDarkly Relay Proxy instance. + +## [1.2.1] - 2021-03-30 +### Fixed: +- Removed unnecessary dependencies on `Xamarin.Android.Support.Core.Utils` and `Xamarin.Android.Support.CustomTabs`. (Thanks, [Vladimir-Mischenchuk](https://github.com/launchdarkly/xamarin-client-sdk/pull/25)!) +- Setting custom base URIs now works correctly even if the base URI includes a path prefix (such as you might use with a reverse proxy that rewrites request URIs). ([#26](https://github.com/launchdarkly/xamarin-client-sdk/issues/26)) +- Fixed the base64 encoding of user properties in request URIs to use the URL-safe variant of base64. + +## [1.2.0] - 2020-01-15 +### Added: +- Added `ILdClient` extension methods `EnumVariation` and `EnumVariationDetail`, which convert strings to enums. +- `User.Secondary`, `IUserBuilder.Secondary` (replaces `SecondaryKey`). +- `EvaluationReason` static methods and properties for creating reason instances. +- `LdValue` helpers for dealing with array/object values, without having to use an intermediate `List` or `Dictionary`: `BuildArray`, `BuildObject`, `Count`, `Get`. +- `LdValue.Parse()`. + +### Changed: +- `EvaluationReason` properties all exist on the base class now, so for instance you do not need to cast to `RuleMatch` to get the `RuleId` property. This is in preparation for a future API change in which `EvaluationReason` will become a struct instead of a base class. + +### Fixed: +- Calling `Identify` or `IdentifyAsync` with a user that has a null key and `Anonymous(true)`-- which should generate a unique key for the anonymous user-- did not work. The symptom was that the client would fail to retrieve the flags, and the call would either never complete (for `IdentifyAsync`) or time out (for `Identify`). This has been fixed. +- Improved memory usage and performance when processing analytics events: the SDK now encodes event data to JSON directly, instead of creating intermediate objects and serializing them via reflection. +- When parsing arbitrary JSON values, the SDK now always stores them internally as `LdValue` rather than `JToken`. This means that no additional copying step is required when the application accesses that value, if it is of a complex type. +- `LdValue.Equals()` incorrectly returned true for object (dictionary) values that were not equal. +- The SDK now specifies a uniquely identifiable request header when sending events to LaunchDarkly to ensure that events are only processed once, even if the SDK sends them two times due to a failed initial attempt. + +### Deprecated: +- `IUserBuilder.SecondaryKey`, `User.SecondaryKey`. +- `EvaluationReason` subclasses. Use only the base class properties and methods to ensure compatibility with future versions. + +## [1.1.1] - 2019-10-23 +### Fixed: +- The JSON serialization of `User` was producing an extra `Anonymous` property in addition to `anonymous`. If Newtonsoft.Json was configured globally to force all properties to lowercase, this would cause an exception when serializing a user since the two properties would end up with the same name. ([#22](https://github.com/launchdarkly/xamarin-client-sdk/issues/22)) + +## [1.1.0] - 2019-10-17 +### Added: +- Added support for upcoming LaunchDarkly experimentation features. See `ILdClient.Track(string, LdValue, double)`. +- `User.AnonymousOptional` and `IUserBuilder.AnonymousOptional` allow treating the `Anonymous` property as nullable (necessary for consistency with other SDKs). See note about this under Fixed. +- Added `LaunchDarkly.Logging.ConsoleAdapter` as a convenience for quickly enabling console logging; this is equivalent to `Common.Logging.Simple.ConsoleOutLoggerFactoryAdapter`, but the latter is not available on some platforms. + +### Fixed: +- `Configuration.Builder` was not setting a default value for the `BackgroundPollingInterval` property. As a result, if you did not set the property explicitly, the SDK would throw an error when the application went into the background on mobile platforms. +- `IUserBuilder` was incorrectly setting the user's `Anonymous` property to `null` even if it had been explicitly set to `false`. Null and false behave the same in terms of LaunchDarkly's user indexing behavior, but currently it is possible to create a feature flag rule that treats them differently. So `IUserBuilder.Anonymous(false)` now correctly sets it to `false`, just as the deprecated method `UserExtensions.WithAnonymous(false)` would. +- `LdValue.Convert.Long` was mistakenly converting to an `int` rather than a `long`. (CommonSdk [#32](https://github.com/launchdarkly/dotnet-sdk-common/issues/32)) + +## [1.0.0] - 2019-09-13 + +First GA release. + +For release notes on earlier beta versions, see the [beta changelog](https://github.com/launchdarkly/xamarin-client-sdk/blob/1.0.0-beta24/CHANGELOG.md). diff --git a/pkgs/sdk/client/CODEOWNERS b/pkgs/sdk/client/CODEOWNERS new file mode 100644 index 00000000..3c1c6e1d --- /dev/null +++ b/pkgs/sdk/client/CODEOWNERS @@ -0,0 +1,2 @@ +# Repository Maintainers +* @launchdarkly/team-sdk-net diff --git a/pkgs/sdk/client/CONTRIBUTING.md b/pkgs/sdk/client/CONTRIBUTING.md new file mode 100644 index 00000000..88c5dae7 --- /dev/null +++ b/pkgs/sdk/client/CONTRIBUTING.md @@ -0,0 +1,66 @@ +# Contributing to the LaunchDarkly Client-Side SDK for .NET + +LaunchDarkly has published an [SDK contributor's guide](https://docs.launchdarkly.com/docs/sdk-contributors-guide) that provides a detailed explanation of how our SDKs work. See below for additional information on how to contribute to this SDK. + +## Submitting bug reports and feature requests + +The LaunchDarkly SDK team monitors the [issue tracker](https://github.com/launchdarkly/dotnet-client-sdk/issues) in the SDK repository. Bug reports and feature requests specific to this SDK should be filed in this issue tracker. The SDK team will respond to all newly filed issues within two business days. + +## Submitting pull requests + +We encourage pull requests and other contributions from the community. Before submitting pull requests, ensure that all temporary or unintended code is removed. Don't worry about adding reviewers to the pull request; the LaunchDarkly SDK team will add themselves. The SDK team will acknowledge all pull requests within two business days. + +## Build instructions + +### Prerequisites + +The .NET Standard target requires only the .NET Core 2.1 SDK or higher. The iOS, Android, MacCatalys, and Windows targets require Net 7.0 or later. + +### Building + +To build the SDK (for all target platforms) without running any tests: + +``` +msbuild /restore src/LaunchDarkly.ClientSdk/LaunchDarkly.ClientSdk.csproj +``` + +Currently this command can only be run on MacOS, because that is the only platform that allows building for all of the targets (.NET Standard, Android, and iOS). + +To build the SDK for only one of the supported platforms, add `/p:TargetFramework=X` where `X` is one of the items in the `` list of `LaunchDarkly.XamarinSdk.csproj`: `netstandard2.0` for .NET Standard 2.0, `MonoAndroid81` for Android 8.1, etc.: + +``` +msbuild /restore /p:TargetFramework=netstandard2.0 src/LaunchDarkly.ClientSdk/LaunchDarkly.ClientSdk.csproj +``` + +Note that the main project, `src/LaunchDarkly.ClientSdk`, contains source files that are built for all platforms (ending in just `.cs`, or `.shared.cs`), and also a smaller amount of code that is conditionally compiled for platform-specific functionality. The latter is all in the `PlatformSpecific` folder. We use `#ifdef` directives only for small sections that differ slightly between platform versions; otherwise the conditional compilation is done according to filename suffix (`.android.cs`, etc.) based on rules in the `.csproj` file. + +### Testing + +The .NET Standard unit tests cover all of the non-platform-specific functionality, as well as behavior specific to .NET Standard (e.g. caching flags in the filesystem). They can be run with only the basic Xamarin framework installed, via the `dotnet` tool: + +``` +msbuild /p:TargetFramework=netstandard2.0 src/LaunchDarkly.ClientSdk +dotnet test tests/LaunchDarkly.ClientSdk.Tests/LaunchDarkly.ClientSdk.Tests.csproj +``` + +The equivalent test suites in Android or iOS must be run in an Android or iOS emulator. The projects `tests/LaunchDarkly.ClientSdk.Android.Tests` and `tests/LaunchDarkly.ClientSdk.iOS.Tests` consist of applications based on the `xunit.runner.devices` tool, which show the test results visually in the emulator and also write the results to the emulator's system log. The actual unit test code is just the same tests from the main `tests/LaunchDarkly.ClientSdk.Tests` project, but running them in this way exercises the mobile-specific behavior for those platforms (e.g. caching flags in user preferences). + +You can run the mobile test projects from Visual Studio (the iOS tests require MacOS); there is also a somewhat complicated process for running them from the command line, which is what the CI build does (see `.circleci/config.yml`). + +Note that the mobile unit tests currently do not cover background-mode behavior or connectivity detection. + +To run the SDK contract test suite, in Linux or MacOS (see [`contract-tests/README.md`](./contract-tests/README.md)): + +```bash +make contract-tests +``` + +### Packaging/releasing + +Releases are done through LaunchDarkly's standard project releaser tool. The scripts in `.ldrelease` implement most of this process, because unlike our other .NET projects which can be built with the .NET 5 SDK in a Linux container, this one currently must be built on a MacOS host in CircleCI. Do not modify these scripts unless you are very sure what you're doing. + +If you need to do a manual package build for any reason, you can do it (on MacOS) using the same command shown above under "Building", but adding the option `/t:pack`. However, this will not include Authenticode signing; therefore, please do not publish a GA release of the package from a manual build. If it's absolutely necessary to do so, consult with the SDK team to find out how to access the code-signing certificate. + +### Building a temporary package + +If you need to build a `.nupkg` for testing another application (in cases where linking directly to this project is not an option), run `./scripts/build-test-package.sh`. This will create a package with a unique version string in `./test-packages`. You can then set your other project to use `test-packages` as a NuGet package source. diff --git a/pkgs/sdk/client/LaunchDarkly.ClientSdk.sln b/pkgs/sdk/client/LaunchDarkly.ClientSdk.sln new file mode 100644 index 00000000..ac9a4c62 --- /dev/null +++ b/pkgs/sdk/client/LaunchDarkly.ClientSdk.sln @@ -0,0 +1,100 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.26730.16 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LaunchDarkly.ClientSdk", "src\LaunchDarkly.ClientSdk\LaunchDarkly.ClientSdk.csproj", "{7717A2B2-9905-40A7-989F-790139D69543}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LaunchDarkly.ClientSdk.Device.Tests", "tests\LaunchDarkly.ClientSdk.Device.Tests\LaunchDarkly.ClientSdk.Device.Tests.csproj", "{0D88C80E-8CD8-4064-AE99-9849C7CD6E35}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LaunchDarkly.ClientSdk.Tests", "tests\LaunchDarkly.ClientSdk.Tests\LaunchDarkly.ClientSdk.Tests.csproj", "{36701E5A-EC04-4B47-8739-BACCCB673C77}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + Debug|iPhoneSimulator = Debug|iPhoneSimulator + Release|iPhone = Release|iPhone + Release|iPhoneSimulator = Release|iPhoneSimulator + Debug|iPhone = Debug|iPhone + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {7717A2B2-9905-40A7-989F-790139D69543}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7717A2B2-9905-40A7-989F-790139D69543}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7717A2B2-9905-40A7-989F-790139D69543}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7717A2B2-9905-40A7-989F-790139D69543}.Release|Any CPU.Build.0 = Release|Any CPU + {7717A2B2-9905-40A7-989F-790139D69543}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {7717A2B2-9905-40A7-989F-790139D69543}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {7717A2B2-9905-40A7-989F-790139D69543}.Release|iPhone.ActiveCfg = Release|Any CPU + {7717A2B2-9905-40A7-989F-790139D69543}.Release|iPhone.Build.0 = Release|Any CPU + {7717A2B2-9905-40A7-989F-790139D69543}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {7717A2B2-9905-40A7-989F-790139D69543}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {7717A2B2-9905-40A7-989F-790139D69543}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {7717A2B2-9905-40A7-989F-790139D69543}.Debug|iPhone.Build.0 = Debug|Any CPU + {F6B71DFE-314C-4F27-A219-A14569C8CF48}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F6B71DFE-314C-4F27-A219-A14569C8CF48}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F6B71DFE-314C-4F27-A219-A14569C8CF48}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F6B71DFE-314C-4F27-A219-A14569C8CF48}.Release|Any CPU.Build.0 = Release|Any CPU + {F6B71DFE-314C-4F27-A219-A14569C8CF48}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {F6B71DFE-314C-4F27-A219-A14569C8CF48}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {F6B71DFE-314C-4F27-A219-A14569C8CF48}.Release|iPhone.ActiveCfg = Release|Any CPU + {F6B71DFE-314C-4F27-A219-A14569C8CF48}.Release|iPhone.Build.0 = Release|Any CPU + {F6B71DFE-314C-4F27-A219-A14569C8CF48}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {F6B71DFE-314C-4F27-A219-A14569C8CF48}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {F6B71DFE-314C-4F27-A219-A14569C8CF48}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {F6B71DFE-314C-4F27-A219-A14569C8CF48}.Debug|iPhone.Build.0 = Debug|Any CPU + {5EFF7561-35C1-4C62-B0BE-A76E37DCEB32}.Debug|Any CPU.ActiveCfg = Debug|iPhoneSimulator + {5EFF7561-35C1-4C62-B0BE-A76E37DCEB32}.Debug|Any CPU.Build.0 = Debug|iPhoneSimulator + {5EFF7561-35C1-4C62-B0BE-A76E37DCEB32}.Release|Any CPU.ActiveCfg = Release|iPhone + {5EFF7561-35C1-4C62-B0BE-A76E37DCEB32}.Release|Any CPU.Build.0 = Release|iPhone + {5EFF7561-35C1-4C62-B0BE-A76E37DCEB32}.Debug|iPhoneSimulator.ActiveCfg = Debug|iPhoneSimulator + {5EFF7561-35C1-4C62-B0BE-A76E37DCEB32}.Debug|iPhoneSimulator.Build.0 = Debug|iPhoneSimulator + {5EFF7561-35C1-4C62-B0BE-A76E37DCEB32}.Release|iPhone.ActiveCfg = Release|iPhone + {5EFF7561-35C1-4C62-B0BE-A76E37DCEB32}.Release|iPhone.Build.0 = Release|iPhone + {5EFF7561-35C1-4C62-B0BE-A76E37DCEB32}.Release|iPhoneSimulator.ActiveCfg = Release|iPhoneSimulator + {5EFF7561-35C1-4C62-B0BE-A76E37DCEB32}.Release|iPhoneSimulator.Build.0 = Release|iPhoneSimulator + {5EFF7561-35C1-4C62-B0BE-A76E37DCEB32}.Debug|iPhone.ActiveCfg = Debug|iPhone + {5EFF7561-35C1-4C62-B0BE-A76E37DCEB32}.Debug|iPhone.Build.0 = Debug|iPhone + {2E7720E4-01A0-403B-863C-C6C596DF5926}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2E7720E4-01A0-403B-863C-C6C596DF5926}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2E7720E4-01A0-403B-863C-C6C596DF5926}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2E7720E4-01A0-403B-863C-C6C596DF5926}.Release|Any CPU.Build.0 = Release|Any CPU + {2E7720E4-01A0-403B-863C-C6C596DF5926}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {2E7720E4-01A0-403B-863C-C6C596DF5926}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {2E7720E4-01A0-403B-863C-C6C596DF5926}.Release|iPhone.ActiveCfg = Release|Any CPU + {2E7720E4-01A0-403B-863C-C6C596DF5926}.Release|iPhone.Build.0 = Release|Any CPU + {2E7720E4-01A0-403B-863C-C6C596DF5926}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {2E7720E4-01A0-403B-863C-C6C596DF5926}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {2E7720E4-01A0-403B-863C-C6C596DF5926}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {2E7720E4-01A0-403B-863C-C6C596DF5926}.Debug|iPhone.Build.0 = Debug|Any CPU + {0D88C80E-8CD8-4064-AE99-9849C7CD6E35}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0D88C80E-8CD8-4064-AE99-9849C7CD6E35}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0D88C80E-8CD8-4064-AE99-9849C7CD6E35}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0D88C80E-8CD8-4064-AE99-9849C7CD6E35}.Release|Any CPU.Build.0 = Release|Any CPU + {0D88C80E-8CD8-4064-AE99-9849C7CD6E35}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {0D88C80E-8CD8-4064-AE99-9849C7CD6E35}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {0D88C80E-8CD8-4064-AE99-9849C7CD6E35}.Release|iPhone.ActiveCfg = Release|Any CPU + {0D88C80E-8CD8-4064-AE99-9849C7CD6E35}.Release|iPhone.Build.0 = Release|Any CPU + {0D88C80E-8CD8-4064-AE99-9849C7CD6E35}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {0D88C80E-8CD8-4064-AE99-9849C7CD6E35}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {0D88C80E-8CD8-4064-AE99-9849C7CD6E35}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {0D88C80E-8CD8-4064-AE99-9849C7CD6E35}.Debug|iPhone.Build.0 = Debug|Any CPU + {36701E5A-EC04-4B47-8739-BACCCB673C77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {36701E5A-EC04-4B47-8739-BACCCB673C77}.Debug|Any CPU.Build.0 = Debug|Any CPU + {36701E5A-EC04-4B47-8739-BACCCB673C77}.Release|Any CPU.ActiveCfg = Release|Any CPU + {36701E5A-EC04-4B47-8739-BACCCB673C77}.Release|Any CPU.Build.0 = Release|Any CPU + {36701E5A-EC04-4B47-8739-BACCCB673C77}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {36701E5A-EC04-4B47-8739-BACCCB673C77}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {36701E5A-EC04-4B47-8739-BACCCB673C77}.Release|iPhone.ActiveCfg = Release|Any CPU + {36701E5A-EC04-4B47-8739-BACCCB673C77}.Release|iPhone.Build.0 = Release|Any CPU + {36701E5A-EC04-4B47-8739-BACCCB673C77}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {36701E5A-EC04-4B47-8739-BACCCB673C77}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {36701E5A-EC04-4B47-8739-BACCCB673C77}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {36701E5A-EC04-4B47-8739-BACCCB673C77}.Debug|iPhone.Build.0 = Debug|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {15BD347E-A7C3-48FE-8007-0DB2D227EF3E} + EndGlobalSection +EndGlobal diff --git a/pkgs/sdk/client/LaunchDarkly.pk b/pkgs/sdk/client/LaunchDarkly.pk new file mode 100644 index 0000000000000000000000000000000000000000..d8290e41aed16ba3e3752701be6d9a3c063507f4 GIT binary patch literal 160 zcmV;R0AK$ABme*efB*oL000060ssI2Bme+XQ$aBR1ONa500966iXFVm!$NhP$%`JV zq(*!F>INn$@)FN{W}otFPA~Js2q`3oyfmANXnk`7Kk-e!h8`a3BQQE!T@$g}14o$? zy%Gzfa?h3>C)=N8CNWlDPKy6ZdEp%XaX2#-e#>3Uz%6$vXW1F}@Nd52C#GcFSs60b OuwPA~$U~O`?ESGf_CA>a literal 0 HcmV?d00001 diff --git a/pkgs/sdk/client/License.txt b/pkgs/sdk/client/License.txt new file mode 100644 index 00000000..f8503553 --- /dev/null +++ b/pkgs/sdk/client/License.txt @@ -0,0 +1,13 @@ +Copyright 2018 Catamorphic, Co. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/pkgs/sdk/client/Makefile b/pkgs/sdk/client/Makefile new file mode 100644 index 00000000..30cc3b6c --- /dev/null +++ b/pkgs/sdk/client/Makefile @@ -0,0 +1,28 @@ + +build: + dotnet build + +test: + dotnet test + +clean: + dotnet clean + +TEMP_TEST_OUTPUT=/tmp/sdk-contract-test-service.log + +build-contract-tests: + @./scripts/build-contract-tests.sh + +start-contract-test-service: + @./scripts/start-contract-test-service.sh + +start-contract-test-service-bg: + @echo "Test service output will be captured in $(TEMP_TEST_OUTPUT)" + @./scripts/start-contract-test-service.sh >$(TEMP_TEST_OUTPUT) 2>&1 & + +run-contract-tests: + @./scripts/run-contract-tests.sh + +contract-tests: build-contract-tests start-contract-test-service-bg run-contract-tests + +.PHONY: build test clean build-contract-tests start-contract-test-service run-contract-tests contract-tests diff --git a/pkgs/sdk/client/README.md b/pkgs/sdk/client/README.md new file mode 100644 index 00000000..95902174 --- /dev/null +++ b/pkgs/sdk/client/README.md @@ -0,0 +1,62 @@ +# LaunchDarkly Client-Side SDK for .NET + +[![NuGet](https://img.shields.io/nuget/v/LaunchDarkly.ClientSdk.svg?style=flat-square)](https://www.nuget.org/packages/LaunchDarkly.ClientSdk/) +[![CircleCI](https://circleci.com/gh/launchdarkly/dotnet-client-sdk.svg?style=shield)](https://circleci.com/gh/launchdarkly/dotnet-client-sdk) +[![Documentation](https://img.shields.io/static/v1?label=GitHub+Pages&message=API+reference&color=00add8)](https://launchdarkly.github.io/dotnet-client-sdk) + +The LaunchDarkly Client-Side SDK for .NET is designed primarily for use by code that is deployed to an end user, such as in a desktop application or a smart device. It follows the client-side LaunchDarkly model for single-user contexts (much like our mobile or JavaScript SDKs). It is not intended for use in multi-user systems such as web servers and applications. + +On platforms with MAUI support (Android, iOS, Mac, Windows), the SDK depends on the MAUI framework which allows .NET code to run on those devices. However, MAUI is not the only way to run .NET code in a client-side context (see "Supported platforms" below), so the SDK has a more general name. + +For using LaunchDarkly in *server-side* .NET applications, refer to our [Server-Side .NET SDK](https://github.com/launchdarkly/dotnet-server-sdk). + +## LaunchDarkly overview + +[LaunchDarkly](https://www.launchdarkly.com) is a feature management platform that serves trillions of feature flags daily to help teams build better software, faster. [Get started](https://docs.launchdarkly.com/home/getting-started) using LaunchDarkly today! + +## Supported platforms + +This version of the SDK is built for the following targets: + +* .Net Standard 2.0 +* .Net 7 Android, for use with Android 5.0 (Android API 21) and higher. +* .Net 7 iOS, for use with iOS 11 and higher. +* .Net 7 macOS (using Mac Catalyst), for use with macOS 10.15 and higher. +* .Net 7 Windows (using WinUI), for Windows 11 and Windows 10 version 1809 or higher. +* .NET 7 + +The .Net Standard and .Net 7.0 targets have no OS-specific code. This allows the SDK to be used in a desktop .NET Framework or .NET 7.0 application. However, due to the lack of OS-specific integration, SDK functionality will be limited in those environments: for instance, the SDK will not be able to detect whether networking is turned on or off. + +The .NET build tools should automatically load the most appropriate build of the SDK for whatever platform your application or library is targeted to. + +## Getting started + +Refer to the [SDK documentation](https://docs.launchdarkly.com/sdk/client-side/dotnet) for instructions on getting started with using the SDK. + +## Learn more + +Read our [documentation](https://docs.launchdarkly.com) for in-depth instructions on configuring and using LaunchDarkly. You can also head straight to the [complete reference guide for this SDK](https://docs.launchdarkly.com/sdk/client-side/dotnet). + +The authoritative description of all types, properties, and methods is in the [generated API documentation](https://launchdarkly.github.io/dotnet-client-sdk/). + +## Testing + +We run integration tests for all our SDKs using a centralized test harness. This approach gives us the ability to test for consistency across SDKs, as well as test networking behavior in a long-running application. These tests cover each method in the SDK, and verify that event sending, flag evaluation, stream reconnection, and other aspects of the SDK all behave correctly. + +## Contributing + +We encourage pull requests and other contributions from the community. Check out our [contributing guidelines](CONTRIBUTING.md) for instructions on how to contribute to this SDK. + +## About LaunchDarkly + +* LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can: + * Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases. + * Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?). + * Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file. + * Grant access to certain features based on user attributes, like payment plan (eg: users on the ‘gold’ plan get access to more features than users in the ‘silver’ plan). Disable parts of your application to facilitate maintenance, without taking everything offline. +* LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Check out [our documentation](https://docs.launchdarkly.com/docs) for a complete list. +* Explore LaunchDarkly + * [launchdarkly.com](https://www.launchdarkly.com/ "LaunchDarkly Main Website") for more information + * [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDK reference guides + * [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API documentation + * [blog.launchdarkly.com](https://blog.launchdarkly.com/ "LaunchDarkly Blog Documentation") for the latest product updates diff --git a/pkgs/sdk/client/SECURITY.md b/pkgs/sdk/client/SECURITY.md new file mode 100644 index 00000000..10f1d1ac --- /dev/null +++ b/pkgs/sdk/client/SECURITY.md @@ -0,0 +1,5 @@ +# Reporting and Fixing Security Issues + +Please report all security issues to the LaunchDarkly security team by submitting a bug bounty report to our [HackerOne program](https://hackerone.com/launchdarkly?type=team). LaunchDarkly will triage and address all valid security issues following the response targets defined in our program policy. Valid security issues may be eligible for a bounty. + +Please do not open issues or pull requests for security issues. This makes the problem immediately visible to everyone, including potentially malicious actors. diff --git a/pkgs/sdk/client/contract-tests/README.md b/pkgs/sdk/client/contract-tests/README.md new file mode 100644 index 00000000..03caa44c --- /dev/null +++ b/pkgs/sdk/client/contract-tests/README.md @@ -0,0 +1,9 @@ +# SDK contract test service + +This directory contains an implementation of the cross-platform SDK testing protocol defined by https://github.com/launchdarkly/sdk-test-harness. See that project's `README` for details of this protocol, and the kinds of SDK capabilities that are relevant to the contract tests. This code should not need to be updated unless the SDK has added or removed such capabilities. + +To run these tests locally, run `make contract-tests` from the SDK project root directory. This downloads the correct version of the test harness tool automatically. + +Or, to test against an in-progress local version of the test harness, run `make start-contract-test-service` from the SDK project root directory; then, in the root directory of the `sdk-test-harness` project, build the test harness and run it from the command line. + +Currently, the project does _not_ automatically detect the available target frameworks. It will default to building and running for .NET Core 2.1. To use a different target framework, set the environment variable `TESTFRAMEWORK` to the name of the application runtime framework (such as `netcoreapp3.1`), and set the environment variable `BUILDFRAMEWORKS` (note the S at the end) to the target framework that the SDK should be built for (which may or may not be the same). diff --git a/pkgs/sdk/client/contract-tests/Representations.cs b/pkgs/sdk/client/contract-tests/Representations.cs new file mode 100644 index 00000000..1836c4dd --- /dev/null +++ b/pkgs/sdk/client/contract-tests/Representations.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using LaunchDarkly.Sdk; + +// Note, in order for System.Text.Json serialization/deserialization to work correctly, the members of +// this class must be properties with get/set, rather than fields. The property names are automatically +// camelCased by System.Text.Json. + +namespace TestService +{ + public class Status + { + public string Name { get; set; } + public string[] Capabilities { get; set; } + public string ClientVersion { get; set; } + } + + public class CreateInstanceParams + { + public SdkConfigParams Configuration { get; set; } + public string Tag { get; set; } + } + + public class SdkConfigParams + { + public string Credential { get; set; } + public long? StartWaitTimeMs { get; set; } + public bool InitCanFail { get; set; } + public SdkConfigStreamParams Streaming { get; set; } + public SdkConfigPollingParams Polling { get; set; } + public SdkConfigEventParams Events { get; set; } + public SdkConfigServiceEndpointsParams ServiceEndpoints { get; set; } + public SdkClientSideParams ClientSide { get; set; } + public SdkConfigTagsParams Tags { get; set; } + } + + public class SdkConfigStreamParams + { + public Uri BaseUri { get; set; } + public long? InitialRetryDelayMs { get; set; } + } + + public class SdkConfigPollingParams + { + public Uri BaseUri { get; set; } + public long? PollIntervalMs { get; set; } + } + + public class SdkConfigEventParams + { + public Uri BaseUri { get; set; } + public bool AllAttributesPrivate { get; set; } + public int? Capacity { get; set; } + public bool EnableDiagnostics { get; set; } + public string[] GlobalPrivateAttributes { get; set; } + public long? FlushIntervalMs { get; set; } + } + + public class SdkConfigServiceEndpointsParams + { + public Uri Streaming { get; set; } + public Uri Polling { get; set; } + public Uri Events { get; set; } + } + + public class SdkConfigTagsParams + { + public string ApplicationId { get; set; } + public string ApplicationName { get; set; } + public string ApplicationVersion { get; set; } + public string ApplicationVersionName { get; set; } + } + + public class SdkClientSideParams + { + public bool? EvaluationReasons { get; set; } + public Context? InitialContext { get; set; } + public User InitialUser { get; set; } + public bool? UseReport { get; set; } + public bool? IncludeEnvironmentAttributes { get; set; } + } + + public class CommandParams + { + public string Command { get; set; } + public EvaluateFlagParams Evaluate { get; set; } + public EvaluateAllFlagsParams EvaluateAll { get; set; } + public IdentifyEventParams IdentifyEvent { get; set; } + public CustomEventParams CustomEvent { get; set; } + public ContextBuildParams ContextBuild { get; set; } + public ContextConvertParams ContextConvert { get; set; } + } + + public class EvaluateFlagParams + { + public string FlagKey { get; set; } + public String ValueType { get; set; } + public LdValue Value { get; set; } + public LdValue DefaultValue { get; set; } + public bool Detail { get; set; } + } + + public class EvaluateFlagResponse + { + public LdValue Value { get; set; } + public int? VariationIndex { get; set; } + public EvaluationReason? Reason { get; set; } + } + + public class EvaluateAllFlagsParams + { + } + + public class EvaluateAllFlagsResponse + { + public IDictionary State { get; set; } + } + + public class IdentifyEventParams + { + public Context? Context { get; set; } + public User User { get; set; } + } + + public class CustomEventParams + { + public string EventKey { get; set; } + public LdValue Data { get; set; } + public bool OmitNullData { get; set; } + public double? MetricValue { get; set; } + } + + public class ContextBuildParams + { + public ContextBuildSingleParams Single { get; set; } + public ContextBuildSingleParams[] Multi { get; set; } + } + + public class ContextBuildSingleParams + { + public string Kind { get; set; } + public string Key { get; set; } + public string Name { get; set; } + public bool Anonymous { get; set; } + public string[] Private { get; set; } + public Dictionary Custom { get; set; } + } + + public class ContextBuildResponse + { + public string Output { get; set; } + public string Error { get; set; } + } + + public class ContextConvertParams + { + public string Input { get; set; } + } +} diff --git a/pkgs/sdk/client/contract-tests/SdkClientEntity.cs b/pkgs/sdk/client/contract-tests/SdkClientEntity.cs new file mode 100644 index 00000000..ec54e4b2 --- /dev/null +++ b/pkgs/sdk/client/contract-tests/SdkClientEntity.cs @@ -0,0 +1,377 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk; +using LaunchDarkly.Sdk.Client; +using LaunchDarkly.Sdk.Json; + +namespace TestService +{ + public class SdkClientEntity + { + private static HttpClient _httpClient = new HttpClient(); + + private readonly LdClient _client; + private readonly Logger _log; + private readonly bool _evaluationReasons; + + public SdkClientEntity( + SdkConfigParams sdkParams, + ILogAdapter logAdapter, + string tag + ) + { + if (sdkParams.ClientSide == null) + { + throw new Exception("test harness did not provide clientSide configuration"); + } + + _log = logAdapter.Logger(tag); + Configuration config = BuildSdkConfig(sdkParams, logAdapter, tag); + + _evaluationReasons = sdkParams.ClientSide.EvaluationReasons ?? false; + + TimeSpan startWaitTime = TimeSpan.FromSeconds(5); + if (sdkParams.StartWaitTimeMs.HasValue) + { + startWaitTime = TimeSpan.FromMilliseconds(sdkParams.StartWaitTimeMs.Value); + } + + if (sdkParams.ClientSide.InitialContext != null) + { + _client = LdClient.Init(config, sdkParams.ClientSide.InitialContext.Value, startWaitTime); + } + else + { + _client = LdClient.Init(config, sdkParams.ClientSide.InitialUser, startWaitTime); + } + if (!_client.Initialized && !sdkParams.InitCanFail) + { + _client.Dispose(); + throw new Exception("Client initialization failed"); + } + } + + public void Close() + { + _client.Dispose(); + _log.Info("Test ended"); + } + + public async Task<(bool, object)> DoCommand(CommandParams command) + { + _log.Info("Test harness sent command: {0}", command.Command); + switch (command.Command) + { + case "evaluate": + return (true, DoEvaluate(command.Evaluate)); + + case "evaluateAll": + return (true, DoEvaluateAll(command.EvaluateAll)); + + case "identifyEvent": + if (command.IdentifyEvent.Context != null) + { + await _client.IdentifyAsync(command.IdentifyEvent.Context.Value); + } + else + { + await _client.IdentifyAsync(command.IdentifyEvent.User); + } + return (true, null); + + case "customEvent": + var custom = command.CustomEvent; + if (custom.MetricValue.HasValue) + { + _client.Track(custom.EventKey, custom.Data, custom.MetricValue.Value); + } + else if (custom.OmitNullData && custom.Data.IsNull) + { + _client.Track(custom.EventKey); + } + else + { + _client.Track(custom.EventKey, custom.Data); + } + return (true, null); + + case "flushEvents": + _client.Flush(); + return (true, null); + + case "contextBuild": + return (true, DoContextBuild(command.ContextBuild)); + + case "contextConvert": + return (true, DoContextConvert(command.ContextConvert)); + + default: + return (false, null); + } + } + + private object DoEvaluate(EvaluateFlagParams p) + { + var resp = new EvaluateFlagResponse(); + switch (p.ValueType) + { + case "bool": + if (p.Detail) + { + var detail = _client.BoolVariationDetail(p.FlagKey, p.DefaultValue.AsBool); + resp.Value = LdValue.Of(detail.Value); + resp.VariationIndex = detail.VariationIndex; + resp.Reason = detail.Reason; + } + else + { + resp.Value = LdValue.Of(_client.BoolVariation(p.FlagKey, p.DefaultValue.AsBool)); + } + break; + + case "int": + if (p.Detail) + { + var detail = _client.IntVariationDetail(p.FlagKey, p.DefaultValue.AsInt); + resp.Value = LdValue.Of(detail.Value); + resp.VariationIndex = detail.VariationIndex; + resp.Reason = detail.Reason; + } + else + { + resp.Value = LdValue.Of(_client.IntVariation(p.FlagKey, p.DefaultValue.AsInt)); + } + break; + + case "double": + if (p.Detail) + { + var detail = _client.DoubleVariationDetail(p.FlagKey, p.DefaultValue.AsDouble); + resp.Value = LdValue.Of(detail.Value); + resp.VariationIndex = detail.VariationIndex; + resp.Reason = detail.Reason; + } + else + { + resp.Value = LdValue.Of(_client.DoubleVariation(p.FlagKey, p.DefaultValue.AsDouble)); + } + break; + + case "string": + if (p.Detail) + { + var detail = _client.StringVariationDetail(p.FlagKey, p.DefaultValue.AsString); + resp.Value = LdValue.Of(detail.Value); + resp.VariationIndex = detail.VariationIndex; + resp.Reason = detail.Reason; + } + else + { + resp.Value = LdValue.Of(_client.StringVariation(p.FlagKey, p.DefaultValue.AsString)); + } + break; + + default: + if (p.Detail) + { + var detail = _client.JsonVariationDetail(p.FlagKey, p.DefaultValue); + resp.Value = detail.Value; + resp.VariationIndex = detail.VariationIndex; + resp.Reason = detail.Reason; + } + else + { + resp.Value = _client.JsonVariation(p.FlagKey, p.DefaultValue); + } + break; + } + + if (p.Detail && !_evaluationReasons && resp.Reason.HasValue && resp.Reason.Value.Kind == EvaluationReasonKind.Off) + { + resp.Reason = null; + } + + return resp; + } + + private object DoEvaluateAll(EvaluateAllFlagsParams p) + { + return new EvaluateAllFlagsResponse + { + State = _client.AllFlags() + }; + } + + private ContextBuildResponse DoContextBuild(ContextBuildParams p) + { + Context c; + if (p.Multi is null) + { + c = DoContextBuildSingle(p.Single); + } + else + { + var b = Context.MultiBuilder(); + foreach (var s in p.Multi) + { + b.Add(DoContextBuildSingle(s)); + } + c = b.Build(); + } + if (c.Valid) + { + return new ContextBuildResponse { Output = LdJsonSerialization.SerializeObject(c) }; + } + return new ContextBuildResponse { Error = c.Error }; + } + + private Context DoContextBuildSingle(ContextBuildSingleParams s) + { + var b = Context.Builder(s.Key) + .Kind(s.Kind) + .Name(s.Name) + .Anonymous(s.Anonymous); + if (!(s.Private is null)) + { + b.Private(s.Private); + } + if (!(s.Custom is null)) + { + foreach (var kv in s.Custom) + { + b.Set(kv.Key, kv.Value); + } + } + return b.Build(); + } + + private ContextBuildResponse DoContextConvert(ContextConvertParams p) + { + try + { + var c = LdJsonSerialization.DeserializeObject(p.Input); + if (c.Valid) + { + return new ContextBuildResponse { Output = LdJsonSerialization.SerializeObject(c) }; + } + return new ContextBuildResponse { Error = c.Error }; + } + catch (Exception e) + { + return new ContextBuildResponse { Error = e.ToString() }; + } + } + + private static Configuration BuildSdkConfig(SdkConfigParams sdkParams, ILogAdapter logAdapter, string tag) + { + var autoEnvAttributes = (sdkParams.ClientSide.IncludeEnvironmentAttributes ?? false) + ? ConfigurationBuilder.AutoEnvAttributes.Enabled + : ConfigurationBuilder.AutoEnvAttributes.Disabled; + + var builder = Configuration.Builder(sdkParams.Credential, autoEnvAttributes); + + builder.Logging(Components.Logging(logAdapter).BaseLoggerName(tag + ".SDK")); + + var endpoints = Components.ServiceEndpoints(); + builder.ServiceEndpoints(endpoints); + if (sdkParams.ServiceEndpoints != null) + { + if (sdkParams.ServiceEndpoints.Streaming != null) + { + endpoints.Streaming(sdkParams.ServiceEndpoints.Streaming); + } + if (sdkParams.ServiceEndpoints.Polling != null) + { + endpoints.Polling(sdkParams.ServiceEndpoints.Polling); + } + if (sdkParams.ServiceEndpoints.Events != null) + { + endpoints.Events(sdkParams.ServiceEndpoints.Events); + } + } + + if (sdkParams.Tags != null) + { + var applicationInfo = Components.ApplicationInfo(); + applicationInfo.ApplicationId(sdkParams.Tags.ApplicationId); + applicationInfo.ApplicationName(sdkParams.Tags.ApplicationName); + applicationInfo.ApplicationVersion(sdkParams.Tags.ApplicationVersion); + applicationInfo.ApplicationVersionName(sdkParams.Tags.ApplicationVersionName); + builder.ApplicationInfo(applicationInfo); + } + + var streamingParams = sdkParams.Streaming; + var pollingParams = sdkParams.Polling; + if (streamingParams != null) + { + endpoints.Streaming(streamingParams.BaseUri); + var dataSource = Components.StreamingDataSource(); + if (streamingParams.InitialRetryDelayMs.HasValue) + { + dataSource.InitialReconnectDelay(TimeSpan.FromMilliseconds(streamingParams.InitialRetryDelayMs.Value)); + } + if (pollingParams != null) + { + endpoints.Polling(pollingParams.BaseUri); + if (pollingParams.PollIntervalMs.HasValue) + { + dataSource.BackgroundPollInterval(TimeSpan.FromMilliseconds(pollingParams.PollIntervalMs.Value)); + } + } + builder.DataSource(dataSource); + } + else if (pollingParams != null) + { + endpoints.Polling(pollingParams.BaseUri); + var dataSource = Components.PollingDataSource(); + if (pollingParams.PollIntervalMs.HasValue) + { + dataSource.PollInterval(TimeSpan.FromMilliseconds(pollingParams.PollIntervalMs.Value)); + } + builder.DataSource(dataSource); + } + + var eventParams = sdkParams.Events; + if (eventParams == null) + { + builder.Events(Components.NoEvents); + } + else + { + endpoints.Events(eventParams.BaseUri); + var events = Components.SendEvents() + .AllAttributesPrivate(eventParams.AllAttributesPrivate); + if (eventParams.Capacity.HasValue && eventParams.Capacity.Value > 0) + { + events.Capacity(eventParams.Capacity.Value); + } + if (eventParams.FlushIntervalMs.HasValue && eventParams.FlushIntervalMs.Value > 0) + { + events.FlushInterval(TimeSpan.FromMilliseconds(eventParams.FlushIntervalMs.Value)); + } + if (eventParams.GlobalPrivateAttributes != null) + { + events.PrivateAttributes(eventParams.GlobalPrivateAttributes); + } + builder.Events(events); + builder.DiagnosticOptOut(!eventParams.EnableDiagnostics); + } + + var http = Components.HttpConfiguration(); + if (sdkParams.ClientSide.UseReport.HasValue) + { + http.UseReport(sdkParams.ClientSide.UseReport.Value); + } + builder.Http(http); + + if (sdkParams.ClientSide.EvaluationReasons.HasValue) + { + builder.EvaluationReasons(sdkParams.ClientSide.EvaluationReasons.Value); + } + + return builder.Build(); + } + } +} diff --git a/pkgs/sdk/client/contract-tests/TestService.cs b/pkgs/sdk/client/contract-tests/TestService.cs new file mode 100644 index 00000000..d23521c5 --- /dev/null +++ b/pkgs/sdk/client/contract-tests/TestService.cs @@ -0,0 +1,137 @@ +using System.Collections.Concurrent; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Client; +using LaunchDarkly.TestHelpers.HttpTest; + +namespace TestService +{ + public class Program + { + const int Port = 8000; + + public static void Main(string[] args) + { + var quitSignal = new EventWaitHandle(false, EventResetMode.AutoReset); + + var app = new Webapp(quitSignal); + var server = HttpServer.Start(Port, app.Handler); + server.Recorder.Enabled = false; + + System.Console.WriteLine("Listening on port {0}", Port); + + quitSignal.WaitOne(); + server.Dispose(); + } + } + + public class Webapp + { + private static readonly string[] Capabilities = { + "client-side", + "context-type", + "mobile", + "service-endpoints", + "singleton", + "strongly-typed", + "user-type", + "tags", + "auto-env-attributes", + "inline-context", + "anonymous-redaction" + }; + + public readonly Handler Handler; + + private readonly string _version; + private readonly ILogAdapter _logging = Logs.ToConsole; + private readonly ConcurrentDictionary _clients = + new ConcurrentDictionary(); + private readonly EventWaitHandle _quitSignal; + private volatile int _lastClientId = 0; + + public Webapp(EventWaitHandle quitSignal) + { + _quitSignal = quitSignal; + + _version = LdClient.Version.ToString(); + + var service = new SimpleJsonService(); + Handler = service.Handler; + + service.Route(HttpMethod.Get, "/", GetStatus); + service.Route(HttpMethod.Delete, "/", ForceQuit); + service.Route(HttpMethod.Post, "/", PostCreateClient); + service.Route(HttpMethod.Post, "/clients/(.*)", PostClientCommand); + service.Route(HttpMethod.Delete, "/clients/(.*)", DeleteClient); + } + + SimpleResponse GetStatus(IRequestContext context) => + SimpleResponse.Of(200, new Status + { + Name = "dotnet-client-sdk", + Capabilities = Capabilities, + ClientVersion = _version + }); + + SimpleResponse ForceQuit(IRequestContext context) + { + _logging.Logger("").Info("Test harness has told us to exit"); + + // The web server won't send the response till we return, so we'll defer the actual shutdown + _ = Task.Run(async () => + { + await Task.Delay(100); + _quitSignal.Set(); + }); + + return SimpleResponse.Of(204); + } + + SimpleResponse PostCreateClient(IRequestContext context, CreateInstanceParams createParams) + { + var client = new SdkClientEntity(createParams.Configuration, _logging, createParams.Tag); + + var id = Interlocked.Increment(ref _lastClientId); + var clientId = id.ToString(); + _clients[clientId] = client; + + var resourceUrl = "/clients/" + clientId; + return SimpleResponse.Of(201).WithHeader("Location", resourceUrl); + } + + async Task> PostClientCommand(IRequestContext context, CommandParams command) + { + var id = context.GetPathParam(0); + if (!_clients.TryGetValue(id, out var client)) + { + return SimpleResponse.Of(404, null); + } + + var result = await client.DoCommand(command); + if (result.Item1) + { + return SimpleResponse.Of(202, result.Item2); + } + else + { + return SimpleResponse.Of(400, null); + } + } + + SimpleResponse DeleteClient(IRequestContext context) + { + var id = context.GetPathParam(0); + if (!_clients.TryGetValue(id, out var client)) + { + return SimpleResponse.Of(400); + } + client.Close(); + _clients.TryRemove(id, out _); + + return SimpleResponse.Of(204); + } + } +} diff --git a/pkgs/sdk/client/contract-tests/TestService.csproj b/pkgs/sdk/client/contract-tests/TestService.csproj new file mode 100644 index 00000000..39805cf1 --- /dev/null +++ b/pkgs/sdk/client/contract-tests/TestService.csproj @@ -0,0 +1,26 @@ + + + + net7.0 + $(TESTFRAMEWORK) + portable + ContractTestService + Exe + ContractTestService + false + false + false + false + false + false + + + + + + + + + + + diff --git a/pkgs/sdk/client/contract-tests/TestService.sln b/pkgs/sdk/client/contract-tests/TestService.sln new file mode 100644 index 00000000..7826ab8f --- /dev/null +++ b/pkgs/sdk/client/contract-tests/TestService.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.810.13 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LaunchDarkly.ClientSdk", "..\src\LaunchDarkly.ClientSdk\LaunchDarkly.ClientSdk.csproj", "{6AD8F034-7096-4420-953F-68F529F2B300}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestService", "TestService.csproj", "{D69B2FDF-76D3-475B-971E-64120FD84401}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6AD8F034-7096-4420-953F-68F529F2B300}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6AD8F034-7096-4420-953F-68F529F2B300}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6AD8F034-7096-4420-953F-68F529F2B300}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6AD8F034-7096-4420-953F-68F529F2B300}.Release|Any CPU.Build.0 = Release|Any CPU + {D69B2FDF-76D3-475B-971E-64120FD84401}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D69B2FDF-76D3-475B-971E-64120FD84401}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D69B2FDF-76D3-475B-971E-64120FD84401}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D69B2FDF-76D3-475B-971E-64120FD84401}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {934189FE-533F-429D-87E8-A2311F8730A6} + EndGlobalSection +EndGlobal diff --git a/pkgs/sdk/client/docfx.json b/pkgs/sdk/client/docfx.json new file mode 100644 index 00000000..40e781a5 --- /dev/null +++ b/pkgs/sdk/client/docfx.json @@ -0,0 +1,46 @@ +{ + "metadata": [ + { + "src": [ + { + "src": "./src", + "files": [ + "**/*.csproj", + "**/bin/**/**LaunchDarkly**.dll" + ] + } + ], + "dest": "./api", + "namespaceLayout": "nested" + } + ], + "build": { + "content": [ + { + "files": [ + "**/*.{md,yml}" + ], + "exclude": [ + "docs/**" + ] + } + ], + "resource": [ + { + "files": [ + "images/**" + ] + } + ], + "output": "docs", + "template": [ + "default" + ], + "globalMetadata": { + "_appName": "LaunchDarkly Dotnet Client SDK", + "_appTitle": "LaunchDarkly Dotnet Client SDK", + "_enableSearch": true, + "pdf": false + } + } +} diff --git a/pkgs/sdk/client/index.md b/pkgs/sdk/client/index.md new file mode 100644 index 00000000..3c4657a3 --- /dev/null +++ b/pkgs/sdk/client/index.md @@ -0,0 +1,11 @@ +--- +_layout: landing +--- + +# LaunchDarkly Client-Side SDK for .NET + +For first time users, visit our [LaunchDarkly Docs](https://docs.launchdarkly.com/sdk/client-side/dotnet) page. Within these docs, the [LDClient](api/LaunchDarkly.Sdk.Client.LdClient.html) and the [ContextBuilder](api/LaunchDarkly.Sdk.ContextBuilder.html) are good starting points to explore from. + +This site contains the full API reference for the [`LaunchDarkly.ClientSdk`](https://www.nuget.org/packages/LaunchDarkly.ClientSdk) package, as well other packages used by the client package. + +For source code, see the [GitHub repository](https://github.com/launchdarkly/dotnet-client-sdk). The [developer notes](https://github.com/launchdarkly/dotnet-client-sdk/blob/main/CONTRIBUTING.md) there include links to other repositories used in the SDK. diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Components.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Components.cs new file mode 100644 index 00000000..3a6c02ab --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Components.cs @@ -0,0 +1,340 @@ +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Client.Integrations; +using LaunchDarkly.Sdk.Client.Internal; +using LaunchDarkly.Sdk.Client.Internal.DataStores; +using LaunchDarkly.Sdk.Client.Subsystems; + +namespace LaunchDarkly.Sdk.Client +{ + /// + /// Provides configurable factories for the standard implementations of LaunchDarkly component interfaces. + /// + /// + /// Some of the configuration options in affect the entire SDK, + /// but others are specific to one area of functionality, such as how the SDK receives feature flag + /// updates or processes analytics events. For the latter, the standard way to specify a configuration + /// is to call one of the static methods in (such as ), + /// apply any desired configuration change to the object that that method returns (such as + /// ), and then use the + /// corresponding method in (such as + /// ) to use that + /// configured component in the SDK. + /// + public static class Components + { + /// + /// Returns a configuration builder for the SDK's networking configuration. + /// + /// + /// Passing this to applies this + /// configuration to all HTTP/HTTPS requests made by the SDK. + /// + /// + /// + /// var config = Configuration.Builder(sdkKey) + /// .Http( + /// Components.HttpConfiguration() + /// .ConnectTimeout(TimeSpan.FromMilliseconds(3000)) + /// ) + /// .Build(); + /// + /// + /// a builder + public static HttpConfigurationBuilder HttpConfiguration() => new HttpConfigurationBuilder(); + + /// + /// Returns a configuration builder for the SDK's logging configuration. + /// + /// + /// + /// Passing this to , + /// after setting any desired properties on the builder, applies this configuration to the SDK. + /// + /// + /// For a description of the default behavior, see . + /// + /// + /// For more about how logging works in the SDK, see the LaunchDarkly + /// feature guide. + /// + /// + /// + /// + /// var config = Configuration.Builder(mobileKey) + /// .Logging(Components.Logging().Level(LogLevel.Warn))) + /// .Build(); + /// + /// + /// a configurable factory object + /// + /// + /// + public static LoggingConfigurationBuilder Logging() => + new LoggingConfigurationBuilder(); + + /// + /// Returns a configuration builder for the SDK's logging configuration, specifying the logging implementation. + /// + /// + /// + /// This is a shortcut for calling and then + /// , to specify a logging implementation + /// other than the default one. For details about the default implementation, see + /// . + /// + /// + /// By default, the minimum log level is Info (that is, Debug logging is + /// disabled). This can be overridden with . + /// + /// + /// For more about log adapters, see . + /// + /// + /// For more about how logging works in the SDK, see the LaunchDarkly + /// feature guide. + /// + /// + /// + /// + /// var config = Configuration.Builder(mobileKey) + /// .Logging(Components.Logging(Logs.ToConsole)) + /// .Build(); + /// + /// + /// an ILogAdapter for the desired logging implementation + /// a configurable factory object + /// + /// + /// + /// + public static LoggingConfigurationBuilder Logging(ILogAdapter adapter) => + new LoggingConfigurationBuilder().Adapter(adapter); + + /// + /// Returns a configuration object that disables analytics events. + /// + /// + /// Passing this to causes + /// the SDK to discard all analytics events and not send them to LaunchDarkly, regardless of + /// any other configuration. + /// + /// + /// + /// var config = Configuration.Builder(mobileKey) + /// .Events(Components.NoEvents) + /// .Build(); + /// + /// + /// + /// + public static IComponentConfigurer NoEvents => + ComponentsImpl.NullEventProcessorFactory.Instance; + + /// + /// A configuration object that disables logging. + /// + /// + /// This is the same as Logging(LaunchDarkly.Logging.Logs.None). + /// + /// + /// + /// var config = Configuration.Builder(mobileKey) + /// .Logging(Components.NoLogging) + /// .Build(); + /// + /// + /// + /// + /// + public static LoggingConfigurationBuilder NoLogging => + new LoggingConfigurationBuilder().Adapter(Logs.None); + + /// + /// A configuration object that disables persistent storage. + /// + /// + /// This is equivalent to `Persistence().MaxCachedUsers(0)`. + /// + /// + /// + /// var config = Configuration.Builder(mobileKey) + /// .Persistence(Components.NoPersistence) + /// .Build(); + /// + /// + /// + /// + public static PersistenceConfigurationBuilder NoPersistence => + Persistence().Storage(NullPersistentDataStoreFactory.Instance).MaxCachedContexts(0); + + /// + /// Returns a configuration builder for the SDK's persistent storage configuration. + /// + /// + /// Passing this to , + /// after setting any desired properties on the builder, applies this configuration to the SDK. + /// + /// + /// + /// var config = Configuration.Builder(sdkKey) + /// .Persistence( + /// Components.Persistence().MaxCachedUsers(10) + /// ) + /// .Build(); + /// + /// + /// a builder + /// + /// + /// + public static PersistenceConfigurationBuilder Persistence() => + new PersistenceConfigurationBuilder(); + + /// + /// Returns a configurable factory for using only polling mode to get feature flag data. + /// + /// + /// + /// This is not the default behavior; by default, the SDK uses a streaming connection to receive feature flag + /// data from LaunchDarkly. In polling mode, the SDK instead makes a new HTTP request to LaunchDarkly at regular + /// intervals. HTTP caching allows it to avoid redundantly downloading data if there have been no changes, but + /// polling is still less efficient than streaming and should only be used on the advice of LaunchDarkly support. + /// + /// + /// The SDK may still use polling mode sometimes even when streaming mode is enabled, such as + /// when an application is in the background. You do not need to specifically select polling + /// mode in order for that to happen. + /// + /// + /// To use only polling mode, call this method to obtain a builder, change its properties with the + /// methods, and pass it to + /// . + /// + /// + /// Setting to will superseded this + /// setting and completely disable network requests. + /// + /// + /// + /// + /// var config = Configuration.Builder(mobileKey) + /// .DataSource(Components.PollingDataSource() + /// .PollInterval(TimeSpan.FromSeconds(45))) + /// .Build(); + /// + /// + /// a builder for setting polling connection properties + /// + /// + public static PollingDataSourceBuilder PollingDataSource() => + new PollingDataSourceBuilder(); + + /// + /// Returns a configuration builder for analytics event delivery. + /// + /// + /// + /// The default configuration has events enabled with default settings. If you want to + /// customize this behavior, call this method to obtain a builder, change its properties + /// with the methods, and pass it to + /// . + /// + /// + /// To completely disable sending analytics events, use instead. + /// + /// + /// + /// + /// var config = Configuration.Builder(mobileKey) + /// .Events(Components.SendEvents() + /// .Capacity(5000) + /// .FlushInterval(TimeSpan.FromSeconds(2))) + /// .Build(); + /// + /// + /// a builder for setting event properties + /// + /// + public static EventProcessorBuilder SendEvents() => new EventProcessorBuilder(); + + /// + /// Returns a builder for configuring custom service URIs. + /// + /// + /// + /// Passing this to , + /// after setting any desired properties on the builder, applies this configuration to the SDK. + /// + /// + /// Most applications will never need to use this method. The main use case is when connecting + /// to a LaunchDarkly + /// Relay Proxy instance. For more information, see . + /// + /// + /// + /// + /// var config = Configuration.Builder(mobileKey) + /// .ServiceEndpoints(Components.ServiceEndpoints().RelayProxy("http://my-relay-hostname:80")) + /// .Build(); + /// + /// + /// a configuration builder + /// + public static ServiceEndpointsBuilder ServiceEndpoints() => new ServiceEndpointsBuilder(); + + /// + /// Returns a configurable builder for the SDK's application metadata. + /// + /// + /// + /// Passing this to after setting any desired properties on the builder, + /// applies this configuration to the SDK. + /// + /// + /// + /// + /// var config = Configuration.Builder(mobileKey) + /// .ApplicationInfo( + /// Components.ApplicationInfo().ApplicationID("MyApplication").ApplicationVersion("version123abc") + /// ) + /// .Build(); + /// + /// + /// a configuration builder + public static ApplicationInfoBuilder ApplicationInfo() => new ApplicationInfoBuilder(); + + /// + /// Returns a configurable factory for using streaming mode to get feature flag data. + /// + /// + /// + /// By default, the SDK uses a streaming connection to receive feature flag data from LaunchDarkly. To use + /// the default behavior, you do not need to call this method. However, if you want to customize the behavior + /// of the connection, call this method to obtain a builder, change its properties with the + /// methods, and pass it to + /// . + /// + /// + /// The SDK may still use polling mode sometimes even when streaming mode is enabled, such as + /// when an application is in the background. + /// + /// + /// Setting to will superseded this + /// setting and completely disable network requests. + /// + /// + /// + /// + /// var config = Configuration.Builder(mobileKey) + /// .DataSource(Components.StreamingDataSource() + /// .InitialReconnectDelay(TimeSpan.FromMilliseconds(500))) + /// .Build(); + /// + /// + /// a builder for setting streaming connection properties + /// + /// + public static StreamingDataSourceBuilder StreamingDataSource() => + new StreamingDataSourceBuilder(); + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Configuration.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Configuration.cs new file mode 100644 index 00000000..9b457674 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Configuration.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Immutable; +using LaunchDarkly.Sdk.Client.Integrations; +using LaunchDarkly.Sdk.Client.Interfaces; +using LaunchDarkly.Sdk.Client.Internal.Interfaces; +using LaunchDarkly.Sdk.Client.PlatformSpecific; +using LaunchDarkly.Sdk.Client.Subsystems; + +namespace LaunchDarkly.Sdk.Client +{ + /// + /// Configuration options for . + /// + /// + /// Instances of are immutable once created. They can be created with the factory method + /// , or using a builder pattern + /// with or . + /// + public sealed class Configuration + { + /// + /// ApplicationInfo configuration which contains info about the application the SDK is running in. + /// + public ApplicationInfoBuilder ApplicationInfo { get; } + + /// + /// True if Auto Environment Attributes functionality is enabled. When enabled, the SDK will automatically + /// provide data about the environment where the application is running. + /// + public bool AutoEnvAttributes { get; } + + /// + /// Default value for and + /// . + /// + public static readonly TimeSpan DefaultBackgroundPollInterval = TimeSpan.FromMinutes(60); + + /// + /// Minimum value for and + /// . + /// + public static readonly TimeSpan MinimumBackgroundPollInterval = TimeSpan.FromMinutes(15); + + // Settable only for testing + internal IBackgroundModeManager BackgroundModeManager { get; } + internal IConnectivityStateManager ConnectivityStateManager { get; } + + /// + /// A factory object that creates an implementation of , which will + /// receive feature flag data. + /// + /// + public IComponentConfigurer DataSource { get; } + + /// + /// True if diagnostic events have been disabled. + /// + /// + public bool DiagnosticOptOut { get; } + + /// + /// Whether to enable feature flag updates when the application is running in the background. + /// + /// + /// This is only relevant on mobile platforms. + /// + /// + public bool EnableBackgroundUpdating { get; } + + /// + /// True if LaunchDarkly should provide additional information about how flag values were + /// calculated. + /// + /// + /// The additional information will then be available through the client's "detail" + /// methods such as . Since this + /// increases the size of network requests, such information is not sent unless you set this option + /// to . + /// + /// + public bool EvaluationReasons { get; } + + /// + /// A factory object that creates an implementation of , responsible + /// for sending analytics events. + /// + /// + public IComponentConfigurer Events { get; } + + /// + /// True if the SDK should provide unique keys for anonymous contexts. + /// + /// + public bool GenerateAnonymousKeys { get; } + + /// + /// HTTP configuration properties for the SDK. + /// + /// + public HttpConfigurationBuilder HttpConfigurationBuilder { get; } + + /// + /// Logging configuration properties for the SDK. + /// + /// + public LoggingConfigurationBuilder LoggingConfigurationBuilder { get; } + + /// + /// The key for your LaunchDarkly environment. + /// + /// + /// This should be the "mobile key" field for the environment on your LaunchDarkly dashboard. + /// + public string MobileKey { get; } + + /// + /// Whether or not this client is offline. If , no calls to LaunchDarkly will be made. + /// + /// + public bool Offline { get; } + + /// + /// Persistent storage configuration properties for the SDK. + /// + /// + public PersistenceConfigurationBuilder PersistenceConfigurationBuilder { get; } + + /// + /// Defines the base service URIs used by SDK components. + /// + /// + public ServiceEndpoints ServiceEndpoints { get; } + + /// + /// Creates a configuration with all parameters set to the default. + /// + /// the SDK key for your LaunchDarkly environment + /// Enable / disable Auto Environment Attributes functionality. When enabled, + /// the SDK will automatically provide data about the environment where the application is running. + /// This data makes it simpler to target your mobile customers based on application name or version, or on + /// device characteristics including manufacturer, model, operating system, locale, and so on. We recommend + /// enabling this when you configure the SDK. See + /// our documentation for + /// more details. + /// a instance + public static Configuration Default(string mobileKey, ConfigurationBuilder.AutoEnvAttributes autoEnvAttributes) + { + return Builder(mobileKey, autoEnvAttributes).Build(); + } + + /// + /// Creates a for constructing a configuration object using a fluent syntax. + /// + /// + /// This is the only method for building a if you are setting properties + /// besides the MobileKey. The has methods for setting any number of + /// properties, after which you call to get the resulting + /// Configuration instance. + /// + /// + /// + /// var config = Configuration.Builder("my-sdk-key") + /// .EventFlushInterval(TimeSpan.FromSeconds(90)) + /// .StartWaitTime(TimeSpan.FromSeconds(5)) + /// .Build(); + /// + /// + /// the mobile SDK key for your LaunchDarkly environment + /// Enable / disable Auto Environment Attributes functionality. When enabled, + /// the SDK will automatically provide data about the environment where the application is running. + /// This data makes it simpler to target your mobile customers based on application name or version, or on + /// device characteristics including manufacturer, model, operating system, locale, and so on. We recommend + /// enabling this when you configure the SDK. See + /// our documentation for + /// more details. + /// a builder object + public static ConfigurationBuilder Builder(string mobileKey, + ConfigurationBuilder.AutoEnvAttributes autoEnvAttributes) + { + if (String.IsNullOrEmpty(mobileKey)) + { + throw new ArgumentOutOfRangeException(nameof(mobileKey), "key is required"); + } + + return new ConfigurationBuilder(mobileKey, autoEnvAttributes); + } + + /// + /// Creates a starting with the properties of an existing . + /// + /// the configuration to copy + /// a builder object + public static ConfigurationBuilder Builder(Configuration fromConfiguration) + { + return new ConfigurationBuilder(fromConfiguration); + } + + internal Configuration(ConfigurationBuilder builder) + { + ApplicationInfo = builder._applicationInfo; + AutoEnvAttributes = builder._autoEnvAttributes; + DataSource = builder._dataSource; + DiagnosticOptOut = builder._diagnosticOptOut; + EnableBackgroundUpdating = builder._enableBackgroundUpdating; + EvaluationReasons = builder._evaluationReasons; + Events = builder._events; + GenerateAnonymousKeys = builder._generateAnonymousKeys; + HttpConfigurationBuilder = builder._httpConfigurationBuilder; + LoggingConfigurationBuilder = builder._loggingConfigurationBuilder; + MobileKey = builder._mobileKey; + Offline = builder._offline; + PersistenceConfigurationBuilder = builder._persistenceConfigurationBuilder; + ServiceEndpoints = (builder._serviceEndpointsBuilder ?? Components.ServiceEndpoints()).Build(); + BackgroundModeManager = builder._backgroundModeManager; + ConnectivityStateManager = builder._connectivityStateManager; + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/ConfigurationBuilder.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/ConfigurationBuilder.cs new file mode 100644 index 00000000..7a3ed36e --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/ConfigurationBuilder.cs @@ -0,0 +1,456 @@ +using System.Net.Http; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Client.Integrations; +using LaunchDarkly.Sdk.Client.Internal.Interfaces; +using LaunchDarkly.Sdk.Client.Subsystems; + +namespace LaunchDarkly.Sdk.Client +{ + /// + /// A mutable object that uses the Builder pattern to specify properties for a object. + /// + /// + /// + /// Obtain an instance of this class by calling . + /// + /// + /// All of the builder methods for setting a configuration property return a reference to the same builder, so they can be + /// chained together. + /// + /// + /// + /// + /// var config = Configuration.Builder("my-mobile-key").AllAttributesPrivate(true).EventCapacity(1000).Build(); + /// + /// + public sealed class ConfigurationBuilder + { + /// + /// Enable / disable options for Auto Environment Attributes functionality. When enabled, the SDK will automatically + /// provide data about the environment where the application is running. This data makes it simpler to target + /// your mobile customers based on application name or version, or on device characteristics including manufacturer, + /// model, operating system, locale, and so on. We recommend enabling this when you configure the SDK. See + /// our documentation + /// for more details. + /// For example, consider a “dark mode” feature being added to an app. Versions 10 through 14 contain early, + /// incomplete versions of the feature. These versions are available to all customers, but the “dark mode” feature is only + /// enabled for testers. With version 15, the feature is considered complete. With Auto Environment Attributes enabled, + /// you can use targeting rules to enable "dark mode" for all customers who are using version 15 or greater, and ensure + /// that customers on previous versions don't use the earlier, unfinished version of the feature. + /// + public enum AutoEnvAttributes + { + /// + /// Enables the Auto EnvironmentAttributes functionality. + /// + Enabled, + + /// + /// Disables the Auto EnvironmentAttributes functionality. + /// + Disabled + } + + // This exists so that we can distinguish between leaving the HttpMessageHandler property unchanged + // and explicitly setting it to null. If the property value is the exact same instance as this, we + // will replace it with a platform-specific implementation. + internal static readonly HttpMessageHandler DefaultHttpMessageHandlerInstance = new HttpClientHandler(); + + internal ApplicationInfoBuilder _applicationInfo; + internal bool _autoEnvAttributes = false; + internal IComponentConfigurer _dataSource = null; + internal bool _diagnosticOptOut = false; + internal bool _enableBackgroundUpdating = true; + internal bool _evaluationReasons = false; + internal IComponentConfigurer _events = null; + internal bool _generateAnonymousKeys = false; + internal HttpConfigurationBuilder _httpConfigurationBuilder = null; + internal LoggingConfigurationBuilder _loggingConfigurationBuilder = null; + internal string _mobileKey; + internal bool _offline = false; + internal PersistenceConfigurationBuilder _persistenceConfigurationBuilder = null; + internal ServiceEndpointsBuilder _serviceEndpointsBuilder = null; + + // Internal properties only settable for testing + internal IBackgroundModeManager _backgroundModeManager; + internal IConnectivityStateManager _connectivityStateManager; + + internal ConfigurationBuilder(string mobileKey, AutoEnvAttributes autoEnvAttributes) + { + _mobileKey = mobileKey; + _autoEnvAttributes = autoEnvAttributes == AutoEnvAttributes.Enabled; // map enum to boolean + } + + internal ConfigurationBuilder(Configuration copyFrom) + { + _applicationInfo = copyFrom.ApplicationInfo; + _autoEnvAttributes = copyFrom.AutoEnvAttributes; + _dataSource = copyFrom.DataSource; + _diagnosticOptOut = copyFrom.DiagnosticOptOut; + _enableBackgroundUpdating = copyFrom.EnableBackgroundUpdating; + _evaluationReasons = copyFrom.EvaluationReasons; + _events = copyFrom.Events; + _httpConfigurationBuilder = copyFrom.HttpConfigurationBuilder; + _loggingConfigurationBuilder = copyFrom.LoggingConfigurationBuilder; + _mobileKey = copyFrom.MobileKey; + _offline = copyFrom.Offline; + _persistenceConfigurationBuilder = copyFrom.PersistenceConfigurationBuilder; + _serviceEndpointsBuilder = new ServiceEndpointsBuilder(copyFrom.ServiceEndpoints); + } + + /// + /// Creates a based on the properties that have been set on the builder. + /// Modifying the builder after this point does not affect the returned . + /// + /// the configured Configuration object + public Configuration Build() + { + return new Configuration(this); + } + + /// + /// Sets the SDK's application metadata, which may be used in the LaunchDarkly analytics or other product + /// features. This object is normally a configuration builder obtained from , + /// which has methods for setting individual metadata properties. + /// + /// builder for + /// the same builder + public ConfigurationBuilder ApplicationInfo(ApplicationInfoBuilder applicationInfo) + { + _applicationInfo = applicationInfo; + return this; + } + + /// + /// Specifies whether the SDK will use Auto Environment Attributes functionality. When enabled, + /// the SDK will automatically provide data about the environment where the application is running. + /// This data makes it simpler to target your mobile customers based on application name or version, or on + /// device characteristics including manufacturer, model, operating system, locale, and so on. We recommend + /// enabling this when you configure the SDK. See + /// our documentation for + /// more details. + /// + /// Enable / disable Auto Environment Attributes functionality. + /// the same builder + public ConfigurationBuilder AutoEnvironmentAttributes(AutoEnvAttributes autoEnvAttributes) + { + _autoEnvAttributes = autoEnvAttributes == AutoEnvAttributes.Enabled; // map enum to boolean + return this; + } + + /// + /// Sets the implementation of the component that receives feature flag data from LaunchDarkly, + /// using a factory object. + /// + /// + /// + /// Depending on the implementation, the factory may be a builder that allows you to set other + /// configuration options as well. + /// + /// + /// The default is . You may instead use + /// . See those methods for details on how + /// to configure them. + /// + /// + /// This overwrites any previous options set with . + /// If you want to set multiple options, set them on the same + /// or . + /// + /// + /// the factory object + /// the same builder + public ConfigurationBuilder DataSource(IComponentConfigurer dataSourceConfig) + { + _dataSource = dataSourceConfig; + return this; + } + + /// + /// Specifies whether true to opt out of sending diagnostic events. + /// + /// + /// Unless this is set to , the client will send some + /// diagnostics data to the LaunchDarkly servers in order to assist in the development + /// of future SDK improvements. These diagnostics consist of an initial payload + /// containing some details of SDK in use, the SDK's configuration, and the platform the + /// SDK is being run on, as well as payloads sent periodically with information on + /// irregular occurrences such as dropped events. + /// + /// to disable diagnostic events + /// the same builder + public ConfigurationBuilder DiagnosticOptOut(bool diagnosticOptOut) + { + _diagnosticOptOut = diagnosticOptOut; + return this; + } + + /// + /// Sets whether to enable feature flag polling when the application is in the background. + /// + /// + /// By default, on Android and iOS the SDK can still receive feature flag updates when an application + /// is in the background, but it will use polling rather than maintaining a streaming connection (and + /// will use the background polling interval rather than the regular polling interval). If you set + /// this property to false, it will not check for feature flag updates until the application returns + /// to the foreground. + /// + /// if background updating should be allowed + /// the same builder + /// + /// + public ConfigurationBuilder EnableBackgroundUpdating(bool enableBackgroundUpdating) + { + _enableBackgroundUpdating = enableBackgroundUpdating; + return this; + } + + /// + /// Set to if LaunchDarkly should provide additional information about how flag values were + /// calculated. + /// + /// + /// The additional information will then be available through the client's "detail" + /// methods such as . Since this + /// increases the size of network requests, such information is not sent unless you set this option + /// to . + /// + /// if evaluation reasons are desired + /// the same builder + public ConfigurationBuilder EvaluationReasons(bool evaluationReasons) + { + _evaluationReasons = evaluationReasons; + return this; + } + + /// + /// Sets the implementation of the component that processes analytics events. + /// + /// + /// + /// The default is , but you may choose to set it to a customized + /// , a custom implementation (for instance, a test fixture), or + /// disable events with . + /// + /// + /// This overwrites any previous options set with . + /// If you want to set multiple options, set them on the same . + /// + /// + /// a builder/factory object for event configuration + /// the same builder + public ConfigurationBuilder Events(IComponentConfigurer eventsConfig) + { + _events = eventsConfig; + return this; + } + + /// + /// Set to to make the SDK provide unique keys for anonymous contexts. + /// + /// + /// + /// If enabled, this option changes the SDK's behavior whenever the (as given to + /// methods like or + /// ) has an + /// property of , as follows: + /// + /// + /// The first time this happens in the application, the SDK will generate a + /// pseudo-random GUID and overwrite the context's with this string. + /// + /// The SDK will then cache this key so that the same key will be reused next time. + /// + /// This uses the same mechanism as the caching of flag values, so if persistent storage + /// is available (see ), the key will persist across restarts; otherwise, + /// it will persist only during the lifetime of the LdClient. + /// + /// + /// If you use multiple s, this behavior is per-kind: that is, a separate + /// randomized key is generated and cached for each context kind. + /// + /// + /// A must always have a key, even if the key will later be overwritten by the + /// SDK, so if you use this functionality you must still provide a placeholder key. This ensures that if + /// the SDK configuration is changed so is no longer enabled, + /// the SDK will still be able to use the context for evaluations. + /// + /// + /// true to enable automatic anonymous key generation + /// the same builder + public ConfigurationBuilder GenerateAnonymousKeys(bool generateAnonymousKeys) + { + _generateAnonymousKeys = generateAnonymousKeys; + return this; + } + + /// + /// Sets the SDK's networking configuration, using a configuration builder obtained from + /// . The builder has methods for setting + /// individual HTTP-related properties. + /// + /// + /// This overwrites any previous options set with . + /// If you want to set multiple options, set them on the same . + /// + /// a builder for HTTP configuration + /// the top-level builder + public ConfigurationBuilder Http(HttpConfigurationBuilder httpConfigurationBuilder) + { + _httpConfigurationBuilder = httpConfigurationBuilder; + return this; + } + + /// + /// Sets the SDK's logging destination. + /// + /// + /// + /// This is a shortcut for Logging(Components.Logging(logAdapter)). You can use it when you + /// only want to specify the basic logging destination, and do not need to set other log properties. + /// + /// + /// For more about how logging works in the SDK, see the LaunchDarkly + /// feature guide. + /// + /// + /// + /// var config = Configuration.Builder("my-sdk-key") + /// .Logging(Logs.ToWriter(Console.Out)) + /// .Build(); + /// + /// an ILogAdapter for the desired logging implementation + /// the same builder + public ConfigurationBuilder Logging(ILogAdapter logAdapter) => + Logging(Components.Logging(logAdapter)); + + /// + /// Sets the SDK's logging configuration, using a configuration builder obtained from + /// . + /// + /// + /// + /// As a shortcut for disabling logging, you may use instead. + /// If all you want to do is to set the basic logging destination, and you do not need to set other + /// logging properties, you can use instead. + /// + /// + /// For more about how logging works in the SDK, see the LaunchDarkly + /// feature guide. + /// + /// + /// This overwrites any previous options set with . + /// If you want to set multiple options, set them on the same . + /// + /// + /// + /// var config = Configuration.Builder("my-sdk-key") + /// .Logging(Components.Logging().Level(LogLevel.Warn))) + /// .Build(); + /// + /// a builder for logging configuration + /// the top-level builder + /// + /// + /// + /// + public ConfigurationBuilder Logging(LoggingConfigurationBuilder loggingConfigurationBuilder) + { + _loggingConfigurationBuilder = loggingConfigurationBuilder; + return this; + } + + /// + /// Sets the key for your LaunchDarkly environment. + /// + /// + /// This should be the "mobile key" field for the environment on your LaunchDarkly dashboard. + /// + /// the mobile key + /// the same builder + public ConfigurationBuilder MobileKey(string mobileKey) + { + _mobileKey = mobileKey; + return this; + } + + /// + /// Sets whether or not this client is offline. If , no calls to LaunchDarkly will be made. + /// + /// if the client should remain offline + /// the same builder + public ConfigurationBuilder Offline(bool offline) + { + _offline = offline; + return this; + } + + /// + /// Sets the SDK's persistent storage configuration, using a configuration builder obtained from + /// . + /// + /// + /// + /// The persistent storage mechanism allows the SDK to immediately access the last known flag data + /// for the user, if any, if it is offline or has not yet received data from LaunchDarkly. + /// + /// + /// By default, the SDK uses a persistence mechanism that is specific to each platform: on Android and + /// iOS it is the native preferences store, and in the .NET Standard implementation for desktop apps + /// it is the System.IO.IsolatedStorage API. You may use the builder methods to substitute a + /// custom implementation or change related parameters. + /// + /// + /// This overwrites any previous options set with this method. If you want to set multiple options, + /// set them on the same . + /// + /// + /// + /// var config = Configuration.Builder("my-sdk-key") + /// .Persistence(Components.Persistence().MaxCachedUsers(10)) + /// .Build(); + /// + /// a builder for persistence configuration + /// the top-level builder + /// + /// + /// + public ConfigurationBuilder Persistence(PersistenceConfigurationBuilder persistenceConfigurationBuilder) + { + _persistenceConfigurationBuilder = persistenceConfigurationBuilder; + return this; + } + + /// + /// Sets the SDK's service URIs, using a configuration builder obtained from + /// . + /// + /// + /// This overwrites any previous options set with . + /// If you want to set multiple options, set them on the same . + /// + /// the subconfiguration builder object + /// the main configuration builder + /// + /// + public ConfigurationBuilder ServiceEndpoints(ServiceEndpointsBuilder serviceEndpointsBuilder) + { + _serviceEndpointsBuilder = serviceEndpointsBuilder; + return this; + } + + // The following properties are internal and settable only for testing. + + internal ConfigurationBuilder BackgroundModeManager(IBackgroundModeManager backgroundModeManager) + { + _backgroundModeManager = backgroundModeManager; + return this; + } + + internal ConfigurationBuilder ConnectivityStateManager(IConnectivityStateManager connectivityStateManager) + { + _connectivityStateManager = connectivityStateManager; + return this; + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/DataModel.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/DataModel.cs new file mode 100644 index 00000000..fe6def93 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/DataModel.cs @@ -0,0 +1,173 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using LaunchDarkly.Sdk.Internal; +using LaunchDarkly.Sdk.Json; + +using static LaunchDarkly.Sdk.Client.Subsystems.DataStoreTypes; +using static LaunchDarkly.Sdk.Internal.JsonConverterHelpers; + +namespace LaunchDarkly.Sdk.Client +{ + /// + /// Contains information about the internal data model for feature flag state. + /// + /// + /// The details of the data model are not public to application code (although of course developers can easily + /// look at the code or the data) so that changes to LaunchDarkly SDK implementation details will not be breaking + /// changes to the application. Therefore, most of the members of this class are internal. The public members + /// provide a high-level description of model objects so that custom integration code or test code can store or + /// serialize them. + /// + public static class DataModel + { + /// + /// Represents the state of a feature flag evaluation received from LaunchDarkly. + /// + [JsonConverter(typeof(FeatureFlagJsonConverter))] + public sealed class FeatureFlag : IEquatable, IJsonSerializable + { + internal LdValue Value { get; } + internal int? Variation { get; } + internal EvaluationReason? Reason { get; } + internal int Version { get; } + internal int? FlagVersion { get; } + internal bool TrackEvents { get; } + internal bool TrackReason { get; } + internal UnixMillisecondTime? DebugEventsUntilDate { get; } + + internal FeatureFlag( + LdValue value, + int? variation, + EvaluationReason? reason, + int version, + int? flagVersion, + bool trackEvents, + bool trackReason, + UnixMillisecondTime? debugEventsUntilDate + ) + { + Value = value; + Variation = variation; + Reason = reason; + Version = version; + FlagVersion = flagVersion; + TrackEvents = trackEvents; + TrackReason = trackReason; + DebugEventsUntilDate = debugEventsUntilDate; + } + + /// + public override bool Equals(object obj) => + Equals(obj as FeatureFlag); + + /// + public bool Equals(FeatureFlag otherFlag) => + Value.Equals(otherFlag.Value) + && Variation == otherFlag.Variation + && Reason.Equals(otherFlag.Reason) + && Version == otherFlag.Version + && FlagVersion == otherFlag.FlagVersion + && TrackEvents == otherFlag.TrackEvents + && DebugEventsUntilDate == otherFlag.DebugEventsUntilDate; + + /// + public override int GetHashCode() => + Value.GetHashCode(); + + /// + public override string ToString() => + string.Format("({0},{1},{2},{3},{4},{5},{6},{7})", + Value, Variation, Reason, Version, FlagVersion, TrackEvents, TrackReason, DebugEventsUntilDate); + + internal ItemDescriptor ToItemDescriptor() => + new ItemDescriptor(Version, this); + } + + internal sealed class FeatureFlagJsonConverter : JsonConverter + { + public override FeatureFlag Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + ReadJsonValue(ref reader); + + public static FeatureFlag ReadJsonValue(ref Utf8JsonReader reader) + { + LdValue value = LdValue.Null; + int version = 0; + int? flagVersion = null; + int? variation = null; + EvaluationReason? reason = null; + bool trackEvents = false; + bool trackReason = false; + UnixMillisecondTime? debugEventsUntilDate = null; + + for (var obj = RequireObject(ref reader); obj.Next(ref reader);) + { + switch (obj.Name) + { + case "value": + value = LdJsonConverters.LdValueConverter.ReadJsonValue(ref reader); + break; + case "version": + version = reader.GetInt32(); + break; + case "flagVersion": + flagVersion = JsonConverterHelpers.GetIntOrNull(ref reader); + break; + case "variation": + variation = JsonConverterHelpers.GetIntOrNull(ref reader); + break; + case "reason": + reason = JsonSerializer.Deserialize(ref reader); + break; + case "trackEvents": + trackEvents = reader.GetBoolean(); + break; + case "trackReason": + trackReason = reader.GetBoolean(); + break; + case "debugEventsUntilDate": + debugEventsUntilDate = JsonSerializer.Deserialize(ref reader); + break; + } + } + + return new FeatureFlag( + value, + variation, + reason, + version, + flagVersion, + trackEvents, + trackReason, + debugEventsUntilDate + ); + } + + public override void Write(Utf8JsonWriter writer, FeatureFlag value, JsonSerializerOptions options) => + WriteJsonValue(value, writer); + + public static void WriteJsonValue(FeatureFlag value, Utf8JsonWriter writer) + { + writer.WriteStartObject(); + + JsonConverterHelpers.WriteLdValue(writer, "value", value.Value); + writer.WriteNumber("version", value.Version); + JsonConverterHelpers.WriteIntIfNotNull(writer, "flagVersion", value.FlagVersion); + JsonConverterHelpers.WriteIntIfNotNull(writer, "variation", value.Variation); + if (value.Reason.HasValue) + { + writer.WritePropertyName("reason"); + JsonSerializer.Serialize(writer, value.Reason.Value); + } + JsonConverterHelpers.WriteBooleanIfTrue(writer, "trackEvents", value.TrackEvents); + JsonConverterHelpers.WriteBooleanIfTrue(writer, "trackReason", value.TrackReason); + if (value.DebugEventsUntilDate.HasValue) + { + writer.WriteNumber("debugEventsUntilDate", value.DebugEventsUntilDate.Value.Value); + } + + writer.WriteEndObject(); + } + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/ILdClientExtensions.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/ILdClientExtensions.cs new file mode 100644 index 00000000..549adaf7 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/ILdClientExtensions.cs @@ -0,0 +1,130 @@ +using System; +using System.Threading.Tasks; +using LaunchDarkly.Sdk.Client.Interfaces; + +namespace LaunchDarkly.Sdk.Client +{ + /// + /// Convenience methods that extend the interface. + /// + /// + /// + /// These allow you to do the following: + /// + /// + /// + /// Treat a string-valued flag as if it referenced values of an enum type. + /// + /// + /// Call methods with the type instead of + /// . The SDK's preferred type for identifying an evaluation context, + /// is ; older versions of the SDK used only the simpler + /// model. These extension methods provide backward compatibility with application code that + /// used the type. Each of them simply converts the User to a Context with + /// and calls the equivalent ILdClient method. + /// For instance, client.Identify(user) is exactly equivalent to + /// client.Identify(Context.FromUser(user)). + /// + /// + /// + /// These are implemented outside of and because they do not + /// rely on any implementation details of ; they are decorators that would work equally + /// well with a stub or test implementation of the interface. + /// + /// + public static class ILdClientExtensions + { + /// + /// Equivalent to , but converts the + /// flag's string value to an enum value. + /// + /// + /// + /// If the flag has a value that is not one of the allowed enum value names, or is not a string, + /// defaultValue is returned. + /// + /// + /// the enum type + /// the client instance + /// the unique feature key for the feature flag + /// the default value of the flag (as an enum value) + /// the variation for the given user, or defaultValue if the flag cannot + /// be evaluated or does not have a valid enum value + public static T EnumVariation(this ILdClient client, string key, T defaultValue) where T : struct, Enum + { + var stringVal = client.StringVariation(key, defaultValue.ToString()); + if (stringVal != null) + { + if (Enum.TryParse(stringVal, out var enumValue)) + { + return enumValue; + } + } + return defaultValue; + } + + /// + /// Equivalent to , but converts the + /// flag's string value to an enum value. + /// + /// + /// + /// If the flag has a value that is not one of the allowed enum value names, or is not a string, + /// defaultValue is returned. + /// + /// + /// the enum type + /// the client instance + /// the unique feature key for the feature flag + /// the default value of the flag (as an enum value) + /// an object + public static EvaluationDetail EnumVariationDetail(this ILdClient client, + string key, T defaultValue) where T : struct, Enum + { + var stringDetail = client.StringVariationDetail(key, defaultValue.ToString()); + if (!stringDetail.IsDefaultValue && stringDetail.Value != null) + { + if (Enum.TryParse(stringDetail.Value, out var enumValue)) + { + return new EvaluationDetail(enumValue, stringDetail.VariationIndex, stringDetail.Reason); + } + return new EvaluationDetail(defaultValue, stringDetail.VariationIndex, EvaluationReason.ErrorReason(EvaluationErrorKind.WrongType)); + } + return new EvaluationDetail(defaultValue, stringDetail.VariationIndex, stringDetail.Reason); + } + + /// + /// Changes the current user, requests flags for that user from LaunchDarkly if we are online, + /// and generates an analytics event to tell LaunchDarkly about the user. + /// + /// + /// This is equivalent to , but using the + /// type instead of . + /// + /// the client instance + /// the user; should not be null (a null reference will cause an error + /// to be logged and no event will be sent + /// the maximum time to wait for the new flag values + /// true if new flag values were obtained + [Obsolete("User has been superseded by Context. See ILdClient.Identify(Context, TimeSpan)")] + public static bool Identify(this ILdClient client, User user, TimeSpan maxWaitTime) => + client.Identify(Context.FromUser(user), maxWaitTime); + + + /// + /// Changes the current user, requests flags for that user from LaunchDarkly if we are online, + /// and generates an analytics event to tell LaunchDarkly about the user. + /// + /// + /// This is equivalent to , but using the + /// type instead of . + /// + /// the client instance + /// the user; should not be null (a null reference will cause an error + /// to be logged and no event will be sent + /// a task that yields true if new flag values were obtained + [Obsolete("User has been superseded by Context. See ILdClient.Identify(Context, TimeSpan)")] + public static Task IdentifyAsync(this ILdClient client, User user) => + client.IdentifyAsync(Context.FromUser(user)); + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/EventProcessorBuilder.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/EventProcessorBuilder.cs new file mode 100644 index 00000000..48250800 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/EventProcessorBuilder.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Client.Internal; +using LaunchDarkly.Sdk.Client.Internal.Events; +using LaunchDarkly.Sdk.Client.Subsystems; +using LaunchDarkly.Sdk.Internal; +using LaunchDarkly.Sdk.Internal.Events; + +using static LaunchDarkly.Sdk.Internal.Events.DiagnosticConfigProperties; + +namespace LaunchDarkly.Sdk.Client.Integrations +{ + /// + /// Contains methods for configuring delivery of analytics events. + /// + /// + /// The SDK normally buffers analytics events and sends them to LaunchDarkly at intervals. If you want + /// to customize this behavior, create a builder with , change its + /// properties with the methods of this class, and pass it to + /// . + /// + /// + /// + /// var config = Configuration.Builder(sdkKey) + /// .Events( + /// Components.SendEvents().Capacity(5000).FlushInterval(TimeSpan.FromSeconds(2)) + /// ) + /// .Build(); + /// + /// + public sealed class EventProcessorBuilder : IComponentConfigurer, IDiagnosticDescription + { + /// + /// The default value for . + /// + public const int DefaultCapacity = 100; + + /// + /// The default value for . + /// + public static readonly TimeSpan DefaultDiagnosticRecordingInterval = TimeSpan.FromMinutes(15); + + /// + /// The default value for . + /// + /// + /// For Android and iOS, this is 30 seconds. For all other platforms, it is 5 seconds. The + /// difference is because the extra HTTP requests for sending events more frequently are + /// undesirable in a mobile application as opposed to a desktop application. + /// + public static readonly TimeSpan DefaultFlushInterval = +#if (ANDROID || IOS) + TimeSpan.FromSeconds(30); +#else + TimeSpan.FromSeconds(5); +#endif + + /// + /// The minimum value for : 5 minutes. + /// + public static readonly TimeSpan MinimumDiagnosticRecordingInterval = TimeSpan.FromMinutes(5); + + internal bool _allAttributesPrivate = false; + internal int _capacity = DefaultCapacity; + internal TimeSpan _diagnosticRecordingInterval = DefaultDiagnosticRecordingInterval; + internal TimeSpan _flushInterval = DefaultFlushInterval; + internal HashSet _privateAttributes = new HashSet(); + internal IEventSender _eventSender = null; // used in testing + + /// + /// Sets whether or not all optional user attributes should be hidden from LaunchDarkly. + /// + /// + /// If this is , all user attribute values (other than the key) will be private, not just + /// the attributes specified in or on a per-user basis with + /// methods. By default, it is . + /// + /// true if all user attributes should be private + /// the builder + public EventProcessorBuilder AllAttributesPrivate(bool allAttributesPrivate) + { + _allAttributesPrivate = allAttributesPrivate; + return this; + } + + /// + /// Sets the capacity of the events buffer. + /// + /// + /// + /// The client buffers up to this many events in memory before flushing. If the capacity is exceeded before + /// the buffer is flushed (see ), events will be discarded. Increasing the + /// capacity means that events are less likely to be discarded, at the cost of consuming more memory. + /// + /// + /// The default value is . A zero or negative value will be changed to the default. + /// + /// + /// the capacity of the event buffer + /// the builder + public EventProcessorBuilder Capacity(int capacity) + { + _capacity = (capacity <= 0) ? DefaultCapacity : capacity; + return this; + } + + /// + /// Sets the interval at which periodic diagnostic data is sent. + /// + /// + /// The default value is ; the minimum value is + /// . This property is ignored if + /// is set to . + /// + /// the diagnostics interval + /// the builder + public EventProcessorBuilder DiagnosticRecordingInterval(TimeSpan diagnosticRecordingInterval) + { + _diagnosticRecordingInterval = + diagnosticRecordingInterval < MinimumDiagnosticRecordingInterval ? + MinimumDiagnosticRecordingInterval : diagnosticRecordingInterval; + return this; + } + + // Used only in testing + internal EventProcessorBuilder DiagnosticRecordingIntervalNoMinimum(TimeSpan diagnosticRecordingInterval) + { + _diagnosticRecordingInterval = diagnosticRecordingInterval; + return this; + } + + // Used only in testing + internal EventProcessorBuilder EventSender(IEventSender eventSender) + { + _eventSender = eventSender; + return this; + } + + /// + /// Sets the interval between flushes of the event buffer. + /// + /// + /// Decreasing the flush interval means that the event buffer is less likely to reach capacity. + /// The default value is . A zero or negative value will be changed to + /// the default. + /// + /// the flush interval + /// the builder + public EventProcessorBuilder FlushInterval(TimeSpan flushInterval) + { + _flushInterval = (flushInterval.CompareTo(TimeSpan.Zero) <= 0) ? + DefaultFlushInterval : flushInterval; + return this; + } + + internal EventProcessorBuilder FlushIntervalNoMinimum(TimeSpan flushInterval) + { + _flushInterval = flushInterval; + return this; + } + + /// + /// Marks a set of attribute names as private. + /// + /// + /// Any contexts sent to LaunchDarkly with this configuration active will have attributes with these + /// names removed. This is in addition to any attributes that were marked as private for an + /// individual context with methods. + /// + /// a set of attributes that will be removed from context data set to LaunchDarkly + /// the builder + public EventProcessorBuilder PrivateAttributes(params string[] attributes) + { + foreach (var a in attributes) + { + _privateAttributes.Add(AttributeRef.FromPath(a)); + } + return this; + } + + /// + public IEventProcessor Build(LdClientContext context) + { + var eventsConfig = MakeEventsConfiguration(context, true); + var logger = context.BaseLogger.SubLogger(LogNames.EventsSubLog); + var eventSender = _eventSender ?? + new DefaultEventSender( + context.Http.HttpProperties, + eventsConfig, + logger + ); + return new DefaultEventProcessorWrapper( + new EventProcessor( + eventsConfig, + eventSender, + null, // no user deduplicator, because the client-side SDK doesn't send index events + context.DiagnosticStore, + context.DiagnosticDisabler, + logger, + null + )); + } + + /// + public LdValue DescribeConfiguration(LdClientContext context) => + LdValue.BuildObject().WithEventProperties( + MakeEventsConfiguration(context, false), + StandardEndpoints.IsCustomUri(context.ServiceEndpoints, e => e.EventsBaseUri) + ) + .Build(); + + private EventsConfiguration MakeEventsConfiguration(LdClientContext context, bool logConfigErrors) + { + var baseUri = StandardEndpoints.SelectBaseUri( + context.ServiceEndpoints, + e => e.EventsBaseUri, + "Events", + logConfigErrors ? context.BaseLogger : Logs.None.Logger("") + ); + return new EventsConfiguration + { + AllAttributesPrivate = _allAttributesPrivate, + EventCapacity = _capacity, + EventFlushInterval = _flushInterval, + EventsUri = baseUri.AddPath(StandardEndpoints.AnalyticsEventsPostRequestPath), + DiagnosticRecordingInterval = _diagnosticRecordingInterval, + DiagnosticUri = baseUri.AddPath(StandardEndpoints.DiagnosticEventsPostRequestPath), + PrivateAttributes = _privateAttributes.ToImmutableHashSet(), + RetryInterval = TimeSpan.FromSeconds(1) + }; + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/HttpConfigurationBuilder.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/HttpConfigurationBuilder.cs new file mode 100644 index 00000000..3810874b --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/HttpConfigurationBuilder.cs @@ -0,0 +1,289 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using LaunchDarkly.Sdk.Client.Internal; +using LaunchDarkly.Sdk.Internal; +using LaunchDarkly.Sdk.Internal.Http; +using LaunchDarkly.Sdk.Client.Subsystems; + +using static LaunchDarkly.Sdk.Internal.Events.DiagnosticConfigProperties; + +namespace LaunchDarkly.Sdk.Client.Integrations +{ + /// + /// Contains methods for configuring the SDK's networking behavior. + /// + /// + /// + /// If you want to set non-default values for any of these properties, create a builder with + /// , change its properties with the methods of this class, and + /// pass it to : + /// + /// + /// + /// var config = Configuration.Builder(sdkKey) + /// .Http( + /// Components.HttpConfiguration() + /// .ConnectTimeout(TimeSpan.FromMilliseconds(3000)) + /// ) + /// .Build(); + /// + /// + /// + public sealed class HttpConfigurationBuilder : IDiagnosticDescription + { + /// + /// The default value for : 10 seconds. + /// + public static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(10); + // deliberately longer than the server-side SDK's default connection timeout + + /// + /// The default value for : 10 seconds. + /// + public static readonly TimeSpan DefaultResponseStartTimeout = TimeSpan.FromSeconds(10); + + internal TimeSpan _connectTimeout = DefaultConnectTimeout; + internal List> _customHeaders = new List>(); + internal HttpMessageHandler _messageHandler = null; + internal IWebProxy _proxy = null; + internal TimeSpan _responseStartTimeout = DefaultResponseStartTimeout; + internal string _wrapperName = null; + internal string _wrapperVersion = null; + internal bool _useReport = false; + + /// + /// Sets the network connection timeout. + /// + /// + /// + /// This is the time allowed for the underlying HTTP client to connect to the + /// LaunchDarkly server, for any individual network connection. + /// + /// + /// It is not the same as the timeout parameter to , + /// which limits the time for initializing the SDK regardless of how many individual HTTP requests + /// are done in that time. + /// + /// + /// Not all .NET platforms support setting a connection timeout. It is supported in + /// .NET Core 2.1+, .NET 5+, and MAUI Android, but not in MAUI iOS. On platforms + /// where it is not supported, only will be used. + /// + /// + /// Also, since this is implemented (on supported platforms) as part of the standard + /// HttpMessageHandler implementation for those platforms, if you have specified + /// some other HTTP handler implementation with , + /// the here will be ignored. + /// + /// + /// the timeout + /// the builder + /// + public HttpConfigurationBuilder ConnectTimeout(TimeSpan connectTimeout) + { + _connectTimeout = connectTimeout; + return this; + } + + /// + /// Specifies a custom HTTP header that should be added to all SDK requests. + /// + /// + /// This may be helpful if you are using a gateway or proxy server that requires a specific header in + /// requests. You may add any number of headers. + /// + /// the header name + /// the header value + /// the builder + public HttpConfigurationBuilder CustomHeader(string name, string value) + { + _customHeaders.Add(new KeyValuePair(name, value)); + return this; + } + + /// + /// Specifies a custom HTTP message handler implementation. + /// + /// + /// This is mainly useful for testing, to cause the SDK to use custom logic instead of actual HTTP requests, + /// but can also be used to customize HTTP behavior on platforms where the default handler is not optimal. + /// The default is the usual native HTTP handler for the current platform, else . + /// + /// the message handler, or null to use the platform's default handler + /// the builder + public HttpConfigurationBuilder MessageHandler(HttpMessageHandler messageHandler) + { + _messageHandler = messageHandler; + return this; + } + + /// + /// Sets an HTTP proxy for making connections to LaunchDarkly. + /// + /// + /// + /// This is ignored if you have specified a custom message handler with , + /// since proxy behavior is implemented by the message handler. + /// + /// + /// Note that this is not the same as the LaunchDarkly + /// Relay Proxy, which would be set with + /// . + /// + /// + /// + /// + /// // Example of using an HTTP proxy with basic authentication + /// + /// var proxyUri = new Uri("http://my-proxy-host:8080"); + /// var proxy = new System.Net.WebProxy(proxyUri); + /// var credentials = new System.Net.CredentialCache(); + /// credentials.Add(proxyUri, "Basic", + /// new System.Net.NetworkCredential("username", "password")); + /// proxy.Credentials = credentials; + /// + /// var config = Configuration.Builder("my-sdk-key") + /// .Http( + /// Components.HttpConfiguration().Proxy(proxy) + /// ) + /// .Build(); + /// + /// + /// any implementation of System.Net.IWebProxy + /// the builder + public HttpConfigurationBuilder Proxy(IWebProxy proxy) + { + _proxy = proxy; + return this; + } + + /// + /// Sets the maximum amount of time to wait for the beginning of an HTTP response. + /// + /// + /// + /// This limits how long the SDK will wait from the time it begins trying to make a + /// network connection for an individual HTTP request to the time it starts receiving + /// any data from the server. It is equivalent to the Timeout property in + /// HttpClient. + /// + /// + /// It is not the same as the timeout parameter to, + /// which limits the time for initializing the SDK regardless of how many individual HTTP requests + /// are done in that time. + /// + /// + /// the timeout + /// the builder + /// + public HttpConfigurationBuilder ResponseStartTimeout(TimeSpan responseStartTimeout) + { + _responseStartTimeout = responseStartTimeout; + return this; + } + + /// + /// Sets whether to use the HTTP REPORT method for feature flag requests. + /// + /// + /// + /// By default, polling and streaming connections are made with the GET method, with the user data + /// encoded into the request URI. Using REPORT allows the user data to be sent in the request body + /// instead, which is somewhat more secure and efficient. + /// + /// + /// However, the REPORT method is not always supported: Android (in the versions tested so far) + /// does not allow it, and some network gateways do not allow it. Therefore it is disabled in the SDK + /// by default. You can enable it if you know your code will not be running on Android and not connecting + /// through a gateway/proxy that disallows REPORT. + /// + /// + /// true to enable the REPORT method + /// the builder +#if !ANDROID + public HttpConfigurationBuilder UseReport(bool useReport) +#else + internal HttpConfigurationBuilder UseReport(bool useReport) +#endif + { + _useReport = useReport; + return this; + } + + /// + /// For use by wrapper libraries to set an identifying name for the wrapper being used. + /// + /// + /// This will be included in a header during requests to the LaunchDarkly servers to allow recording + /// metrics on the usage of these wrapper libraries. + /// + /// an identifying name for the wrapper library + /// version string for the wrapper library + /// the builder + public HttpConfigurationBuilder Wrapper(string wrapperName, string wrapperVersion) + { + _wrapperName = wrapperName; + _wrapperVersion = wrapperVersion; + return this; + } + + /// + /// Called internally by the SDK to create an implementation instance. Applications do not need + /// to call this method. + /// + /// Key for authenticating with LD service + /// Application Info for this application environment + /// an + public HttpConfiguration CreateHttpConfiguration(string authKey, ApplicationInfo? applicationInfo) => + new HttpConfiguration( + MakeHttpProperties(authKey, applicationInfo), + _messageHandler, + _responseStartTimeout, + _useReport + ); + + /// + public LdValue DescribeConfiguration(LdClientContext context) => + LdValue.BuildObject() + .WithHttpProperties(MakeHttpProperties(context.MobileKey, context.EnvironmentReporter.ApplicationInfo)) + .Add("useReport", _useReport) + .Set("socketTimeoutMillis", _responseStartTimeout.TotalMilliseconds) + // WithHttpProperties normally sets socketTimeoutMillis to the ReadTimeout value, + // which is more correct, but we can't really set ReadTimeout in this SDK + .Build(); + + private HttpProperties MakeHttpProperties(string authToken, ApplicationInfo? applicationInfo) + { + Func handlerFn; + if (_messageHandler is null) + { + handlerFn = PlatformSpecific.Http.GetHttpMessageHandlerFactory(_connectTimeout, _proxy); + } + else + { + handlerFn = p => _messageHandler; + } + + var httpProperties = HttpProperties.Default + .WithAuthorizationKey(authToken) + .WithConnectTimeout(_connectTimeout) + .WithHttpMessageHandlerFactory(handlerFn) + .WithProxy(_proxy) + .WithUserAgent(SdkPackage.UserAgent) + .WithWrapper(_wrapperName, _wrapperVersion); + + if (applicationInfo.HasValue) + { + httpProperties = httpProperties.WithApplicationTags(applicationInfo.Value); + } + + foreach (var kv in _customHeaders) + { + httpProperties = httpProperties.WithHeader(kv.Key, kv.Value); + } + return httpProperties; + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/LoggingConfigurationBuilder.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/LoggingConfigurationBuilder.cs new file mode 100644 index 00000000..946c8f97 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/LoggingConfigurationBuilder.cs @@ -0,0 +1,202 @@ +using System; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Client.Subsystems; + +namespace LaunchDarkly.Sdk.Client.Integrations +{ + /// + /// Contains methods for configuring the SDK's logging behavior. + /// + /// + /// + /// If you want to set non-default values for any of these properties, create a builder with + /// , change its properties with the methods of this class, and pass it + /// to . + /// + /// + /// The default behavior, if you do not change any properties, depends on the runtime platform: + /// + /// + /// On Android, it uses + /// android.util.Log. + /// On iOS, it uses the Apple + /// unified logging system + /// (OSLog). + /// On all other platforms, it writes to . + /// + /// + /// By default, the minimum log level is Info (that is, Debug logging is + /// disabled). This can be overridden with . + /// + /// + /// The base logger name is normally LaunchDarkly.Sdk (on iOS, this corresponds to a + /// "subsystem" name of "LaunchDarkly" and a "category" name of "Sdk"). See + /// for more about logger names and how to change the name. + /// + /// + /// + /// + /// var config = Configuration.Builder("my-sdk-key") + /// .Logging(Components.Logging().Level(LogLevel.Warn)) + /// .Build(); + /// + /// + public sealed class LoggingConfigurationBuilder + { + private string _baseLoggerName = null; + private ILogAdapter _logAdapter = null; + private LogLevel? _minimumLevel = null; + + /// + /// Creates a new builder with default properties. + /// + public LoggingConfigurationBuilder() { } + + /// + /// Specifies a custom base logger name. + /// + /// + /// + /// Logger names are used to give context to the log output, indicating that it is from the + /// LaunchDarkly SDK instead of another component, or indicating a more specific area of + /// functionality within the SDK. The default console logging implementation shows the logger + /// name in brackets, for instance: + /// + /// + /// [LaunchDarkly.Sdk.DataSource] INFO: Reconnected to LaunchDarkly stream + /// + /// + /// If you are using an adapter for a third-party logging framework (see + /// ), most frameworks have a mechanism for filtering log + /// output by the logger name. + /// + /// + /// By default, the SDK uses a base logger name of LaunchDarkly.Sdk. Messages will be + /// logged either under this name, or with a suffix to indicate what general area of + /// functionality is involved: + /// + /// + /// .DataSource: problems or status messages regarding how the SDK gets + /// feature flag data from LaunchDarkly. + /// .DataStore: problems or status messages regarding how the SDK stores its + /// feature flag data. + /// .Events problems or status messages regarding the SDK's delivery of + /// analytics event data to LaunchDarkly. + /// + /// + /// Setting BaseLoggerName to a non-null value overrides the default. The SDK still + /// adds the same suffixes to the name, so for instance if you set it to "LD", the + /// example message above would show [LD.DataSource]. + /// + /// + /// When using the default logging framework in iOS, logger names are handled slightly + /// differently because iOS's OSLog has two kinds of logger names: a general + /// "subsystem", and a more specific "category". The SDK handles this by taking everything + /// after the first period in the logger name as the category: for instance, for + /// LaunchDarkly.Sdk.DataSource, the subsystem is LaunchDarkly and the category + /// is Sdk.DataSource. If you set a custom base logger name, the same rules apply, so + /// for instance if you set it to "LD" then then LD.DataSource would become + /// a subsystem of LD and a category of DataSource. + /// + /// + /// the same builder + public LoggingConfigurationBuilder BaseLoggerName(string baseLoggerName) + { + _baseLoggerName = baseLoggerName; + return this; + } + + /// + /// Specifies the implementation of logging to use. + /// + /// + /// + /// The LaunchDarkly.Logging API defines the + /// ILogAdapter interface to specify where log output should be sent. By default, it is set to + /// Logs.ToConsole, meaning that output will be sent to Console.Error. You may use other + /// LaunchDarkly.Logging.Logs methods, or a custom implementation, to handle log output differently. + /// Logs.None disables logging (equivalent to ). + /// + /// + /// For more about logging adapters, see the API + /// documentation for LaunchDarkly.Logging. + /// + /// + /// If you don't need to customize any options other than the adapter, you can call + /// as a shortcut rather than using + /// . + /// + /// + /// + /// + /// // This example configures the SDK to send log output to a file writer. + /// var writer = File.CreateText("sdk.log"); + /// var config = Configuration.Builder("my-sdk-key") + /// .Logging(Components.Logging().Adapter(Logs.ToWriter(writer))) + /// .Build(); + /// + /// + /// an ILogAdapter for the desired logging implementation; + /// to use the default implementation + /// the same builder + public LoggingConfigurationBuilder Adapter(ILogAdapter adapter) + { + _logAdapter = adapter; + return this; + } + + /// + /// Specifies the lowest level of logging to enable. + /// + /// + /// + /// This adds a log level filter that is applied regardless of what implementation of logging is + /// being used, so that log messages at lower levels are suppressed. For instance, setting the + /// minimum level to means that Debug-level output is disabled. + /// External logging frameworks may also have their own mechanisms for setting a minimum log level. + /// + /// + /// If you did not specify an at all, so it is using the default Console.Error + /// destination, then the default minimum logging level is Info. + /// + /// + /// If you did specify an , then the SDK does not apply a level filter by + /// default. This is so as not to interfere with any other configuration that you may have set up + /// in an external logging framework. However, you can still use this method to set a higher level + /// so that any messages below that level will not be sent to the external framework at all. + /// + /// + /// the lowest level of logging to enable + /// the same builder + public LoggingConfigurationBuilder Level(LogLevel minimumLevel) + { + _minimumLevel = minimumLevel; + return this; + } + + /// + /// Called internally by the SDK to create a configuration instance. Applications do not need + /// to call this method. + /// + /// the logging configuration + public LoggingConfiguration CreateLoggingConfiguration() + { + ILogAdapter logAdapter; + if (_logAdapter is null) + { + logAdapter = PlatformSpecific.Logging.DefaultAdapter + .Level(_minimumLevel ?? LogLevel.Info); + } + else + { + logAdapter = _minimumLevel.HasValue ? + _logAdapter.Level(_minimumLevel.Value) : + _logAdapter; + } + return new LoggingConfiguration( + _baseLoggerName, + logAdapter + ); + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/PersistenceConfigurationBuilder.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/PersistenceConfigurationBuilder.cs new file mode 100644 index 00000000..1d88a15f --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/PersistenceConfigurationBuilder.cs @@ -0,0 +1,101 @@ +using LaunchDarkly.Sdk.Client.Internal.DataStores; +using LaunchDarkly.Sdk.Client.Subsystems; + +namespace LaunchDarkly.Sdk.Client.Integrations +{ + /// + /// Contains methods for configuring the SDK's persistent storage behavior. + /// + /// + /// + /// The persistent storage mechanism allows the SDK to immediately access the last known flag data + /// for the user, if any, if it was started offline or has not yet received data from LaunchDarkly. + /// + /// + /// By default, the SDK uses a persistence mechanism that is specific to each platform, as + /// described in . To use a custom persistence + /// implementation, or to customize related properties defined in this class, create a builder with + /// , change its properties with the methods of this class, and + /// pass it to . + /// + /// + /// + /// + /// var config = Configuration.Builder(sdkKey) + /// .Persistence( + /// Components.Persistence().MaxCachedUsers(5) + /// ) + /// .Build(); + /// + /// + public sealed class PersistenceConfigurationBuilder + { + /// + /// Default value for : 5. + /// + public const int DefaultMaxCachedContexts = 5; + + /// + /// Passing this value (or any negative number) to + /// means there is no limit on cached user data. + /// + public const int UnlimitedCachedContexts = -1; + + private IComponentConfigurer _storeFactory = null; + private int _maxCachedContexts = DefaultMaxCachedContexts; + + internal PersistenceConfigurationBuilder() { } + + /// + /// Sets the storage implementation. + /// + /// + /// By default, the SDK uses a persistence mechanism that is specific to each platform: on Android and + /// iOS it is the native preferences store, and in the .NET Standard implementation for desktop apps + /// it is the System.IO.IsolatedStorage API. You may use this method to specify a custom + /// implementation using a factory object. + /// + /// a factory for the custom storage implementation, or + /// to use the default implementation + /// the builder + public PersistenceConfigurationBuilder Storage(IComponentConfigurer persistentDataStoreFactory) + { + _storeFactory = persistentDataStoreFactory; + return this; + } + + /// + /// Sets the maximum number of users to store flag data for. + /// + /// + /// + /// A value greater than zero means that the SDK will use persistent storage to remember the last + /// known flag values for up to that number of unique user keys. If the limit is exceeded, the SDK + /// discards the data for the least recently used user. + /// + /// + /// A value of zero means that the SDK will not use persistent storage; it will only have whatever + /// flag data it has received since the current LdClient instance was started. + /// + /// + /// A value of or any other negative number means there is no + /// limit. Use this mode with caution, as it could cause the size of mobile device preferences to + /// grow indefinitely if your application uses many different user keys on the same device. + /// + /// + /// + /// the builder + public PersistenceConfigurationBuilder MaxCachedContexts(int maxCachedContexts) + { + _maxCachedContexts = maxCachedContexts; + return this; + } + + internal PersistenceConfiguration Build(LdClientContext clientContext) => + new PersistenceConfiguration( + _storeFactory is null ? PlatformSpecific.LocalStorage.Instance : + _storeFactory.Build(clientContext), + _maxCachedContexts + ); + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/PollingDataSourceBuilder.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/PollingDataSourceBuilder.cs new file mode 100644 index 00000000..44f289bb --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/PollingDataSourceBuilder.cs @@ -0,0 +1,132 @@ +using System; +using LaunchDarkly.Sdk.Client.Internal; +using LaunchDarkly.Sdk.Client.Internal.DataSources; +using LaunchDarkly.Sdk.Client.Subsystems; + +using static LaunchDarkly.Sdk.Internal.Events.DiagnosticConfigProperties; + +namespace LaunchDarkly.Sdk.Client.Integrations +{ + /// + /// Contains methods for configuring the polling data source. + /// + /// + /// + /// Polling is not the default behavior; by default, the SDK uses a streaming connection to receive feature flag + /// data from LaunchDarkly. In polling mode, the SDK instead makes a new HTTP request to LaunchDarkly at regular + /// intervals. HTTP caching allows it to avoid redundantly downloading data if there have been no changes, but + /// polling is still less efficient than streaming and should only be used on the advice of LaunchDarkly support. + /// + /// + /// To use polling mode, create a builder with , change its properties + /// with the methods of this class, and pass it to . + /// + /// + /// Setting to will supersede this + /// setting and completely disable network requests. + /// + /// + /// + /// + /// var config = Configuration.Builder(sdkKey) + /// .DataSource(Components.PollingDataSource() + /// .PollInterval(TimeSpan.FromSeconds(45))) + /// .Build(); + /// + /// + public sealed class PollingDataSourceBuilder : IComponentConfigurer, IDiagnosticDescription + { + /// + /// The default value for : 5 minutes. + /// + public static readonly TimeSpan DefaultPollInterval = TimeSpan.FromMinutes(5); + + internal TimeSpan _backgroundPollInterval = Configuration.DefaultBackgroundPollInterval; + internal TimeSpan _pollInterval = DefaultPollInterval; + + /// + /// Sets the interval between feature flag updates when the application is running in the background. + /// + /// + /// This is only relevant on mobile platforms. The default is ; + /// the minimum is . + /// + /// the background polling interval + /// the same builder + /// + public PollingDataSourceBuilder BackgroundPollInterval(TimeSpan backgroundPollInterval) + { + _backgroundPollInterval = (backgroundPollInterval < Configuration.MinimumBackgroundPollInterval) ? + Configuration.MinimumBackgroundPollInterval : backgroundPollInterval; + return this; + } + + /// + /// Sets the interval at which the SDK will poll for feature flag updates. + /// + /// + /// The default and minimum value is . Values less than this will + /// be set to the default. + /// + /// the polling interval + /// the builder + public PollingDataSourceBuilder PollInterval(TimeSpan pollInterval) + { + _pollInterval = (pollInterval < DefaultPollInterval) ? + DefaultPollInterval : + pollInterval; + return this; + } + + // Exposed internally for testing + internal PollingDataSourceBuilder PollIntervalNoMinimum(TimeSpan pollInterval) + { + _pollInterval = pollInterval; + return this; + } + + /// + public IDataSource Build(LdClientContext clientContext) + { + if (!clientContext.InBackground) + { + clientContext.BaseLogger.Warn("You should only disable the streaming API if instructed to do so by LaunchDarkly support"); + } + var baseUri = StandardEndpoints.SelectBaseUri( + clientContext.ServiceEndpoints, + e => e.PollingBaseUri, + "Polling", + clientContext.BaseLogger + ); + + var logger = clientContext.BaseLogger.SubLogger(LogNames.DataSourceSubLog); + var requestor = new FeatureFlagRequestor( + baseUri, + clientContext.CurrentContext, + clientContext.EvaluationReasons, + clientContext.Http, + logger + ); + + return new PollingDataSource( + clientContext.DataSourceUpdateSink, + clientContext.CurrentContext, + requestor, + _pollInterval, + TimeSpan.Zero, + clientContext.TaskExecutor, + logger + ); + } + + /// + public LdValue DescribeConfiguration(LdClientContext context) => + LdValue.BuildObject() + .WithPollingProperties( + StandardEndpoints.IsCustomUri(context.ServiceEndpoints, e => e.PollingBaseUri), + _pollInterval + ) + .Add("backgroundPollingIntervalMillis", _backgroundPollInterval.TotalMilliseconds) + .Build(); + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/ServiceEndpointsBuilder.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/ServiceEndpointsBuilder.cs new file mode 100644 index 00000000..f0e0ded5 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/ServiceEndpointsBuilder.cs @@ -0,0 +1,263 @@ +using System; +using LaunchDarkly.Sdk.Client.Interfaces; +using LaunchDarkly.Sdk.Client.Internal; + +namespace LaunchDarkly.Sdk.Client.Integrations +{ + /// + /// Contains methods for configuring the SDK's service URIs. + /// + /// + /// + /// If you want to set non-default values for any of these properties, create a builder with + /// , change its properties with the methods of this class, and pass it + /// to . + /// + /// + /// The default behavior, if you do not change any of these properties, is that the SDK will connect + /// to the standard endpoints in the LaunchDarkly production service. There are several use cases for + /// changing these properties: + /// + /// + /// + /// You are using the LaunchDarkly + /// Relay Proxy. In this case, set to the base URI of the Relay Proxy + /// instance. Note that this is not the same as a regular HTTP proxy, which would be set with + /// . + /// + /// + /// You are connecting to a private instance of LaunchDarkly, rather than the standard production + /// services. In this case, there will be custom base URIs for each service, so you must set + /// , , and . + /// + /// + /// You are connecting to a test fixture that simulates the service endpoints. In this case, you + /// may set the base URIs to whatever you want, although the SDK will still set the URI paths to + /// the expected paths for LaunchDarkly services. + /// + /// + /// + /// Each of the setter methods can be called with either a or an equivalent + /// string. Passing a string that is not a valid URI will cause an immediate + /// + /// + /// + /// If you are using a private instance and you set some of the base URIs, but not all of them, + /// the SDK will log an error and may not work properly. The only exception is if you have explicitly + /// disabled the SDK's use of one of the services: for instance, if you have disabled analytics + /// events with , you do not have to set . + /// + /// + /// + /// + /// // Example of specifying a Relay Proxy instance + /// var config = Configuration.Builder(mobileKey) + /// .ServiceEndpoints(Components.ServiceEndpoints() + /// .RelayProxy("http://my-relay-hostname:8080")) + /// .Build(); + /// + /// // Example of specifying a private LaunchDarkly instance + /// var config = Configuration.Builder(mobileKey) + /// .ServiceEndpoints(Components.ServiceEndpoints() + /// .Streaming("https://stream.mycompany.launchdarkly.com") + /// .Polling("https://app.mycompany.launchdarkly.com") + /// .Events("https://events.mycompany.launchdarkly.com")) + /// .Build(); + /// + /// + public class ServiceEndpointsBuilder + { + private Uri _streamingBaseUri = null; + private Uri _pollingBaseUri = null; + private Uri _eventsBaseUri = null; + + internal ServiceEndpointsBuilder() { } + + internal ServiceEndpointsBuilder(ServiceEndpoints copyFrom) + { + _streamingBaseUri = copyFrom.StreamingBaseUri; + _pollingBaseUri = copyFrom.PollingBaseUri; + _eventsBaseUri = copyFrom.EventsBaseUri; + } + + /// + /// Sets a custom base URI for the events service. + /// + /// + /// You should only call this method if you are using a private instance or a test fixture + /// (see ). If you are using the LaunchDarkly Relay Proxy, + /// call instead. + /// + /// + /// var config = Configuration.Builder(mobileKey) + /// .ServiceEndpoints(Components.ServiceEndpoints() + /// .Streaming("https://stream.mycompany.launchdarkly.com") + /// .Polling("https://app.mycompany.launchdarkly.com") + /// .Events("https://events.mycompany.launchdarkly.com")) + /// .Build(); + /// + /// the base URI of the events service; null to use the default + /// the builder + /// + public ServiceEndpointsBuilder Events(Uri eventsBaseUri) + { + _eventsBaseUri = eventsBaseUri; + return this; + } + + /// + /// Equivalent to , specifying the URI as a string. + /// + /// the base URI of the events service, or + /// to reset to the default + /// the same builder + /// if the string is not null and is not a valid URI + /// + public ServiceEndpointsBuilder Events(string eventsBaseUri) => + Events(new Uri(eventsBaseUri)); + + /// + /// Sets a custom base URI for the polling service. + /// + /// + /// You should only call this method if you are using a private instance or a test fixture + /// (see ). If you are using the LaunchDarkly Relay Proxy, + /// call instead. + /// + /// + /// var config = Configuration.Builder(mobileKey) + /// .ServiceEndpoints(Components.ServiceEndpoints() + /// .Streaming("https://stream.mycompany.launchdarkly.com") + /// .Polling("https://app.mycompany.launchdarkly.com") + /// .Events("https://events.mycompany.launchdarkly.com")) + /// .Build(); + /// + /// the base URI of the polling service; null to use the default + /// the builder + /// + public ServiceEndpointsBuilder Polling(Uri pollingBaseUri) + { + _pollingBaseUri = pollingBaseUri; + return this; + } + + /// + /// Equivalent to , specifying the URI as a string. + /// + /// the base URI of the polling service, or + /// to reset to the default + /// the same builder + /// if the string is not null and is not a valid URI + /// + public ServiceEndpointsBuilder Polling(string pollingBaseUri) => + Polling(new Uri(pollingBaseUri)); + + /// + /// Specifies a single base URI for a Relay Proxy instance. + /// + /// + /// + /// When using the LaunchDarkly Relay Proxy, + /// the SDK only needs to know the single base URI of the Relay Proxy, which will provide all of the + /// proxied service endpoints. + /// + /// + /// Note that this is not the same as a regular HTTP proxy, which would be set with + /// . + /// + /// + /// + /// + /// var relayUri = new Uri("http://my-relay-hostname:8080"); + /// var config = Configuration.Builder(mobileKey) + /// .ServiceEndpoints(Components.ServiceEndpoints().RelayProxy(relayUri)) + /// .Build(); + /// + /// + /// the Relay Proxy base URI, or + /// to reset to default endpoints + /// the builder + /// + public ServiceEndpointsBuilder RelayProxy(Uri relayProxyBaseUri) + { + _streamingBaseUri = relayProxyBaseUri; + _pollingBaseUri = relayProxyBaseUri; + _eventsBaseUri = relayProxyBaseUri; + return this; + } + + /// + /// Equivalent to , specifying the URI as a string. + /// + /// the Relay Proxy base URI, or + /// to reset to default endpoints + /// the same builder + /// if the string is not null and is not a valid URI + /// + public ServiceEndpointsBuilder RelayProxy(string relayProxyBaseUri) => + RelayProxy(new Uri(relayProxyBaseUri)); + + /// + /// Sets a custom base URI for the streaming service. + /// + /// + /// + /// You should only call this method if you are using a private instance or a test fixture + /// (see ). If you are using the LaunchDarkly Relay Proxy, + /// call instead. + /// + /// + /// If you set a custom Streaming URI, you must also set a custom Polling URI. + /// Even when in streaming mode, the SDK may sometimes need to make a request to the polling + /// service. + /// + /// + /// + /// var config = Configuration.Builder(mobileKey) + /// .ServiceEndpoints(Components.ServiceEndpoints() + /// .Streaming("https://stream.mycompany.launchdarkly.com") + /// .Polling("https://app.mycompany.launchdarkly.com") + /// .Events("https://events.mycompany.launchdarkly.com")) + /// .Build(); + /// + /// the base URI of the streaming service; null to use the default + /// the builder + /// + public ServiceEndpointsBuilder Streaming(Uri streamingBaseUri) + { + _streamingBaseUri = streamingBaseUri; + return this; + } + + /// + /// Equivalent to , specifying the URI as a string. + /// + /// the base URI of the streaming service, or + /// to reset to the default + /// the same builder + /// if the string is not null and is not a valid URI + /// + public ServiceEndpointsBuilder Streaming(string streamingBaseUri) => + Streaming(new Uri(streamingBaseUri)); + + /// + /// Called internally by the SDK to create a configuration instance. Applications do not need + /// to call this method. + /// + /// the configuration object + public ServiceEndpoints Build() + { + // The logic here is based on the assumption that if *any* custom URIs have been set, + // then we do not want to use default values for any that were not set, so we will leave + // those null. That way, if we decide later on (in other component factories, such as + // EventProcessorBuilder) that we are actually interested in one of these values, and we + // see that it is null, we can assume that there was a configuration mistake and log an + // error. + if (_streamingBaseUri is null && _pollingBaseUri is null && _eventsBaseUri is null) + { + return StandardEndpoints.BaseUris; + } + return new ServiceEndpoints(_streamingBaseUri, _pollingBaseUri, _eventsBaseUri); + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/StreamingDataSourceBuilder.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/StreamingDataSourceBuilder.cs new file mode 100644 index 00000000..0afd5ee6 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/StreamingDataSourceBuilder.cs @@ -0,0 +1,144 @@ +using System; +using LaunchDarkly.Sdk.Client.Internal; +using LaunchDarkly.Sdk.Client.Internal.DataSources; +using LaunchDarkly.Sdk.Client.Subsystems; + +using static LaunchDarkly.Sdk.Internal.Events.DiagnosticConfigProperties; + +namespace LaunchDarkly.Sdk.Client.Integrations +{ + /// + /// Contains methods for configuring the streaming data source. + /// + /// + /// + /// By default, the SDK uses a streaming connection to receive feature flag data from LaunchDarkly. If you want + /// to customize the behavior of the connection, create a builder with , + /// change its properties with the methods of this class, and pass it to + /// . + /// + /// + /// Setting to will supersede this + /// setting and completely disable network requests. + /// + /// + /// + /// + /// var config = Configuration.Builder(sdkKey) + /// .DataSource(Components.PollingDataSource() + /// .PollInterval(TimeSpan.FromSeconds(45))) + /// .Build(); + /// + /// + public sealed class StreamingDataSourceBuilder : IComponentConfigurer, IDiagnosticDescription + { + /// + /// The default value for : 1000 milliseconds. + /// + public static readonly TimeSpan DefaultInitialReconnectDelay = TimeSpan.FromSeconds(1); + + internal TimeSpan _backgroundPollInterval = Configuration.DefaultBackgroundPollInterval; + internal TimeSpan _initialReconnectDelay = DefaultInitialReconnectDelay; + + /// + /// Sets the interval between feature flag updates when the application is running in the background. + /// + /// + /// This is only relevant on mobile platforms. The default is ; + /// the minimum is . + /// + /// the background polling interval + /// the same builder + /// + public StreamingDataSourceBuilder BackgroundPollInterval(TimeSpan backgroundPollInterval) + { + _backgroundPollInterval = (backgroundPollInterval < Configuration.MinimumBackgroundPollInterval) ? + Configuration.MinimumBackgroundPollInterval : backgroundPollInterval; + return this; + } + + internal StreamingDataSourceBuilder BackgroundPollingIntervalWithoutMinimum(TimeSpan backgroundPollInterval) + { + _backgroundPollInterval = backgroundPollInterval; + return this; + } + + /// + /// Sets the initial reconnect delay for the streaming connection. + /// + /// + /// + /// The streaming service uses a backoff algorithm (with jitter) every time the connection needs + /// to be reestablished.The delay for the first reconnection will start near this value, and then + /// increase exponentially for any subsequent connection failures. + /// + /// + /// The default value is . + /// + /// + /// the reconnect time base value + /// the builder + public StreamingDataSourceBuilder InitialReconnectDelay(TimeSpan initialReconnectDelay) + { + _initialReconnectDelay = initialReconnectDelay; + return this; + } + + /// + public IDataSource Build(LdClientContext clientContext) + { + var baseUri = StandardEndpoints.SelectBaseUri( + clientContext.ServiceEndpoints, + e => e.StreamingBaseUri, + "Streaming", + clientContext.BaseLogger + ); + var pollingBaseUri = StandardEndpoints.SelectBaseUri( + clientContext.ServiceEndpoints, + e => e.PollingBaseUri, + "Polling", + clientContext.BaseLogger + ); + + if (clientContext.InBackground) + { + // When in the background, always use polling instead of streaming + return new PollingDataSourceBuilder() + .BackgroundPollInterval(_backgroundPollInterval) + .Build(clientContext); + } + + var logger = clientContext.BaseLogger.SubLogger(LogNames.DataSourceSubLog); + var requestor = new FeatureFlagRequestor( + pollingBaseUri, + clientContext.CurrentContext, + clientContext.EvaluationReasons, + clientContext.Http, + logger + ); + + return new StreamingDataSource( + clientContext.DataSourceUpdateSink, + clientContext.CurrentContext, + baseUri, + clientContext.EvaluationReasons, + _initialReconnectDelay, + requestor, + clientContext.Http, + logger, + clientContext.DiagnosticStore + ); + } + + /// + public LdValue DescribeConfiguration(LdClientContext context) => + LdValue.BuildObject() + .WithStreamingProperties( + StandardEndpoints.IsCustomUri(context.ServiceEndpoints, e => e.PollingBaseUri), + StandardEndpoints.IsCustomUri(context.ServiceEndpoints, e => e.StreamingBaseUri), + _initialReconnectDelay + ) + .Add("backgroundPollingIntervalMillis", _backgroundPollInterval.TotalMilliseconds) + .Build(); + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/TestData.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/TestData.cs new file mode 100644 index 00000000..d8d3fcf7 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Integrations/TestData.cs @@ -0,0 +1,690 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading.Tasks; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Client.Interfaces; +using LaunchDarkly.Sdk.Client.Internal; +using LaunchDarkly.Sdk.Client.Subsystems; + +using static LaunchDarkly.Sdk.Client.DataModel; +using static LaunchDarkly.Sdk.Client.Subsystems.DataStoreTypes; + +namespace LaunchDarkly.Sdk.Client.Integrations +{ + /// + /// A mechanism for providing dynamically updatable feature flag state in a simplified form to an SDK + /// client in test scenarios. + /// + /// + /// + /// This mechanism does not use any external resources. It provides only the data that the application + /// has put into it using the method. + /// + /// + /// The example code below uses a simple boolean flag, but more complex configurations are possible using + /// the methods of the that is returned by . + /// + /// + /// If the same instance is used to configure multiple + /// instances, any changes made to the data will propagate to all of the s. + /// + /// + /// + /// + /// var td = TestData.DataSource(); + /// td.Update(td.Flag("flag-key-1").BooleanFlag().Variation(true)); + /// + /// var config = Configuration.Builder("sdk-key") + /// .DataSource(td) + /// .Build(); + /// var client = new LdClient(config); + /// + /// // flags can be updated at any time: + /// td.update(testData.flag("flag-key-2") + /// .VariationForUser("some-user-key", false)); + /// + /// + public sealed class TestData : IComponentConfigurer + { + #region Private fields + + private readonly object _lock = new object(); + private readonly Dictionary _currentFlagVersions = + new Dictionary(); + private readonly Dictionary _currentBuilders = + new Dictionary(); + private readonly List _instances = new List(); + + #endregion + + #region Private constructor + + private TestData() { } + + #endregion + + #region Public methods + + /// + /// Creates a new instance of the test data source. + /// + /// + /// See for details. + /// + /// a new configurable test data source + public static TestData DataSource() => new TestData(); + + /// + /// Creates or copies a for building a test flag configuration. + /// + /// + /// + /// If this flag key has already been defined in this instance, then + /// the builder starts with the same configuration that was last provided for this flag. + /// + /// + /// Otherwise, it starts with a new default configuration in which the flag has true + /// and false variations, and is true by default for all contexts. You can change + /// any of those properties, and provide more complex behavior, using the + /// methods. + /// + /// + /// Once you have set the desired configuration, pass the builder to + /// . + /// + /// + /// the flag key + /// a flag configuration builder + /// + public FlagBuilder Flag(string key) + { + FlagBuilder existingBuilder; + lock (_lock) + { + _currentBuilders.TryGetValue(key, out existingBuilder); + } + if (existingBuilder != null) + { + return new FlagBuilder(existingBuilder); + } + return new FlagBuilder(key).BooleanFlag(); + } + + /// + /// Updates the test data with the specified flag configuration. + /// + /// + /// + /// This has the same effect as if a flag were added or modified on the LaunchDarkly dashboard. + /// It immediately propagates the flag change to any instance(s) that + /// you have already configured to use this . If no + /// has been started yet, it simply adds this flag to the test data which will be provided to any + /// that you subsequently configure. + /// + /// + /// Any subsequent changes to this instance do not affect the test data, + /// unless you call again. + /// + /// + /// a flag configuration builder + /// the same instance + /// + public TestData Update(FlagBuilder flagBuilder) + { + var key = flagBuilder._key; + var clonedBuilder = new FlagBuilder(flagBuilder); + UpdateInternal(key, clonedBuilder); + return this; + } + + private void UpdateInternal(string key, FlagBuilder builder) + { + DataSourceImpl[] instances; + int newVersion; + + lock (_lock) + { + if (!_currentFlagVersions.TryGetValue(key, out var oldVersion)) + { + oldVersion = 0; + } + newVersion = oldVersion + 1; + _currentFlagVersions[key] = newVersion; + if (builder is null) + { + _currentBuilders.Remove(key); + } + else + { + _currentBuilders[key] = builder; + } + instances = _instances.ToArray(); + } + + foreach (var instance in instances) + { + instance.DoUpdate(key, builder.CreateFlag(newVersion, instance.Context)); + } + } + + /// + /// Simulates a change in the data source status. + /// + /// + /// Use this if you want to test the behavior of application code that uses + /// to track whether the data source is having + /// problems (for example, a network failure interrupting the streaming connection). It does + /// not actually stop the data source from working, so even if you have + /// simulated an outage, calling will still send updates. + /// + /// one of the constants defined by + /// an optional instance + /// the same instance + public TestData UpdateStatus(DataSourceState newState, DataSourceStatus.ErrorInfo? newError) + { + DataSourceImpl[] instances; + lock (_lock) + { + instances = _instances.ToArray(); + } + foreach (var instance in instances) + { + instance.DoUpdateStatus(newState, newError); + } + return this; + } + + /// + public IDataSource Build(LdClientContext clientContext) + { + var instance = new DataSourceImpl( + this, + clientContext.DataSourceUpdateSink, + clientContext.CurrentContext, + clientContext.BaseLogger.SubLogger("DataSource.TestData") + ); + lock (_lock) + { + _instances.Add(instance); + } + return instance; + } + + internal FullDataSet MakeInitData(Context context) + { + lock (_lock) + { + var b = ImmutableList.CreateBuilder>(); + foreach (var fb in _currentBuilders) + { + if (!_currentFlagVersions.TryGetValue(fb.Key, out var version)) + { + version = 1; + _currentFlagVersions[fb.Key] = version; + } + b.Add(new KeyValuePair(fb.Key, + fb.Value.CreateFlag(version, context))); + } + return new FullDataSet(b.ToImmutable()); + } + } + + internal void ClosedInstance(DataSourceImpl instance) + { + lock (_lock) + { + _instances.Remove(instance); + } + } + + #endregion + + #region Public inner types + + /// + /// A builder for feature flag configurations to be used with . + /// + /// + /// + public sealed class FlagBuilder + { + #region Private/internal fields + + private const int TrueVariationForBoolean = 0; + private const int FalseVariationForBoolean = 1; + + internal readonly string _key; + private List _variations; + private int _defaultVariation; + Dictionary> _variationByContextKey = + new Dictionary>(); + private Func _variationFunc; + private FeatureFlag _preconfiguredFlag; + + #endregion + + #region Internal constructors + + internal FlagBuilder(string key) + { + _key = key; + _variations = new List(); + _defaultVariation = 0; + } + + internal FlagBuilder(FlagBuilder from) + { + _key = from._key; + _variations = new List(from._variations); + _defaultVariation = from._defaultVariation; + _variationFunc = from._variationFunc; + foreach (var kv in from._variationByContextKey) + { + _variationByContextKey[kv.Key] = new Dictionary(kv.Value); + } + _preconfiguredFlag = from._preconfiguredFlag; + } + + #endregion + + #region Public methods + + /// + /// A shortcut for setting the flag to use the standard boolean configuration. + /// + /// + /// This is the default for all new flags created with . + /// The flag will have two variations, true and false (in that order). When + /// using evaluation reasons, the reason will be set to + /// whenever the value is true, and whenever the + /// value is false. + /// + /// the builder + public FlagBuilder BooleanFlag() => + IsBooleanFlag ? this : Variations(LdValue.Of(true), LdValue.Of(false)); + + /// + /// Sets the flag to return the specified boolean variation for all contexts by default. + /// + /// + /// The flag's variations are set to true and false if they are not already + /// (equivalent to calling ). + /// + /// the desired true/false variation to be returned for all users + /// the builder + public FlagBuilder Variation(bool variation) => + BooleanFlag().Variation(VariationForBoolean(variation)); + + /// + /// Sets the flag to return the specified variation for all contexts by default. + /// + /// + /// The variation is specified by number, out of whatever variation values have already been + /// defined. + /// + /// the desired variation: 0 for the first, 1 for the second, etc. + /// the builder + public FlagBuilder Variation(int variationIndex) + { + _defaultVariation = variationIndex; + return this; + } + + /// + /// Sets the flag to return the specified variation value for all contexts by default. + /// + /// + /// The value may be of any JSON type, as defined by . If the value + /// matches one of the values previously specified with , + /// then the variation index is set to the index of that value. Otherwise, the value is + /// added to the variation list. + /// + /// the desired value to be returned for all users + /// the builder + public FlagBuilder Variation(LdValue value) + { + AddVariationIfNotDefined(value); + _defaultVariation = _variations.IndexOf(value); + _variationFunc = null; + return this; + } + + /// + /// Sets the flag to return the specified boolean variation for a specific user key, + /// overriding any other defaults. + /// + /// + /// The flag's variations are set to true and false if they are not already + /// (equivalent to calling ). + /// + /// the user key + /// the desired true/false variation to be returned for this user + /// the builder + /// + /// + /// + public FlagBuilder VariationForUser(string userKey, bool variation) => + VariationForKey(ContextKind.Default, userKey, variation); + + /// + /// Sets the flag to return the specified variation for a specific user key, overriding + /// any other defaults. + /// + /// + /// The variation is specified by number, out of whatever variation values have already been + /// defined. + /// + /// the user key + /// the desired variation to be returned for this user when + /// targeting is on: 0 for the first, 1 for the second, etc. + /// the builder + /// + /// + /// + public FlagBuilder VariationForUser(string userKey, int variationIndex) => + VariationForKey(ContextKind.Default, userKey, variationIndex); + + /// + /// Sets the flag to return the specified variation value for a specific user key, overriding + /// any other defaults. + /// + /// + /// The value may be of any JSON type, as defined by . If the value + /// matches one of the values previously specified with , + /// then the variation index is set to the index of that value. Otherwise, the value is + /// added to the variation list. + /// + /// a user key + /// the desired value to be returned for this user + /// the builder + /// + /// + /// + public FlagBuilder VariationForUser(string userKey, LdValue value) => + VariationForKey(ContextKind.Default, userKey, value); + + /// + /// Sets the flag to return the specified boolean variation for a specific context by kind + /// and key, overriding any other defaults. + /// + /// + /// The flag's variations are set to true and false if they are not already + /// (equivalent to calling ). + /// + /// the context kind + /// the context key + /// the desired true/false variation to be returned for this context + /// the builder + /// + /// + /// + public FlagBuilder VariationForKey(ContextKind contextKind, string contextKey, bool variation) => + BooleanFlag().VariationForKey(contextKind, contextKey, VariationForBoolean(variation)); + + /// + /// Sets the flag to return the specified variation for a specific context by kind and key, + /// overriding any other defaults. + /// + /// + /// The variation is specified by number, out of whatever variation values have already been + /// defined. + /// + /// the context kind + /// the context key + /// the desired variation to be returned for this context when + /// targeting is on: 0 for the first, 1 for the second, etc. + /// the builder + /// + /// + /// + public FlagBuilder VariationForKey(ContextKind contextKind, string contextKey, int variationIndex) + { + if (!_variationByContextKey.TryGetValue(contextKind, out var keys)) + { + keys = new Dictionary(); + _variationByContextKey[contextKind] = keys; + } + keys[contextKey] = variationIndex; + return this; + } + + /// + /// Sets the flag to return the specified variation value for a specific context by kind and + /// key, overriding any other defaults. + /// + /// + /// The value may be of any JSON type, as defined by . If the value + /// matches one of the values previously specified with , + /// then the variation index is set to the index of that value. Otherwise, the value is + /// added to the variation list. + /// + /// the context kind + /// the context key + /// the desired value to be returned for this context + /// the builder + /// + /// + /// + public FlagBuilder VariationForKey(ContextKind contextKind, string contextKey, LdValue value) => + VariationForKey(contextKind, contextKey, AddVariationIfNotDefined(value)); + + /// + /// Sets the flag to use a function to determine whether to return true or false for + /// any given context. + /// + /// + /// + /// The function takes an evaluation context and returns , , + /// or . A result means that the flag will + /// fall back to its default variation for all contexts. + /// + /// + /// The flag's variations are set to true and false if they are not already + /// (equivalent to calling ). + /// + /// + /// This function is called only if the context was not specifically targeted with + /// or . + /// + /// + /// a function to determine the variation + /// the builder + public FlagBuilder VariationFunc(Func variationFunc) => + BooleanFlag().VariationFunc(context => + { + var b = variationFunc(context); + return b.HasValue ? VariationForBoolean(b.Value) : (int?)null; + }); + + /// + /// Sets the flag to use a function to determine the variation index to return for + /// any given context. + /// + /// + /// + /// The function takes an evaluation context and returns an integer variation index or . + /// A result means that the flag will fall back to its default + /// variation for all contexts. + /// + /// + /// This function is called only if the context was not specifically targeted with + /// or . + /// + /// + /// a function to determine the variation + /// the builder + public FlagBuilder VariationFunc(Func variationFunc) + { + _variationFunc = variationFunc; + return this; + } + + /// + /// Sets the flag to use a function to determine the variation value to return for + /// any given context. + /// + /// + /// + /// The function takes an evaluation context and returns an or . + /// A result means that the flag will fall back to its default + /// variation for all contexts. + /// + /// + /// The value returned by the function must be one of the values previously specified + /// with ; otherwise it will be ignored. + /// + /// + /// This function is called only if the context was not specifically targeted with + /// or . + /// + /// + /// a function to determine the variation + /// the builder + public FlagBuilder VariationFunc(Func variationFunc) => + VariationFunc(context => + { + var v = variationFunc(context); + if (!v.HasValue || !_variations.Contains(v.Value)) + { + return null; + } + return _variations.IndexOf(v.Value); + }); + + /// + /// Changes the allowable variation values for the flag. + /// + /// + /// The value may be of any JSON type, as defined by . For instance, a + /// boolean flag normally has LdValue.Of(true), LdValue.Of(false); a string-valued + /// flag might have LdValue.Of("red"), LdValue.Of("green"), LdValue.Of("blue"); etc. + /// + /// the desired variations + /// the builder + public FlagBuilder Variations(params LdValue[] values) + { + _variations.Clear(); + _variations.AddRange(values); + return this; + } + + // For testing only + internal FlagBuilder PreconfiguredFlag(FeatureFlag preconfiguredFlag) + { + _preconfiguredFlag = preconfiguredFlag; + return this; + } + + #endregion + + #region Internal methods + + internal ItemDescriptor CreateFlag(int version, Context context) + { + if (_preconfiguredFlag != null) + { + return new ItemDescriptor(version, new FeatureFlag( + _preconfiguredFlag.Value, + _preconfiguredFlag.Variation, + _preconfiguredFlag.Reason, + _preconfiguredFlag.Version > version ? _preconfiguredFlag.Version : version, + _preconfiguredFlag.FlagVersion, + _preconfiguredFlag.TrackEvents, + _preconfiguredFlag.TrackReason, + _preconfiguredFlag.DebugEventsUntilDate)); + } + int variation; + if (!_variationByContextKey.TryGetValue(context.Kind, out var keys) || + !keys.TryGetValue(context.Key, out variation)) + { + variation = _variationFunc?.Invoke(context) ?? _defaultVariation; + } + var value = (variation < 0 || variation >= _variations.Count) ? LdValue.Null : + _variations[variation]; + var reason = variation == 0 ? EvaluationReason.FallthroughReason : + EvaluationReason.OffReason; + var flag = new FeatureFlag( + value, + variation, + reason, + version, + null, + false, + false, + null + ); + return new ItemDescriptor(version, flag); + } + + internal bool IsBooleanFlag => + _variations.Count == 2 && + _variations[TrueVariationForBoolean] == LdValue.Of(true) && + _variations[FalseVariationForBoolean] == LdValue.Of(false); + + internal int AddVariationIfNotDefined(LdValue value) + { + int i = _variations.IndexOf(value); + if (i >= 0) + { + return i; + } + _variations.Add(value); + return _variations.Count - 1; + } + + internal static int VariationForBoolean(bool value) => + value ? TrueVariationForBoolean : FalseVariationForBoolean; + + #endregion + } + + #endregion + + #region Internal inner type + + internal class DataSourceImpl : IDataSource + { + private readonly TestData _parent; + private readonly IDataSourceUpdateSink _updateSink; + private readonly Logger _log; + + internal readonly Context Context; + + internal DataSourceImpl(TestData parent, IDataSourceUpdateSink updateSink, Context context, Logger log) + { + _parent = parent; + _updateSink = updateSink; + Context = context; + _log = log; + } + + public Task Start() + { + _updateSink.Init(Context, _parent.MakeInitData(Context)); + return Task.FromResult(true); + } + + public bool Initialized => true; + + public void Dispose() => + _parent.ClosedInstance(this); + + internal void DoUpdate(string key, ItemDescriptor item) + { + _log.Debug("updating \"{0}\" to {1}", key, LogValues.Defer(() => + item.Item is null ? "" : DataModelSerialization.SerializeFlag(item.Item))); + _updateSink.Upsert(Context, key, item); + } + + internal void DoUpdateStatus(DataSourceState newState, DataSourceStatus.ErrorInfo? newError) + { + _log.Debug("updating status to {0}{1}", newState, + newError.HasValue ? (" (" + newError.Value + ")") : ""); + _updateSink.UpdateStatus(newState, newError); + } + } + + #endregion + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Interfaces/DataSourceStatus.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Interfaces/DataSourceStatus.cs new file mode 100644 index 00000000..cd90d3b5 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Interfaces/DataSourceStatus.cs @@ -0,0 +1,344 @@ +using System; +using System.IO; +using System.Text; + +namespace LaunchDarkly.Sdk.Client.Interfaces +{ + /// + /// Information about the data source's status and about the last status change. + /// + /// + /// + public struct DataSourceStatus + { + /// + /// An enumerated value representing the overall current state of the data source. + /// + public DataSourceState State { get; set; } + + /// + /// The date/time that the value of most recently changed. + /// + /// + /// The meaning of this depends on the current state: + /// + /// For , it is the time that the SDK started + /// initializing. + /// For , it is the time that the data source most + /// recently entered a valid state, after previously having been + /// or an invalid state such as . + /// For , it is the time that the data source + /// most recently entered an error state, after previously having been . + /// + /// For , + /// , or , it is + /// the time that the SDK switched off the data source after detecting one of those conditions. + /// + /// For , it is the time that the data source + /// encountered an unrecoverable error or that the SDK was explicitly shut down. + /// + /// + public DateTime StateSince { get; set; } + + /// + /// Information about the last error that the data source encountered, if any. + /// + /// + /// This property should be updated whenever the data source encounters a problem, even if it does not cause + /// to change. For instance, if a stream connection fails and the state changes to + /// , and then subsequent attempts to restart the connection also fail, the + /// state will remain but the error information will be updated each time-- + /// and the last error will still be reported in this property even if the state later becomes + /// . + /// + public ErrorInfo? LastError { get; set; } + + /// + public override string ToString() => + string.Format("DataSourceStatus({0},{1},{2})", State, StateSince, LastError); + + /// + /// A description of an error condition that the data source encountered. + /// + /// + public struct ErrorInfo + { + /// + /// An enumerated value representing the general category of the error. + /// + public ErrorKind Kind { get; set; } + + /// + /// The HTTP status code if the error was , or zero otherwise. + /// + public int StatusCode { get; set; } + + /// + /// Any additional human-readable information relevant to the error. + /// + /// + /// The format of this message is subject to change and should not be relied on programmatically. + /// + public string Message { get; set; } + + /// + /// The date/time that the error occurred. + /// + public DateTime Time { get; set; } + + /// + /// Constructs an instance based on an exception. + /// + /// the exception + /// an ErrorInfo + public static ErrorInfo FromException(Exception e) => new ErrorInfo + { + Kind = e is IOException ? ErrorKind.NetworkError : ErrorKind.Unknown, + Message = e.Message, + Time = DateTime.Now + }; + + /// + /// Constructs an instance based on an HTTP error status. + /// + /// the status code + /// an ErrorInfo + public static ErrorInfo FromHttpError(int statusCode) => new ErrorInfo + { + Kind = ErrorKind.ErrorResponse, + StatusCode = statusCode, + Time = DateTime.Now + }; + + /// + public override string ToString() + { + var s = new StringBuilder(); + s.Append(Kind.Identifier()); + if (StatusCode > 0 || !string.IsNullOrEmpty(Message)) + { + s.Append("("); + if (StatusCode > 0) + { + s.Append(StatusCode); + } + if (!string.IsNullOrEmpty(Message)) + { + if (StatusCode > 0) + { + s.Append(","); + } + s.Append(Message); + } + s.Append(")"); + } + s.Append("@"); + s.Append(Time); + return s.ToString(); + } + } + + /// + /// An enumeration describing the general type of an error reported in . + /// + public enum ErrorKind + { + /// + /// An unexpected error, such as an uncaught exception, further described by . + /// + Unknown, + + /// + /// An I/O error such as a dropped connection. + /// + NetworkError, + + /// + /// The LaunchDarkly service returned an HTTP response with an error status, available with + /// . + /// + ErrorResponse, + + /// + /// The SDK received malformed data from the LaunchDarkly service. + /// + InvalidData, + + /// + /// The data source itself is working, but when it tried to put an update into the data store, the data + /// store failed (so the SDK may not have the latest data). + /// + /// + /// Data source implementations do not need to report this kind of error; it will be automatically + /// reported by the SDK whenever one of the update methods of throws an + /// exception. + /// + StoreError + } + } + + /// + /// An enumeration of possible values for . + /// + public enum DataSourceState + { + /// + /// The initial state of the data source when the SDK is being initialized. + /// + /// + /// If it encounters an error that requires it to retry initialization, the state will remain at + /// until it either succeeds and becomes , or + /// permanently fails and becomes . + /// + Initializing, + + /// + /// Indicates that the data source is currently operational and has not had any problems since the + /// last time it received data. + /// + /// + /// In streaming mode, this means that there is currently an open stream connection and that at least + /// one initial message has been received on the stream. In polling mode, it means that the last poll + /// request succeeded. + /// + Valid, + + /// + /// Indicates that the data source encountered an error that it will attempt to recover from. + /// + /// + /// + /// In streaming mode, this means that the stream connection failed, or had to be dropped due to some + /// other error, and will be retried after a backoff delay. In polling mode, it means that the last poll + /// request failed, and a new poll request will be made after the configured polling interval. + /// + /// + /// This is different from , which would mean that the SDK knows the + /// device is not online at all and is waiting for it to be online again. + /// + /// + Interrupted, + + /// + /// Indicates that the SDK is in background mode and background updating has been disabled. + /// + /// + /// On mobile devices, if the application containing the SDK is put into the background, by default + /// the SDK will still check for feature flag updates occasionally. However, if this has been disabled + /// with EnableBackgroundUpdating(false), the SDK will instead stop the data source and wait + /// until it is in the foreground again. During that time, the state is BackgroundDisabled. + /// + /// + BackgroundDisabled, + + /// + /// Indicates that the SDK is aware of a lack of network connectivity. + /// + /// + /// + /// On mobile devices, if wi-fi is turned off or there is no wi-fi connection and cellular data is + /// unavailable, the device OS will tell the SDK that the network is unavailable. The SDK then enters + /// this state, where it will not try to make any network connections since they would be guaranteed to + /// fail, until the OS informs it that the network is available again. + /// + /// + /// This is different from , which would mean that the SDK thinks network + /// requests ought to be working but for some reason they are not (due to either a network problem + /// that the device OS does not know about, or a problem with the service endpoint). + /// + /// + /// The .NET Standard version of the SDK is not able to detect network status, so desktop applications + /// will see the state if the network is turned off. + /// + /// + NetworkUnavailable, + + /// + /// Indicates that the application has told the SDK to stay offline. + /// + /// + /// This means that either the SDK was originally configured with Offline(true) and has not been + /// changed to be online since then, or that it was originally online but has been put offline with + /// or . + /// It is not a permanent condition; the application can change this at any time. + /// + /// + /// + /// + SetOffline, + + /// + /// Indicates that the data source has been permanently shut down. + /// + /// + /// This could be because it encountered an unrecoverable error (for instance, the LaunchDarkly service + /// rejected the SDK key; an invalid SDK key will never become valid), or because the SDK client was + /// explicitly shut down. + /// + Shutdown + } + + /// + /// Extension helper methods for use with data source status types. + /// + public static class DataSourceStatusExtensions + { + /// + /// Returns a standardized string identifier for a . + /// + /// + /// These Java-style uppercase identifiers (INITIALIZING, VALID, etc.) may be used in + /// logging for consistency across SDKs. + /// + /// a state value + /// a string identifier + public static string Identifier(this DataSourceState state) + { + switch (state) + { + case DataSourceState.Initializing: + return "INITIALIZING"; + case DataSourceState.Valid: + return "VALID"; + case DataSourceState.Interrupted: + return "INTERRUPTED"; + case DataSourceState.NetworkUnavailable: + return "NETWORK_UNAVAILABLE"; + case DataSourceState.SetOffline: + return "SET_OFFLINE"; + case DataSourceState.Shutdown: + return "SHUTDOWN"; + default: + return state.ToString(); + } + } + + /// + /// Returns a standardized string identifier for a . + /// + /// + /// These Java-style uppercase identifiers (ERROR_RESPONSE, NETWORK_ERROR, etc.) may be + /// used in logging for consistency across SDKs. + /// + /// an error kind value + /// a string identifier + public static string Identifier(this DataSourceStatus.ErrorKind errorKind) + { + switch (errorKind) + { + case DataSourceStatus.ErrorKind.ErrorResponse: + return "ERROR_RESPONSE"; + case DataSourceStatus.ErrorKind.InvalidData: + return "INVALID_DATA"; + case DataSourceStatus.ErrorKind.NetworkError: + return "NETWORK_ERROR"; + case DataSourceStatus.ErrorKind.StoreError: + return "STORE_ERROR"; + case DataSourceStatus.ErrorKind.Unknown: + return "UNKNOWN"; + default: + return errorKind.ToString(); + } + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Interfaces/IDataSourceStatusProvider.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Interfaces/IDataSourceStatusProvider.cs new file mode 100644 index 00000000..42d74cb5 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Interfaces/IDataSourceStatusProvider.cs @@ -0,0 +1,121 @@ +using System; +using System.Threading.Tasks; + +namespace LaunchDarkly.Sdk.Client.Interfaces +{ + /// + /// An interface for querying the status of the SDK's data source. + /// + /// + /// + /// The data source is the component that receives updates to feature flag data. Normally this is a streaming + /// connection, but it could be polling or test data depending on your configuration. + /// + /// + /// An implementation of this interface is returned by . + /// Application code never needs to implement this interface. + /// + /// + /// + public interface IDataSourceStatusProvider + { + /// + /// The current status of the data source. + /// + /// + /// + /// All of the built-in data source implementations are guaranteed to update this status whenever they + /// successfully initialize, encounter an error, or recover after an error. + /// + /// + /// For a custom data source implementation, it is the responsibility of the data source to report its + /// status via ; if it does not do so, the status will always be reported + /// as . + /// + /// + DataSourceStatus Status { get; } + + /// + /// An event for receiving notifications of status changes. + /// + /// + /// + /// Any handlers attached to this event will be notified whenever any property of the status has changed. + /// See for an explanation of the meaning of each property and what could cause it + /// to change. + /// + /// + /// Notifications will be dispatched either on the main thread (on mobile platforms) or in a + /// background task (on all other platforms). It is the listener's responsibility to return + /// as soon as possible so as not to block subsequent notifications. + /// + /// + event EventHandler StatusChanged; + + /// + /// A synchronous method for waiting for a desired connection state. + /// + /// + /// + /// If the current state is already when this method is called, it immediately + /// returns. Otherwise, it blocks until 1. the state has become , 2. the state + /// has become (since that is a permanent condition), or 3. the specified + /// timeout elapses. + /// + /// + /// A scenario in which this might be useful is if you want to create the without waiting + /// for it to initialize, and then wait for initialization at a later time or on a different thread: + /// + /// + /// // create the client but do not wait + /// var config = Configuration.Builder("my-sdk-key").StartWaitTime(TimeSpan.Zero).Build(); + /// var client = new LDClient(config); + /// + /// // later, possibly on another thread: + /// var inited = client.DataSourceStatusProvider.WaitFor(DataSourceState.Valid, + /// TimeSpan.FromSeconds(10)); + /// if (!inited) { + /// // do whatever is appropriate if initialization has timed out + /// } + /// + /// + /// the desired connection state (normally this would be + /// ) + /// the maximum amount of time to wait-- or to block + /// indefinitely + /// true if the connection is now in the desired state; false if it timed out, or if the state + /// changed to and that was not the desired state + /// + bool WaitFor(DataSourceState desiredState, TimeSpan timeout); + + /// + /// An asynchronous method for waiting for a desired connection state. + /// + /// + /// + /// This method behaves identically to except that it is asynchronous. The following + /// example is the asynchronous equivalent of the example code shown for : + /// + /// + /// // create the client but do not wait + /// var config = Configuration.Builder("my-sdk-key").StartWaitTime(TimeSpan.Zero).Build(); + /// var client = new LDClient(config); + /// + /// // later, possibly on another thread: + /// var inited = await client.DataSourceStatusProvider.WaitFor(DataSourceState.Valid, + /// TimeSpan.FromSeconds(10)); + /// if (!inited) { + /// // do whatever is appropriate if initialization has timed out + /// } + /// + /// + /// the desired connection state (normally this would be + /// ) + /// the maximum amount of time to wait-- or to block + /// indefinitely + /// true if the connection is now in the desired state; false if it timed out, or if the state + /// changed to and that was not the desired state + /// + Task WaitForAsync(DataSourceState desiredState, TimeSpan timeout); + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Interfaces/IFlagTracker.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Interfaces/IFlagTracker.cs new file mode 100644 index 00000000..722e971c --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Interfaces/IFlagTracker.cs @@ -0,0 +1,124 @@ +using System; + +namespace LaunchDarkly.Sdk.Client.Interfaces +{ + /// + /// An interface for tracking changes in feature flag configurations. + /// + /// + /// An implementation of this interface is returned by . + /// Application code never needs to implement this interface. + /// + public interface IFlagTracker + { + /// + /// An event for receiving notifications of feature flag changes. + /// + /// + /// + /// This event is raised whenever the SDK receives a variation for any feature flag that is + /// not equal to its previous variation. This could mean that the flag configuration was + /// changed in LaunchDarkly, or that you have changed the current user and the flag values + /// are different for this user than for the previous user. The event is not raised if the + /// SDK has just received its very first set of flag values. + /// + /// + /// Currently this event will not fire in a scenario where 1. the client is offline, 2. + /// or + /// has been called to change the current user, and 3. the SDK had previously stored flag data + /// for that user (see ) and has + /// now loaded those flags. The event will only fire if the SDK has received new flag data + /// from LaunchDarkly or from . + /// + /// + /// Notifications will be dispatched either on the main thread (on mobile platforms) or in a + /// background task (on all other platforms). It is the listener's responsibility to return + /// as soon as possible so as not to block subsequent notifications. + /// + /// + /// + /// client.FlagTracker.FlagChanged += (sender, eventArgs) => + /// { + /// System.Console.WriteLine("flag '" + eventArgs.Key + /// + "' changed from " + eventArgs.OldValue + /// + " to " + eventArgs.NewValue); + /// }; + /// + event EventHandler FlagValueChanged; + } + + /// + /// A parameter class used with . + /// + /// + /// This is not an analytics event to be sent to LaunchDarkly; it is a notification to the + /// application. + /// + public struct FlagValueChangeEvent + { + /// + /// The key of the feature flag whose configuration has changed. + /// + /// + /// The specified flag may have been modified directly, or this may be an indirect + /// change due to a change in some other flag that is a prerequisite for this flag, or + /// a user segment that is referenced in the flag's rules. + /// + public string Key { get; } + + /// + /// The last known value of the flag for the specified user prior to the update. + /// + /// + /// + /// Since flag values can be of any JSON data type, this is represented as + /// . That class has properties for converting to other .NET types, + /// such as . + /// + /// + /// If the flag was deleted or could not be evaluated, this will be . + /// there is no application default value parameter as there is for the Variation + /// methods; it is up to your code to substitute whatever fallback value is appropriate. + /// + /// + public LdValue OldValue { get; } + + /// + /// The new value of the flag for the specified user. + /// + /// + /// + /// Since flag values can be of any JSON data type, this is represented as + /// . That class has properties for converting to other .NET types, + /// such as . + /// + /// + /// If the flag was deleted or could not be evaluated, this will be . + /// there is no application default value parameter as there is for the Variation + /// methods; it is up to your code to substitute whatever fallback value is appropriate. + /// + /// + public LdValue NewValue { get; } + + /// + /// True if the flag was completely removed from the environment. + /// + public bool Deleted { get; } + + /// + /// Constructs a new instance. + /// + /// the key of the feature flag whose configuration has changed + /// the last known value of the flag for the specified user prior to + /// the update + /// the new value of the flag for the specified user + /// true if the flag was deleted + public FlagValueChangeEvent(string key, LdValue oldValue, LdValue newValue, bool deleted) + { + Key = key; + OldValue = oldValue; + NewValue = newValue; + Deleted = deleted; + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Interfaces/ILdClient.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Interfaces/ILdClient.cs new file mode 100644 index 00000000..5629eb8e --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Interfaces/ILdClient.cs @@ -0,0 +1,427 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using LaunchDarkly.Sdk.Client.Integrations; + +namespace LaunchDarkly.Sdk.Client.Interfaces +{ + /// + /// Interface for the standard SDK client methods and properties. The only implementation of this is . + /// + /// + /// See also , which provides convenience methods that build upon + /// this interface. + /// + public interface ILdClient : IDisposable + { + /// + /// A mechanism for tracking the status of the data source. + /// + /// + /// The data source is the mechanism that the SDK uses to get feature flag configurations, such as a + /// streaming connection (the default) or poll requests. The + /// has methods for checking whether the data source is (as far as the SDK knows) currently operational, + /// and tracking changes in this status. This property will never be null. + /// + IDataSourceStatusProvider DataSourceStatusProvider { get; } + + /// + /// A mechanism for tracking changes in feature flag configurations. + /// + /// + /// The contains methods for requesting notifications about feature flag + /// changes using an event listener model. + /// + IFlagTracker FlagTracker { get; } + + /// + /// Returns a boolean value indicating LaunchDarkly connection and flag state within the client. + /// + /// + /// + /// When you first start the client, once or + /// has returned, should be + /// if and only if either 1. it connected to LaunchDarkly and successfully retrieved + /// flags, or 2. it started in offline mode so there's no need to connect to LaunchDarkly. If the client + /// timed out trying to connect to LD, then is (even if we + /// do have cached flags). If the client connected and got a 401 error, is + /// . This serves the purpose of letting the app know that there was a problem of some kind. + /// + /// + /// If you call or , + /// will become until the SDK receives the new context's flags. + /// + /// + bool Initialized { get; } + + /// + /// Indicates whether the SDK is configured to be always offline. + /// + /// + /// + /// This is initially if you set it to in the configuration with + /// . However, you can change it at any time to allow the client + /// to go online, or force it to go offline, using or + /// . + /// + /// + /// When is , the SDK connects to LaunchDarkly if possible, but + /// this does not guarantee that the connection is successful. There is currently no mechanism to detect whether + /// the SDK is currently connected to LaunchDarkly. + /// + /// + bool Offline { get; } + + /// + /// Sets whether the SDK should be always offline. + /// + /// + /// + /// This is equivalent to , but as a synchronous method. + /// + /// + /// If you set the property to , any existing connection will be dropped, and the + /// method immediately returns . + /// + /// + /// If you set it to when it was previously , but no connection can + /// be made because the network is not available, the method immediately returns , but the + /// SDK will attempt to connect later if the network becomes available. + /// + /// + /// If you set it to when it was previously , and the network is + /// available, the SDK will attempt to connect to LaunchDarkly. If the connection succeeds within the interval + /// maxWaitTime, the method returns . If the connection permanently fails (e.g. if + /// the mobile key is invalid), the method returns . If the connection attempt is still in + /// progress after maxWaitTime elapses, the method returns , but the connection + /// might succeed later. + /// + /// + /// true if the client should be always offline + /// the maximum length of time to wait for a connection + /// true if a new connection was successfully made + bool SetOffline(bool value, TimeSpan maxWaitTime); + + /// + /// Sets whether the SDK should be always offline. + /// + /// + /// + /// This is equivalent to , but as an asynchronous method. + /// + /// + /// If you set the property to , any existing connection will be dropped, and the + /// task immediately yields . + /// + /// + /// If you set it to when it was previously , but no connection can + /// be made because the network is not available, the task immediately yields , but the + /// SDK will attempt to connect later if the network becomes available. + /// + /// + /// If you set it to when it was previously , and the network is + /// available, the SDK will attempt to connect to LaunchDarkly. If and when the connection succeeds, the task + /// yields . If and when the connection permanently fails (e.g. if the mobile key is + /// invalid), the task yields . + /// + /// + /// true if the client should be always offline + /// a task that yields true if a new connection was successfully made + Task SetOfflineAsync(bool value); + + /// + /// Returns the boolean value of a feature flag for a given flag key. + /// + /// the unique feature key for the feature flag + /// the default value of the flag + /// the variation for the selected user, or defaultValue if the flag is + /// disabled in the LaunchDarkly control panel + bool BoolVariation(string key, bool defaultValue = false); + + /// + /// Returns the boolean value of a feature flag for a given flag key, in an object that also + /// describes the way the value was determined. + /// + /// + /// The property in the result will also be included in analytics + /// events, if you are capturing detailed event data for this flag. + /// + /// the unique feature key for the feature flag + /// the default value of the flag + /// an EvaluationDetail object + EvaluationDetail BoolVariationDetail(string key, bool defaultValue = false); + + /// + /// Returns the string value of a feature flag for a given flag key. + /// + /// the unique feature key for the feature flag + /// the default value of the flag + /// the variation for the selected user, or defaultValue if the flag is + /// disabled in the LaunchDarkly control panel + string StringVariation(string key, string defaultValue); + + /// + /// Returns the string value of a feature flag for a given flag key, in an object that also + /// describes the way the value was determined. + /// + /// + /// The property in the result will also be included in analytics + /// events, if you are capturing detailed event data for this flag. + /// + /// the unique feature key for the feature flag + /// the default value of the flag + /// an EvaluationDetail object + EvaluationDetail StringVariationDetail(string key, string defaultValue); + + /// + /// Returns the single-precision floating-point value of a feature flag for a given flag key. + /// + /// the unique feature key for the feature flag + /// the default value of the flag + /// the variation for the selected user, or defaultValue if the flag is + /// disabled in the LaunchDarkly control panel + float FloatVariation(string key, float defaultValue = 0); + + /// + /// Returns the single-precision floating-point value of a feature flag for a given flag key, + /// in an object that also describes the way the value was determined. + /// + /// + /// The property in the result will also be included in analytics + /// events, if you are capturing detailed event data for this flag. + /// + /// the unique feature key for the feature flag + /// the default value of the flag + /// an EvaluationDetail object + EvaluationDetail FloatVariationDetail(string key, float defaultValue = 0); + + /// + /// Returns the double-precision floating-point value of a feature flag for a given flag key. + /// + /// the unique feature key for the feature flag + /// the default value of the flag + /// the variation for the selected user, or defaultValue if the flag is + /// disabled in the LaunchDarkly control panel + double DoubleVariation(string key, double defaultValue = 0); + + /// + /// Returns the double-precision floating-point value of a feature flag for a given flag key, + /// in an object that also describes the way the value was determined. + /// + /// + /// The property in the result will also be included in analytics + /// events, if you are capturing detailed event data for this flag. + /// + /// the unique feature key for the feature flag + /// the default value of the flag + /// an EvaluationDetail object + EvaluationDetail DoubleVariationDetail(string key, double defaultValue = 0); + + /// + /// Returns the integer value of a feature flag for a given flag key. + /// + /// the unique feature key for the feature flag + /// the default value of the flag + /// the variation for the selected user, or defaultValue if the flag is + /// disabled in the LaunchDarkly control panel + int IntVariation(string key, int defaultValue = 0); + + /// + /// Returns the integer value of a feature flag for a given flag key, in an object that also + /// describes the way the value was determined. + /// + /// + /// The property in the result will also be included in analytics + /// events, if you are capturing detailed event data for this flag. + /// + /// the unique feature key for the feature flag + /// the default value of the flag + /// an EvaluationDetail object + EvaluationDetail IntVariationDetail(string key, int defaultValue = 0); + + /// + /// Returns the JSON value of a feature flag for a given flag key. + /// + /// the unique feature key for the feature flag + /// the default value of the flag + /// the variation for the selected user, or defaultValue if the flag is + /// disabled in the LaunchDarkly control panel + LdValue JsonVariation(string key, LdValue defaultValue); + + /// + /// Returns the JSON value of a feature flag for a given flag key, in an object that also + /// describes the way the value was determined. + /// + /// + /// The property in the result will also be included in analytics + /// events, if you are capturing detailed event data for this flag. + /// + /// the unique feature key for the feature flag + /// the default value of the flag + /// an EvaluationDetail object + EvaluationDetail JsonVariationDetail(string key, LdValue defaultValue); + + /// + /// Tracks that current user performed an event for the given event name. + /// + /// the name of the event + void Track(string eventName); + + /// + /// Tracks that the current user performed an event for the given event name, with additional JSON data. + /// + /// the name of the event + /// a JSON value containing additional data associated with the event + void Track(string eventName, LdValue data); + + /// + /// Tracks that the current user performed an event for the given event name, and associates it with a + /// numeric metric value. + /// + /// + /// + /// the name of the event + /// a JSON value containing additional data associated with the event; pass + /// if you do not need this value + /// this value is used by the LaunchDarkly experimentation feature in + /// numeric custom metrics, and will also be returned as part of the custom event for Data Export + void Track(string eventName, LdValue data, double metricValue); + + /// + /// Returns a map from feature flag keys to feature flag values for the current user. + /// + /// + /// + /// If the result of a flag's value would have returned the default variation, the value in the map will contain + /// . If the client is offline or has not been initialized, an empty + /// map will be returned. + /// + /// + /// This method will not send analytics events back to LaunchDarkly. + /// + /// + /// a map from feature flag keys to values for the current user + IDictionary AllFlags(); + + /// + /// Changes the current evaluation context, requests flags for that context from LaunchDarkly if we are online, + /// and generates an analytics event to tell LaunchDarkly about the context. + /// + /// + /// + /// This is equivalent to , but as a synchronous method. + /// + /// + /// If the SDK is online, waits to receive feature flag values for the new context from + /// LaunchDarkly. If it receives the new flag values before maxWaitTime has elapsed, it returns + /// . If the timeout elapses, it returns (although the SDK might + /// still receive the flag values later). If we do not need to request flags from LaunchDarkly because we are + /// in offline mode, it returns . + /// + /// + /// If you do not want to wait, you can either set maxWaitTime to zero or call . + /// + /// + /// the new evaluation context; see for more + /// about setting the context and optionally requesting a unique key for it + /// the maximum time to wait for the new flag values + /// true if new flag values were obtained + /// + bool Identify(Context context, TimeSpan maxWaitTime); + + /// + /// Changes the current evaluation context, requests flags for that context from LaunchDarkly if we are online, + /// and generates an analytics event to tell LaunchDarkly about the context. + /// + /// + /// + /// This is equivalent to , but as an asynchronous method. + /// + /// + /// If the SDK is online, the returned task is completed once the SDK has received feature flag values for the + /// new user from LaunchDarkly, or received an unrecoverable error; it yields for success + /// or for an error. If the SDK is offline, the returned task is completed immediately + /// and yields . + /// + /// + /// the new evaluation context; see for more + /// about setting the context and optionally requesting a unique key for it + /// a task that yields true if new flag values were obtained + /// + Task IdentifyAsync(Context context); + + /// + /// Tells the client that all pending analytics events (if any) should be delivered as soon + /// as possible. + /// + /// + /// + /// This flush is asynchronous, so this method will return before it is complete. To wait for + /// the flush to complete, use instead (or, if you are done + /// with the SDK, ). + /// + /// + /// For more information, see: + /// Flushing Events. + /// + /// + /// + /// + void Flush(); + + /// + /// Tells the client to deliver any pending analytics events synchronously now. + /// + /// + /// + /// Unlike , this method waits for event delivery to finish. The timeout parameter, if + /// greater than zero, specifies the maximum amount of time to wait. If the timeout elapses before + /// delivery is finished, the method returns early and returns false; in this case, the SDK may still + /// continue trying to deliver the events in the background. + /// + /// + /// If the timeout parameter is zero or negative, the method waits as long as necessary to deliver the + /// events. However, the SDK does not retry event delivery indefinitely; currently, any network error + /// or server error will cause the SDK to wait one second and retry one time, after which the events + /// will be discarded so that the SDK will not keep consuming more memory for events indefinitely. + /// + /// + /// The method returns true if event delivery either succeeded, or definitively failed, before the + /// timeout elapsed. It returns false if the timeout elapsed. + /// + /// + /// This method is also implicitly called if you call . The difference is + /// that FlushAndWait does not shut down the SDK client. + /// + /// + /// For more information, see: + /// Flushing Events. + /// + /// + /// the maximum time to wait + /// true if completed, false if timed out + /// + /// + bool FlushAndWait(TimeSpan timeout); + + /// + /// Tells the client to deliver any pending analytics events now, returning a Task that can be awaited. + /// + /// + /// + /// This is equivalent to , but with asynchronous semantics so it + /// does not block the calling thread. The difference between this and is that you + /// can await the task to simulate blocking behavior. + /// + /// + /// For more information, see: + /// Flushing Events. + /// + /// + /// the maximum time to wait + /// a Task that resolves to true if completed, false if timed out + /// + /// + Task FlushAndWaitAsync(TimeSpan timeout); + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Interfaces/ServiceEndpoints.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Interfaces/ServiceEndpoints.cs new file mode 100644 index 00000000..bdec1098 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Interfaces/ServiceEndpoints.cs @@ -0,0 +1,29 @@ +using System; + +namespace LaunchDarkly.Sdk.Client.Interfaces +{ + /// + /// Specifies the base service URIs used by SDK components. + /// + /// + /// This class's properties are not public, since they are only read by the SDK. + /// + /// + public sealed class ServiceEndpoints + { + internal Uri StreamingBaseUri { get; } + internal Uri PollingBaseUri { get; } + internal Uri EventsBaseUri { get; } + + internal ServiceEndpoints( + Uri streamingBaseUri, + Uri pollingBaseUri, + Uri eventsBaseUri + ) + { + StreamingBaseUri = streamingBaseUri; + PollingBaseUri = pollingBaseUri; + EventsBaseUri = eventsBaseUri; + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/AnonymousKeyContextDecorator.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/AnonymousKeyContextDecorator.cs new file mode 100644 index 00000000..008a8bed --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/AnonymousKeyContextDecorator.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using LaunchDarkly.Sdk.Client.Internal.DataStores; + +namespace LaunchDarkly.Sdk.Client.Internal +{ + internal class AnonymousKeyContextDecorator + { + private readonly PersistentDataStoreWrapper _store; + private readonly bool _generateAnonymousKeys; + + private Dictionary _cachedGeneratedKey = new Dictionary(); + private object _generatedKeyLock = new object(); + + public AnonymousKeyContextDecorator( + PersistentDataStoreWrapper store, + bool generateAnonymousKeys + ) + { + _store = store; + _generateAnonymousKeys = generateAnonymousKeys; + } + + public Context DecorateContext(Context context) + { + if (!_generateAnonymousKeys) + { + return context; + } + if (context.Multiple) + { + if (context.MultiKindContexts.Any(c => c.Anonymous)) + { + var builder = Context.MultiBuilder(); + foreach (var c in context.MultiKindContexts) + { + builder.Add(c.Anonymous ? SingleKindContextWithGeneratedKey(c) : c); + } + return builder.Build(); + } + } + else if (context.Anonymous) + { + return SingleKindContextWithGeneratedKey(context); + } + return context; + } + + private Context SingleKindContextWithGeneratedKey(Context context) => + Context.BuilderFromContext(context).Key(GetOrCreateAutoContextKey(context.Kind)).Build(); + + private string GetOrCreateAutoContextKey(ContextKind contextKind) + { + lock (_generatedKeyLock) + { + if (_cachedGeneratedKey.TryGetValue(contextKind, out var key)) + { + return key; + } + var uniqueId = _store?.GetGeneratedContextKey(contextKind); + if (uniqueId is null) + { + uniqueId = Guid.NewGuid().ToString(); + _store?.SetGeneratedContextKey(contextKind, uniqueId); + } + _cachedGeneratedKey[contextKind] = uniqueId; + return uniqueId; + } + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/AutoEnvContextDecorator.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/AutoEnvContextDecorator.cs new file mode 100644 index 00000000..c41670aa --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/AutoEnvContextDecorator.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.EnvReporting; +using LaunchDarkly.Sdk.Client.Internal.DataStores; + +namespace LaunchDarkly.Sdk.Client.Internal +{ + /// + /// This class can decorate a context by adding additional contexts to it using auto environment attributes + /// provided by . + /// + internal class AutoEnvContextDecorator + { + internal const string LdApplicationKind = "ld_application"; + internal const string LdDeviceKind = "ld_device"; + internal const string AttrId = "id"; + internal const string AttrName = "name"; + internal const string AttrVersion = "version"; + internal const string AttrVersionName = "versionName"; + internal const string AttrManufacturer = "manufacturer"; + internal const string AttrModel = "model"; + internal const string AttrLocale = "locale"; + internal const string AttrOs = "os"; + internal const string AttrFamily = "family"; + internal const string EnvAttributesVersion = "envAttributesVersion"; + internal const string SpecVersion = "1.0"; + + private readonly PersistentDataStoreWrapper _persistentData; + private readonly IEnvironmentReporter _environmentReporter; + private readonly Logger _logger; + + /// + /// Creates a . + /// + /// the data source that will be used for retrieving/saving information related + /// to the generated contexts. Example data includes the stable key of the ld_device context kind. + /// the environment reporter that will be used to source the + /// environment attributes + /// the logger + public AutoEnvContextDecorator( + PersistentDataStoreWrapper persistentData, + IEnvironmentReporter environmentReporter, + Logger logger) + { + _persistentData = persistentData; + _environmentReporter = environmentReporter; + _logger = logger; + } + + /// + /// Decorates the provided context with additional contexts containing environment attributes. + /// + /// the context to be decorated + /// the decorated context + public Context DecorateContext(Context context) + { + var builder = Context.MultiBuilder(); + builder.Add(context); + + foreach (var recipe in MakeRecipeList()) + { + if (!context.TryGetContextByKind(recipe.Kind, out _)) + { + // only add contexts for recipe Kinds not already in context to avoid overwriting data. + recipe.TryWrite(builder); + } + else + { + _logger.Warn("Unable to automatically add environment attributes for kind:{0}. {1} already exists.", + recipe.Kind, recipe.Kind); + } + } + + return builder.Build(); + } + + private readonly struct ContextRecipe + { + public ContextKind Kind { get; } + private Func KeyCallable { get; } + private List RecipeNodes { get; } + + public ContextRecipe(ContextKind kind, Func keyCallable, List recipeNodes) + { + Kind = kind; + KeyCallable = keyCallable; + RecipeNodes = recipeNodes; + } + + public void TryWrite(ContextMultiBuilder multiBuilder) + { + var contextBuilder = Context.Builder(Kind, KeyCallable.Invoke()); + var adaptedBuilder = new ContextBuilderAdapter(contextBuilder); + if (RecipeNodes.Aggregate(false, (wrote, node) => wrote | node.TryWrite(adaptedBuilder))) + { + contextBuilder.Set(EnvAttributesVersion, SpecVersion); + multiBuilder.Add(contextBuilder.Build()); + } + } + } + + private List MakeRecipeList() + { + var ldApplicationKind = ContextKind.Of(LdApplicationKind); + var applicationNodes = new List + { + new Node(AttrId, LdValue.Of(_environmentReporter.ApplicationInfo?.ApplicationId)), + new Node(AttrName, + LdValue.Of(_environmentReporter.ApplicationInfo?.ApplicationName)), + new Node(AttrVersion, + LdValue.Of(_environmentReporter.ApplicationInfo?.ApplicationVersion)), + new Node(AttrVersionName, + LdValue.Of(_environmentReporter.ApplicationInfo?.ApplicationVersionName)), + new Node(AttrLocale, LdValue.Of(_environmentReporter.Locale)), + }; + + var ldDeviceKind = ContextKind.Of(LdDeviceKind); + var deviceNodes = new List + { + new Node(AttrManufacturer, + LdValue.Of(_environmentReporter.DeviceInfo?.Manufacturer)), + new Node(AttrModel, LdValue.Of(_environmentReporter.DeviceInfo?.Model)), + new Node(AttrOs, new List + { + new Node(AttrFamily, LdValue.Of(_environmentReporter.OsInfo?.Family)), + new Node(AttrName, LdValue.Of(_environmentReporter.OsInfo?.Name)), + new Node(AttrVersion, LdValue.Of(_environmentReporter.OsInfo?.Version)), + }) + }; + + return new List + { + new ContextRecipe( + ldApplicationKind, + () => Base64.UrlSafeSha256Hash( + _environmentReporter.ApplicationInfo?.ApplicationId ?? "" + ), + applicationNodes + ), + new ContextRecipe( + ldDeviceKind, + () => GetOrCreateAutoContextKey(_persistentData, ldDeviceKind), + deviceNodes + ) + }; + } + + private string GetOrCreateAutoContextKey(PersistentDataStoreWrapper store, ContextKind contextKind) + { + var uniqueId = store.GetGeneratedContextKey(contextKind); + if (uniqueId is null) + { + uniqueId = Guid.NewGuid().ToString(); + store.SetGeneratedContextKey(contextKind, uniqueId); + } + return uniqueId; + } + + private interface ISettableMap + { + void Set(string attributeName, LdValue value); + } + + private class Node + { + private readonly string _key; + private readonly LdValue? _value; + private readonly List _children; + + public Node(string key, List children) + { + _key = key; + _children = children; + } + + public Node(string key, LdValue value) + { + _key = key; + _value = value; + } + + public bool TryWrite(ISettableMap settableMap) + { + if (_value.HasValue && !_value.Value.IsNull) + { + settableMap.Set(_key, _value.Value); + return true; + } + + if (_children == null) return false; + + var objBuilder = LdValue.BuildObject(); + var adaptedBuilder = new ObjectBuilderAdapter(objBuilder); + + if (!_children.Aggregate(false, (wrote, node) => wrote | node.TryWrite(adaptedBuilder))) return false; + + settableMap.Set(_key, objBuilder.Build()); + return true; + } + } + + + private class ObjectBuilderAdapter : ISettableMap + { + private readonly LdValue.ObjectBuilder _underlyingBuilder; + + public ObjectBuilderAdapter(LdValue.ObjectBuilder builder) + { + _underlyingBuilder = builder; + } + + public void Set(string attributeName, LdValue value) + { + _underlyingBuilder.Set(attributeName, value); + } + } + + private class ContextBuilderAdapter : ISettableMap + { + private readonly ContextBuilder _underlyingBuilder; + + public ContextBuilderAdapter(ContextBuilder builder) + { + _underlyingBuilder = builder; + } + + public void Set(string attributeName, LdValue value) + { + _underlyingBuilder.Set(attributeName, value); + } + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Base64.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Base64.cs new file mode 100644 index 00000000..d8d877c9 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Base64.cs @@ -0,0 +1,22 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace LaunchDarkly.Sdk.Client.Internal +{ + internal static class Base64 + { + private static readonly SHA256 _hasher = SHA256.Create(); + + public static string UrlSafeEncode(this string plainText) => + UrlSafeBase64String(Encoding.UTF8.GetBytes(plainText)); + + public static string UrlSafeSha256Hash(string input) => + UrlSafeBase64String( + _hasher.ComputeHash(Encoding.UTF8.GetBytes(input)) + ); + + public static string UrlSafeBase64String(byte[] input) => + Convert.ToBase64String(input).Replace('+', '-').Replace('/', '_'); + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/ComponentsImpl.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/ComponentsImpl.cs new file mode 100644 index 00000000..1ddc16ba --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/ComponentsImpl.cs @@ -0,0 +1,54 @@ +using System; +using System.Threading.Tasks; +using LaunchDarkly.Sdk.Client.Subsystems; + +namespace LaunchDarkly.Sdk.Client.Internal +{ + internal static class ComponentsImpl + { + internal sealed class NullDataSourceFactory : IComponentConfigurer + { + public IDataSource Build(LdClientContext context) => + new NullDataSource(); + } + + internal sealed class NullDataSource : IDataSource + { + public bool Initialized => true; + + public void Dispose() { } + + public Task Start() => Task.FromResult(true); + } + + internal sealed class NullEventProcessorFactory : IComponentConfigurer + { + internal static readonly NullEventProcessorFactory Instance = new NullEventProcessorFactory(); + + public IEventProcessor Build(LdClientContext context) => + NullEventProcessor.Instance; + } + + internal sealed class NullEventProcessor : IEventProcessor + { + internal static readonly NullEventProcessor Instance = new NullEventProcessor(); + + public void Dispose() { } + + public void Flush() { } + + public bool FlushAndWait(TimeSpan timeout) => true; + + public Task FlushAndWaitAsync(TimeSpan timeout) => Task.FromResult(true); + + public void RecordCustomEvent(in EventProcessorTypes.CustomEvent e) { } + + public void RecordEvaluationEvent(in EventProcessorTypes.EvaluationEvent e) { } + + public void RecordIdentifyEvent(in EventProcessorTypes.IdentifyEvent e) { } + + public void SetOffline(bool offline) { } + } + + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Constants.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Constants.cs new file mode 100644 index 00000000..8775a8c2 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Constants.cs @@ -0,0 +1,16 @@ + +namespace LaunchDarkly.Sdk.Client.Internal +{ + internal static class Constants + { + public const string KEY = "key"; + public const string VERSION = "version"; + public const string CONTENT_TYPE = "Content-Type"; + public const string APPLICATION_JSON = "application/json"; + public const string PUT = "put"; + public const string PATCH = "patch"; + public const string DELETE = "delete"; + public const string PING = "ping"; + public const string UNIQUE_ID_KEY = "unique_id_key"; + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataModelSerialization.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataModelSerialization.cs new file mode 100644 index 00000000..a270aa36 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataModelSerialization.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Text; +using System.Text.Json; +using LaunchDarkly.Sdk.Internal; +using LaunchDarkly.Sdk.Json; + +using static LaunchDarkly.Sdk.Client.DataModel; +using static LaunchDarkly.Sdk.Client.Subsystems.DataStoreTypes; +using static LaunchDarkly.Sdk.Internal.JsonConverterHelpers; + +namespace LaunchDarkly.Sdk.Client.Internal +{ + // Methods for converting data to or from a serialized form. + // + // The JSON representation of a Context is defined along with Context in LaunchDarkly.CommonSdk. + // + // The serialized representation of a single FeatureFlag is simply a JSON object containing + // its properties, as defined in FeatureFlag. + // + // For a whole set of FeatureFlags, the format used to store serialized data is the same as + // the format used by the LaunchDarkly polling and streaming endpoints. It is a JSON object + // where each key is a flag key and each value is the FeatureFlag representation. There is + // no way to represent a deleted item placeholder in this format. The version for each flag + // is simply the "version" property of the flag's JSON representation. + // + // All deserialization methods throw InvalidDataException for malformed data. + + internal static class DataModelSerialization + { + private const string ParseErrorMessage = "Data was not in a recognized format"; + + internal static string SerializeContext(Context context) => + LdJsonSerialization.SerializeObject(context); + + internal static string SerializeFlag(FeatureFlag flag) => + LdJsonSerialization.SerializeObject(flag); + + internal static string SerializeAll(FullDataSet allData) + { + return JsonUtils.WriteJsonAsString(w => + { + w.WriteStartObject(); + foreach (var item in allData.Items) + { + if (item.Value.Item != null) + { + w.WritePropertyName(item.Key); + JsonSerializer.Serialize(w, item.Value.Item); + } + } + w.WriteEndObject(); + }); + } + + internal static FeatureFlag DeserializeFlag(string json) + { + try + { + return LdJsonSerialization.DeserializeObject(json); + } + catch (Exception e) + { + throw new InvalidDataException(ParseErrorMessage, e); + } + } + + internal static FullDataSet DeserializeAll(string serializedData) + { + try + { + return DeserializeV1Schema(serializedData); + } + catch (InvalidDataException) + { + throw; + } + catch (Exception e) + { + throw new InvalidDataException(ParseErrorMessage, e); + } + throw new InvalidDataException(ParseErrorMessage); + } + + // Currently there is only one serialization schema, but it is possible that future + // SDK versions will require a richer model. In that case we will need to design the + // serialized format to be distinguishable from previous formats and allow reading + // of older formats, while only writing the new format. + + internal static FullDataSet DeserializeV1Schema(string serializedData) + { + var builder = ImmutableList.CreateBuilder>(); + var r = new Utf8JsonReader(Encoding.UTF8.GetBytes(serializedData)); + r.Read(); + + try + { + for (var obj = RequireObject(ref r); obj.Next(ref r);) + { + var name = obj.Name; + var flag = FeatureFlagJsonConverter.ReadJsonValue(ref r); + builder.Add(new KeyValuePair(name, flag.ToItemDescriptor())); + } + } + catch (Exception e) + { + throw new InvalidDataException(ParseErrorMessage, e); + } + return new FullDataSet(builder.ToImmutable()); + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/ConnectionManager.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/ConnectionManager.cs new file mode 100644 index 00000000..8eeb739d --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/ConnectionManager.cs @@ -0,0 +1,337 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Client.Interfaces; +using LaunchDarkly.Sdk.Client.Internal.Events; +using LaunchDarkly.Sdk.Internal; +using LaunchDarkly.Sdk.Client.Subsystems; + +namespace LaunchDarkly.Sdk.Client.Internal.DataSources +{ + /// + /// Manages our connection to LaunchDarkly, if any, and encapsulates all of the state that + /// determines whether we should have a connection or not. + /// + /// + /// Whenever the state of this object is modified by , + /// , , + /// , or , it will decide whether to make a new + /// connection, drop an existing connection, both, or neither. If the caller wants to know when a + /// new connection (if any) is ready, it should await the returned task. + /// + /// ConnectionManager also keeps track of whether event sending should be enabled. + /// + /// The object begins in a non-started state, so regardless of what properties are set, it will not + /// make a connection until after has been called. + /// + internal sealed class ConnectionManager : IDisposable + { + private readonly Logger _log; + private readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim(); + private readonly LdClientContext _clientContext; + private readonly IComponentConfigurer _dataSourceFactory; + private readonly IDataSourceUpdateSink _updateSink; + private readonly IEventProcessor _eventProcessor; + private readonly DiagnosticDisablerImpl _diagnosticDisabler; + private readonly bool _enableBackgroundUpdating; + private bool _disposed = false; + private bool _started = false; + private bool _initialized = false; + private bool _forceOffline = false; + private bool _networkEnabled = false; + private bool _inBackground = false; + private Context _context; + private IDataSource _dataSource = null; + + // Note that these properties do not have simple setter methods, because the setters all + // need to return Tasks. + + /// + /// True if we are in offline mode ( was set to true). + /// + public bool ForceOffline => LockUtils.WithReadLock(_lock, () => _forceOffline); + + /// + /// True if we have been told there is network connectivity ( + /// was set to true). + /// + public bool NetworkEnabled => LockUtils.WithReadLock(_lock, () => _networkEnabled); + + /// + /// True if we made a successful LaunchDarkly connection or do not need to make one (see + /// ). + /// + public bool Initialized => LockUtils.WithReadLock(_lock, () => _initialized); + + internal ConnectionManager( + LdClientContext clientContext, + IComponentConfigurer dataSourceFactory, + IDataSourceUpdateSink updateSink, + IEventProcessor eventProcessor, + DiagnosticDisablerImpl diagnosticDisabler, + bool enableBackgroundUpdating, + Context initialContext, + Logger log + ) + { + _clientContext = clientContext; + _dataSourceFactory = dataSourceFactory; + _updateSink = updateSink; + _eventProcessor = eventProcessor; + _diagnosticDisabler = diagnosticDisabler; + _enableBackgroundUpdating = enableBackgroundUpdating; + _context = initialContext; + _log = log; + } + + /// + /// Sets whether the client should always be offline, and attempts to connect if appropriate. + /// + /// + /// Besides updating the value of the property, we do the + /// following: + /// + /// If forceOffline is true, we drop our current connection (if any), and we will not + /// make any connections no matter what other properties are changed as long as this property is + /// still true. + /// + /// If forceOffline is false and we already have a connection, nothing happens. + /// + /// If forceOffline is false and we have no connection, but other conditions disallow + /// making a connection (or we do not have an update processor factory), nothing happens. + /// + /// If forceOffline is false, and we do not yet have a connection, and no other + /// conditions disallow making a connection, and we have an update processor factory, + /// we create an update processor and tell it to start. + /// + /// The returned task is immediately completed unless we are making a new connection, in which + /// case it is completed when the update processor signals success or failure. The task yields + /// a true result if we successfully made a connection or if we decided not to connect + /// because we are in offline mode. In other words, the result is true if + /// is true. + /// + /// true if the client should always be offline + /// a task as described above + public Task SetForceOffline(bool forceOffline) + { + return LockUtils.WithWriteLock(_lock, () => + { + if (_disposed || _forceOffline == forceOffline) + { + return Task.FromResult(false); + } + _forceOffline = forceOffline; + _log.Info("Offline mode is now {0}", forceOffline); + return OpenOrCloseConnectionIfNecessary(false); // not awaiting + }); + } + + /// + /// Sets whether we should be able to make network connections, and attempts to connect if appropriate. + /// + /// + /// Besides updating the value of the property, we do the + /// following: + /// + /// If networkEnabled is false, we drop our current connection (if any), and we will not + /// make any connections no matter what other properties are changed as long as this property is + /// still true. + /// + /// If networkEnabled is true and we already have a connection, nothing happens. + /// + /// If networkEnabled is true and we have no connection, but other conditions disallow + /// making a connection (or we do not have an update processor factory), nothing happens. + /// + /// If networkEnabled is true, and we do not yet have a connection, and no other + /// conditions disallow making a connection, and we have an update processor factory, + /// we create an update processor and tell it to start. + /// + /// The returned task is immediately completed unless we are making a new connection, in which + /// case it is completed when the update processor signals success or failure. The task yields + /// a true result if we successfully made a connection or if we decided not to connect + /// because we are in offline mode. In other words, the result is true if + /// is true. + /// + /// true if we think we can make network connections + /// a task as described above + public Task SetNetworkEnabled(bool networkEnabled) + { + return LockUtils.WithWriteLock(_lock, () => + { + if (_disposed || _networkEnabled == networkEnabled) + { + return Task.FromResult(false); + } + _networkEnabled = networkEnabled; + _log.Info("Network availability is now {0}", networkEnabled); + return OpenOrCloseConnectionIfNecessary(false); // not awaiting + }); + } + + /// + /// Sets whether the application is currently in the background. + /// + /// + /// When in the background, we use a different data source (polling, at a longer interval) + /// and we do not send diagnostic events. + /// + /// true if the application is now in the background + public void SetInBackground(bool inBackground) + { + LockUtils.WithWriteLock(_lock, () => + { + if (_disposed || _inBackground == inBackground) + { + return; + } + _inBackground = inBackground; + _log.Debug("Background mode is changing to {0}", inBackground); + _ = OpenOrCloseConnectionIfNecessary(true); // not awaiting + }); + } + + /// + /// Updates the current user. + /// + /// the new context + /// a task that is completed when we have received data for the new user, if the + /// data source is online, or completed immediately otherwise + public Task SetContext(Context context) + { + return LockUtils.WithWriteLock(_lock, () => + { + if (_disposed) + { + return Task.FromResult(false); + } + _context = context; + _initialized = false; + return OpenOrCloseConnectionIfNecessary(true); + }); + } + + /// + /// Tells the ConnectionManager that it can go ahead and connect if appropriate. + /// + /// a task which will yield true if this method results in a successful connection, or + /// if we are in offline mode and don't need to make a connection + public Task Start() + { + return LockUtils.WithWriteLock(_lock, () => + { + if (_started) + { + return Task.FromResult(_initialized); + } + _started = true; + return OpenOrCloseConnectionIfNecessary(false); // not awaiting + }); + } + + public void Dispose() + { + IDataSource dataSource = null; + LockUtils.WithWriteLock(_lock, () => + { + if (_disposed) + { + return; + } + dataSource = _dataSource; + _dataSource = null; + _disposed = true; + }); + dataSource?.Dispose(); + } + + // This method is called while _lock is being held. If we're starting up a new connection, we do + // *not* wait for it to succeed; we return a Task that will be completed once it succeeds. In all + // other cases we return an immediately-completed Task. + + private Task OpenOrCloseConnectionIfNecessary(bool mustReinitializeDataSource) + { + if (!_started) + { + return Task.FromResult(false); + } + + // Analytics event sending is enabled as long as we're allowed to do any network things. + // (If the SDK is configured not to send events, then this is a no-op because _eventProcessor + // will be a no-op implementation). + _eventProcessor.SetOffline(_forceOffline || !_networkEnabled); + + // Diagnostic events are disabled if we're in the background. + _diagnosticDisabler?.SetDisabled(_forceOffline || !_networkEnabled || _inBackground); + + if (mustReinitializeDataSource && _dataSource != null) + { + _dataSource?.Dispose(); + _dataSource = null; + } + + if (_networkEnabled && !_forceOffline) + { + if (_inBackground && !_enableBackgroundUpdating) + { + _log.Debug("Background updating is disabled"); + _updateSink.UpdateStatus(DataSourceState.BackgroundDisabled, null); + return Task.FromResult(true); + } + if (_dataSource is null) + { + // Set the state to Initializing when there's a new data source that has not yet + // started. The state will then be updated as appropriate by the data source either + // calling UpdateStatus, or Init which implies UpdateStatus(Valid). + _updateSink.UpdateStatus(DataSourceState.Initializing, null); + _dataSource = _dataSourceFactory.Build( + _clientContext.WithContextAndBackgroundState(_context, _inBackground) + .WithDataSourceUpdateSink(_updateSink)); + return _dataSource.Start() + .ContinueWith(SetInitializedIfUpdateProcessorStartedSuccessfully); + } + } + else + { + // Either we've been explicitly set to be offline (in which case the state is always + // SetOffline regardless of any other conditions), or we're offline because the network + // is unavailable. If either of those things changes, we'll end up calling this method + // again and the state will be updated if appropriate. + _dataSource?.Dispose(); + _dataSource = null; + _initialized = true; + _updateSink.UpdateStatus( + _forceOffline ? DataSourceState.SetOffline : DataSourceState.NetworkUnavailable, + null + ); + return Task.FromResult(true); + } + return Task.FromResult(false); + } + + // When this method is called, we are no longer holding the lock. + + private bool SetInitializedIfUpdateProcessorStartedSuccessfully(Task task) + { + if (task.IsCompleted) + { + if (task.IsFaulted) + { + // Don't let exceptions from the update processor propagate up into the SDK. Just say we didn't initialize. + LogHelpers.LogException(_log, "Failed to initialize LaunchDarkly connection", task.Exception); + return false; + } + var success = task.Result; + if (success) + { + LockUtils.WithWriteLock(_lock, () => + { + _initialized = true; + }); + return true; + } + } + return false; + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/DataSourceStatusProviderImpl.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/DataSourceStatusProviderImpl.cs new file mode 100644 index 00000000..c1a8dffc --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/DataSourceStatusProviderImpl.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading.Tasks; +using LaunchDarkly.Sdk.Client.Interfaces; +using LaunchDarkly.Sdk.Client.Subsystems; +using LaunchDarkly.Sdk.Internal.Concurrent; + +namespace LaunchDarkly.Sdk.Client.Internal.DataSources +{ + internal sealed class DataSourceStatusProviderImpl : IDataSourceStatusProvider + { + private readonly DataSourceUpdateSinkImpl _updateSink; + + public event EventHandler StatusChanged + { + add => _updateSink.StatusChanged += value; + remove => _updateSink.StatusChanged -= value; + } + + public DataSourceStatus Status => _updateSink.CurrentStatus; + + internal DataSourceStatusProviderImpl(DataSourceUpdateSinkImpl updateSink) + { + _updateSink = updateSink; + } + + public bool WaitFor(DataSourceState desiredState, TimeSpan timeout) => + AsyncUtils.WaitSafely(() => _updateSink.WaitForAsync(desiredState, timeout)); + + public Task WaitForAsync(DataSourceState desiredState, TimeSpan timeout) => + _updateSink.WaitForAsync(desiredState, timeout); + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/DataSourceUpdateSinkImpl.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/DataSourceUpdateSinkImpl.cs new file mode 100644 index 00000000..6c63db4e --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/DataSourceUpdateSinkImpl.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading.Tasks; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Client.Interfaces; +using LaunchDarkly.Sdk.Client.Internal.DataStores; +using LaunchDarkly.Sdk.Internal; +using LaunchDarkly.Sdk.Internal.Concurrent; +using LaunchDarkly.Sdk.Client.Subsystems; + +using static LaunchDarkly.Sdk.Client.DataModel; +using static LaunchDarkly.Sdk.Client.Subsystems.DataStoreTypes; + +namespace LaunchDarkly.Sdk.Client.Internal.DataSources +{ + internal sealed class DataSourceUpdateSinkImpl : IDataSourceUpdateSink + { + private readonly FlagDataManager _dataStore; + private readonly object _lastValuesLock = new object(); + private readonly TaskExecutor _taskExecutor; + + private volatile ImmutableDictionary> _lastValues = + ImmutableDictionary>.Empty; + + private readonly StateMonitor _status; + internal DataSourceStatus CurrentStatus => _status.Current; + + internal event EventHandler FlagValueChanged; + internal event EventHandler StatusChanged; + + internal DataSourceUpdateSinkImpl( + FlagDataManager dataStore, + bool isConfiguredOffline, + TaskExecutor taskExecutor, + Logger log + ) + { + _dataStore = dataStore; + _taskExecutor = taskExecutor; + var initialStatus = new DataSourceStatus + { + State = isConfiguredOffline ? DataSourceState.SetOffline : DataSourceState.Initializing, + StateSince = DateTime.Now, + LastError = null + }; + _status = new StateMonitor(initialStatus, MaybeUpdateStatus, log); + } + + public void Init(Context context, FullDataSet data) + { + _dataStore.Init(context, data, true); + + ImmutableDictionary oldValues, newValues; + var contextKey = context.FullyQualifiedKey; + lock (_lastValuesLock) + { + _lastValues.TryGetValue(contextKey, out oldValues); + var builder = ImmutableDictionary.CreateBuilder(); + foreach (var newEntry in data.Items) + { + var newFlag = newEntry.Value.Item; + if (newFlag != null) + { + builder.Add(newEntry.Key, newFlag); + } + } + newValues = builder.ToImmutable(); + _lastValues = _lastValues.SetItem(contextKey, newValues); + } + + UpdateStatus(DataSourceState.Valid, null); + + if (oldValues != null) + { + List events = new List(); + + foreach (var newEntry in newValues) + { + var newFlag = newEntry.Value; + if (oldValues.TryGetValue(newEntry.Key, out var oldFlag)) + { + if (newFlag.Variation != oldFlag.Variation) + { + events.Add(new FlagValueChangeEvent(newEntry.Key, + oldFlag.Value, newFlag.Value, false)); + } + } + else + { + events.Add(new FlagValueChangeEvent(newEntry.Key, + LdValue.Null, newFlag.Value, false)); + } + } + foreach (var oldEntry in oldValues) + { + if (!newValues.ContainsKey(oldEntry.Key)) + { + events.Add(new FlagValueChangeEvent(oldEntry.Key, + oldEntry.Value.Value, LdValue.Null, true)); + } + } + foreach (var e in events) + { + _taskExecutor.ScheduleEvent(e, FlagValueChanged); + } + } + } + + public void Upsert(Context context, string flagKey, ItemDescriptor data) + { + var updated = _dataStore.Upsert(flagKey, data); + if (!updated) + { + return; + } + + FeatureFlag oldFlag = null; + var contextKey = context.FullyQualifiedKey; + lock (_lastValuesLock) + { + _lastValues.TryGetValue(contextKey, out var oldValues); + if (oldValues is null) + { + // didn't have any flags for this user + var initValues = ImmutableDictionary.Empty; + if (data.Item != null) + { + initValues = initValues.SetItem(flagKey, data.Item); + } + _lastValues = _lastValues.SetItem(contextKey, initValues); + return; // don't bother with change events if we had no previous data + } + oldValues.TryGetValue(flagKey, out oldFlag); + var newValues = data.Item is null ? + oldValues.Remove(flagKey) : oldValues.SetItem(flagKey, data.Item); + _lastValues = _lastValues.SetItem(contextKey, newValues); + } + if (oldFlag?.Variation != data.Item?.Variation) + { + var eventArgs = new FlagValueChangeEvent(flagKey, + oldFlag?.Value ?? LdValue.Null, + data.Item?.Value ?? LdValue.Null, + data.Item is null + ); + _taskExecutor.ScheduleEvent(eventArgs, FlagValueChanged); + } + } + + private struct StateAndError + { + public DataSourceState State { get; set; } + public DataSourceStatus.ErrorInfo? Error { get; set; } + } + + private static DataSourceStatus? MaybeUpdateStatus( + DataSourceStatus oldStatus, + StateAndError update + ) + { + var newState = + (update.State == DataSourceState.Interrupted && oldStatus.State == DataSourceState.Initializing) + ? DataSourceState.Initializing // see comment on IDataSourceUpdateSink.UpdateStatus + : update.State; + + if (newState == oldStatus.State && !update.Error.HasValue) + { + return null; + } + return new DataSourceStatus + { + State = newState, + StateSince = newState == oldStatus.State ? oldStatus.StateSince : DateTime.Now, + LastError = update.Error ?? oldStatus.LastError + }; + } + + public void UpdateStatus(DataSourceState newState, DataSourceStatus.ErrorInfo? newError) + { + var updated = _status.Update(new StateAndError { State = newState, Error = newError }, + out var newStatus); + + if (updated) + { + _taskExecutor.ScheduleEvent(newStatus, StatusChanged); + } + } + + internal async Task WaitForAsync(DataSourceState desiredState, TimeSpan timeout) + { + var newStatus = await _status.WaitForAsync( + status => status.State == desiredState || status.State == DataSourceState.Shutdown, + timeout + ); + return newStatus.HasValue && newStatus.Value.State == desiredState; + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/DefaultBackgroundModeManager.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/DefaultBackgroundModeManager.cs new file mode 100644 index 00000000..acb8bc2c --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/DefaultBackgroundModeManager.cs @@ -0,0 +1,21 @@ +using System; +using LaunchDarkly.Sdk.Client.Internal.Interfaces; +using LaunchDarkly.Sdk.Client.PlatformSpecific; + +namespace LaunchDarkly.Sdk.Client.Internal.DataSources +{ + internal class DefaultBackgroundModeManager : IBackgroundModeManager + { + public event EventHandler BackgroundModeChanged + { + add + { + BackgroundDetection.BackgroundModeChanged += value; + } + remove + { + BackgroundDetection.BackgroundModeChanged -= value; + } + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/DefaultConnectivityStateManager.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/DefaultConnectivityStateManager.cs new file mode 100644 index 00000000..042e652c --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/DefaultConnectivityStateManager.cs @@ -0,0 +1,38 @@ +using System; +using LaunchDarkly.Sdk.Client.Internal.Interfaces; +using LaunchDarkly.Sdk.Client.PlatformSpecific; + +namespace LaunchDarkly.Sdk.Client.Internal.DataSources +{ + internal sealed class DefaultConnectivityStateManager : IConnectivityStateManager + { + public Action ConnectionChanged { get; set; } + + internal DefaultConnectivityStateManager() + { + UpdateConnectedStatus(); + PlatformConnectivity.ConnectivityChanged += Connectivity_ConnectivityChanged; + } + + bool isConnected; + bool IConnectivityStateManager.IsConnected + { + get { return isConnected; } + set + { + isConnected = value; + } + } + + void Connectivity_ConnectivityChanged(object sender, EventArgs e) + { + UpdateConnectedStatus(); + ConnectionChanged?.Invoke(isConnected); + } + + private void UpdateConnectedStatus() + { + isConnected = PlatformConnectivity.LdNetworkAccess == LdNetworkAccess.Internet; + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/FeatureFlagRequestor.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/FeatureFlagRequestor.cs new file mode 100644 index 00000000..c12d8c69 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/FeatureFlagRequestor.cs @@ -0,0 +1,147 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Internal; +using LaunchDarkly.Sdk.Internal.Http; +using LaunchDarkly.Sdk.Client.Subsystems; + +namespace LaunchDarkly.Sdk.Client.Internal.DataSources +{ + internal struct WebResponse + { + public int statusCode { get; private set; } + public string jsonResponse { get; private set; } + public string errorMessage { get; private set; } + + public WebResponse(int code, string response, string error) + { + statusCode = code; + jsonResponse = response; + errorMessage = error; + } + } + + internal interface IFeatureFlagRequestor : IDisposable + { + Task FeatureFlagsAsync(); + } + + internal sealed class FeatureFlagRequestor : IFeatureFlagRequestor + { + private static readonly HttpMethod ReportMethod = new HttpMethod("REPORT"); + + private readonly Uri _baseUri; + private readonly Context _currentContext; + private readonly bool _useReport; + private readonly bool _withReasons; + private readonly HttpClient _httpClient; + private readonly HttpConfiguration _httpConfig; + private readonly Logger _log; + private volatile EntityTagHeaderValue _etag; + + internal FeatureFlagRequestor( + Uri baseUri, + Context context, + bool withReasons, + HttpConfiguration httpConfig, + Logger log + ) + { + this._baseUri = baseUri; + this._httpConfig = httpConfig; + this._httpClient = httpConfig.HttpProperties.NewHttpClient(); + this._currentContext = context; + this._useReport = httpConfig.UseReport; + this._withReasons = withReasons; + this._log = log; + } + + public async Task FeatureFlagsAsync() + { + var requestMessage = _useReport ? ReportRequestMessage() : GetRequestMessage(); + return await MakeRequest(requestMessage); + } + + private HttpRequestMessage GetRequestMessage() + { + var path = StandardEndpoints.PollingRequestGetRequestPath( + Base64.UrlSafeEncode(DataModelSerialization.SerializeContext(_currentContext))); + return new HttpRequestMessage(HttpMethod.Get, MakeRequestUriWithPath(path)); + } + + private HttpRequestMessage ReportRequestMessage() + { + var request = new HttpRequestMessage(ReportMethod, MakeRequestUriWithPath(StandardEndpoints.PollingRequestReportRequestPath)); + request.Content = new StringContent(DataModelSerialization.SerializeContext(_currentContext), Encoding.UTF8, Constants.APPLICATION_JSON); + return request; + } + + private Uri MakeRequestUriWithPath(string path) + { + var uri = _baseUri.AddPath(path); + return _withReasons ? uri.AddQuery("withReasons=true") : uri; + } + + private async Task MakeRequest(HttpRequestMessage request) + { + _httpConfig.HttpProperties.AddHeaders(request); + using (var cts = new CancellationTokenSource(_httpConfig.ResponseStartTimeout)) + { + if (_etag != null) + { + request.Headers.IfNoneMatch.Add(_etag); + } + + try + { + _log.Debug("Getting flags with uri: {0}", request.RequestUri.AbsoluteUri); + using (var response = await _httpClient.SendAsync(request, cts.Token).ConfigureAwait(false)) + { + if (response.StatusCode == HttpStatusCode.NotModified) + { + _log.Debug("Get all flags returned 304: not modified"); + return new WebResponse(304, null, "Get all flags returned 304: not modified"); + } + _etag = response.Headers.ETag; + //We ensure the status code after checking for 304, because 304 isn't considered success + if (!response.IsSuccessStatusCode) + { + throw new UnsuccessfulResponseException((int)response.StatusCode); + } + + var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return new WebResponse(200, content, null); + } + } + catch (TaskCanceledException tce) + { + if (tce.CancellationToken == cts.Token) + { + //Indicates the task was cancelled by something other than a request timeout + throw; + } + //Otherwise this was a request timeout. + throw new TimeoutException("Get item with URL: " + request.RequestUri + + " timed out after : " + _httpConfig.ResponseStartTimeout); + } + } + } + + // Sealed, non-derived class should implement Dispose() and finalize method, not Dispose(boolean) + public void Dispose() + { + _httpClient.Dispose(); + GC.SuppressFinalize(this); + } + + ~FeatureFlagRequestor() + { + Dispose(); + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/PollingDataSource.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/PollingDataSource.cs new file mode 100644 index 00000000..9379801b --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/PollingDataSource.cs @@ -0,0 +1,139 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Client.Interfaces; +using LaunchDarkly.Sdk.Internal; +using LaunchDarkly.Sdk.Internal.Concurrent; +using LaunchDarkly.Sdk.Internal.Http; +using LaunchDarkly.Sdk.Client.Subsystems; + +namespace LaunchDarkly.Sdk.Client.Internal.DataSources +{ + internal sealed class PollingDataSource : IDataSource + { + private readonly IFeatureFlagRequestor _featureFlagRequestor; + private readonly IDataSourceUpdateSink _updateSink; + private readonly Context _context; + private readonly TimeSpan _pollingInterval; + private readonly TimeSpan _initialDelay; + private readonly Logger _log; + private readonly TaskExecutor _taskExecutor; + private readonly TaskCompletionSource _startTask; + private volatile CancellationTokenSource _canceller; + private readonly AtomicBoolean _initialized = new AtomicBoolean(false); + + internal PollingDataSource( + IDataSourceUpdateSink updateSink, + Context context, + IFeatureFlagRequestor featureFlagRequestor, + TimeSpan pollingInterval, + TimeSpan initialDelay, + TaskExecutor taskExecutor, + Logger log) + { + this._featureFlagRequestor = featureFlagRequestor; + this._updateSink = updateSink; + this._context = context; + this._pollingInterval = pollingInterval; + this._initialDelay = initialDelay; + this._taskExecutor = taskExecutor; + this._log = log; + _startTask = new TaskCompletionSource(); + } + + public Task Start() + { + if (_pollingInterval.Equals(TimeSpan.Zero)) + throw new Exception("Timespan for polling can't be zero"); + + if (_initialDelay > TimeSpan.Zero) + { + _log.Info("Starting LaunchDarkly PollingProcessor with interval: {0} (waiting {1} first)", _pollingInterval, _initialDelay); + } + else + { + _log.Info("Starting LaunchDarkly PollingProcessor with interval: {0}", _pollingInterval); + } + + _canceller = _taskExecutor.StartRepeatingTask(_initialDelay, _pollingInterval, UpdateTaskAsync); + return _startTask.Task; + } + + public bool Initialized => _initialized.Get(); + + private async Task UpdateTaskAsync() + { + try + { + var response = await _featureFlagRequestor.FeatureFlagsAsync(); + if (response.statusCode == 200) + { + var flagsAsJsonString = response.jsonResponse; + var allData = DataModelSerialization.DeserializeV1Schema(flagsAsJsonString); + _updateSink.Init(_context, allData); + + if (_initialized.GetAndSet(true) == false) + { + _startTask.SetResult(true); + _log.Info("Initialized LaunchDarkly Polling Processor."); + } + } + } + catch (UnsuccessfulResponseException ex) + { + var errorInfo = DataSourceStatus.ErrorInfo.FromHttpError(ex.StatusCode); + + if (HttpErrors.IsRecoverable(ex.StatusCode)) + { + _log.Warn(HttpErrors.ErrorMessage(ex.StatusCode, "polling request", "will retry")); + _updateSink.UpdateStatus(DataSourceState.Interrupted, errorInfo); + } + else + { + _log.Error(HttpErrors.ErrorMessage(ex.StatusCode, "polling request", "")); + _updateSink.UpdateStatus(DataSourceState.Shutdown, errorInfo); + + // if client is initializing, make it stop waiting + _startTask.TrySetResult(false); + + ((IDisposable)this).Dispose(); + } + } + catch (InvalidDataException ex) + { + _log.Error("Polling request received malformed data: {0}", LogValues.ExceptionSummary(ex)); + _updateSink.UpdateStatus(DataSourceState.Interrupted, + new DataSourceStatus.ErrorInfo + { + Kind = DataSourceStatus.ErrorKind.InvalidData, + Time = DateTime.Now + }); + } + catch (Exception ex) + { + Exception realEx = (ex is AggregateException ae) ? ae.Flatten() : ex; + _log.Warn("Polling for feature flag updates failed: {0}", LogValues.ExceptionSummary(realEx)); + _log.Debug(LogValues.ExceptionTrace(realEx)); + _updateSink.UpdateStatus(DataSourceState.Interrupted, + DataSourceStatus.ErrorInfo.FromException(realEx)); + } + } + + void IDisposable.Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (disposing) + { + _canceller?.Cancel(); + _featureFlagRequestor.Dispose(); + } + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/StreamingDataSource.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/StreamingDataSource.cs new file mode 100644 index 00000000..caf96d2c --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataSources/StreamingDataSource.cs @@ -0,0 +1,308 @@ +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using LaunchDarkly.EventSource; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Client.Interfaces; +using LaunchDarkly.Sdk.Internal; +using LaunchDarkly.Sdk.Internal.Events; +using LaunchDarkly.Sdk.Internal.Concurrent; +using LaunchDarkly.Sdk.Internal.Http; +using LaunchDarkly.Sdk.Client.Subsystems; + +using static LaunchDarkly.Sdk.Client.Subsystems.DataStoreTypes; + +namespace LaunchDarkly.Sdk.Client.Internal.DataSources +{ + internal sealed class StreamingDataSource : IDataSource + { + // The read timeout for the stream is not the same read timeout that can be set in the SDK configuration. + // It is a fixed value that is set to be slightly longer than the expected interval between heartbeats + // from the LaunchDarkly streaming server. If this amount of time elapses with no new data, the connection + // will be cycled. + private static readonly TimeSpan LaunchDarklyStreamReadTimeout = TimeSpan.FromMinutes(5); + + private static readonly HttpMethod ReportMethod = new HttpMethod("REPORT"); + + private readonly IDataSourceUpdateSink _updateSink; + private readonly Uri _baseUri; + private readonly Context _context; + private readonly bool _useReport; + private readonly bool _withReasons; + private readonly TimeSpan _initialReconnectDelay; + private readonly IFeatureFlagRequestor _requestor; + private readonly HttpProperties _httpProperties; + private readonly IDiagnosticStore _diagnosticStore; + private readonly TaskCompletionSource _initTask; + private readonly AtomicBoolean _initialized = new AtomicBoolean(false); + private readonly Logger _log; + + private volatile IEventSource _eventSource; + + internal DateTime _esStarted; // exposed for testing + + internal StreamingDataSource( + IDataSourceUpdateSink updateSink, + Context context, + Uri baseUri, + bool withReasons, + TimeSpan initialReconnectDelay, + IFeatureFlagRequestor requestor, + HttpConfiguration httpConfig, + Logger log, + IDiagnosticStore diagnosticStore + ) + { + this._updateSink = updateSink; + this._context = context; + this._baseUri = baseUri; + this._useReport = httpConfig.UseReport; + this._withReasons = withReasons; + this._initialReconnectDelay = initialReconnectDelay; + this._requestor = requestor; + this._httpProperties = httpConfig.HttpProperties; + this._diagnosticStore = diagnosticStore; + this._initTask = new TaskCompletionSource(); + this._log = log; + } + + public bool Initialized => _initialized.Get(); + + public Task Start() + { + if (_useReport) + { + _eventSource = CreateEventSource( + _httpProperties, + ReportMethod, + MakeRequestUriWithPath(StandardEndpoints.StreamingReportRequestPath), + DataModelSerialization.SerializeContext(_context) + ); + } + else + { + _eventSource = CreateEventSource( + _httpProperties, + HttpMethod.Get, + MakeRequestUriWithPath(StandardEndpoints.StreamingGetRequestPath( + Base64.UrlSafeEncode(DataModelSerialization.SerializeContext(_context)))), + null + ); + } + + _eventSource.MessageReceived += OnMessage; + _eventSource.Error += OnError; + _eventSource.Opened += OnOpen; + + _esStarted = DateTime.Now; + + _ = Task.Run(() => _eventSource.StartAsync()); + return _initTask.Task; + } + + private IEventSource CreateEventSource( + HttpProperties httpProperties, + HttpMethod method, + Uri uri, + string jsonBody + ) + { + var configBuilder = EventSource.Configuration.Builder(uri) + .Method(method) + .HttpMessageHandler(httpProperties.NewHttpMessageHandler()) + .ResponseStartTimeout(httpProperties.ConnectTimeout) + .InitialRetryDelay(_initialReconnectDelay) + .ReadTimeout(LaunchDarklyStreamReadTimeout) + .RequestHeaders(httpProperties.BaseHeaders.ToDictionary(kv => kv.Key, kv => kv.Value)) + .Logger(_log); + if (jsonBody != null) + { + configBuilder.RequestBody(jsonBody, "application/json"); + } + return new EventSource.EventSource(configBuilder.Build()); + } + + private Uri MakeRequestUriWithPath(string path) + { + var uri = _baseUri.AddPath(path); + return _withReasons ? uri.AddQuery("withReasons=true") : uri; + } + + private void RecordStreamInit(bool failed) + { + if (_diagnosticStore != null) + { + DateTime now = DateTime.Now; + _diagnosticStore.AddStreamInit(_esStarted, now - _esStarted, failed); + _esStarted = now; + } + } + + private void OnOpen(object sender, EventSource.StateChangedEventArgs e) + { + _log.Debug("EventSource Opened"); + RecordStreamInit(false); + } + + private void OnMessage(object sender, EventSource.MessageReceivedEventArgs e) + { + try + { + HandleMessage(e.EventName, e.Message.Data); + } + catch (InvalidDataException ex) + { + _log.Error("LaunchDarkly service request failed or received invalid data: {0}", + LogValues.ExceptionSummary(ex)); + + var errorInfo = new DataSourceStatus.ErrorInfo + { + Kind = DataSourceStatus.ErrorKind.InvalidData, + Message = ex.Message, + Time = DateTime.Now + }; + _updateSink.UpdateStatus(DataSourceState.Interrupted, errorInfo); + + _eventSource.Restart(false); + } + catch (Exception ex) + { + LogHelpers.LogException(_log, "Unexpected error in stream processing", ex); + } + } + + private void OnError(object sender, EventSource.ExceptionEventArgs e) + { + var ex = e.Exception; + var recoverable = true; + DataSourceStatus.ErrorInfo errorInfo; + + RecordStreamInit(true); + + if (ex is EventSourceServiceUnsuccessfulResponseException respEx) + { + int status = respEx.StatusCode; + errorInfo = DataSourceStatus.ErrorInfo.FromHttpError(status); + if (!HttpErrors.IsRecoverable(status)) + { + recoverable = false; + _log.Error(HttpErrors.ErrorMessage(status, "streaming connection", "")); + } + else + { + _log.Warn(HttpErrors.ErrorMessage(status, "streaming connection", "will retry")); + } + } + else + { + errorInfo = DataSourceStatus.ErrorInfo.FromException(ex); + _log.Warn("Encountered EventSource error: {0}", LogValues.ExceptionSummary(ex)); + _log.Debug(LogValues.ExceptionTrace(ex)); + } + + _updateSink.UpdateStatus(recoverable ? DataSourceState.Interrupted : DataSourceState.Shutdown, + errorInfo); + + if (!recoverable) + { + // Make _initTask complete to tell the client to stop waiting for initialization. We use + // TrySetResult rather than SetResult here because it might have already been completed + // (if for instance the stream started successfully, then restarted and got a 401). + _initTask.TrySetResult(false); + ((IDisposable)this).Dispose(); + } + } + + void HandleMessage(string messageType, string messageData) + { + _log.Debug("Event '{0}': {1}", messageType, messageData); + switch (messageType) + { + case Constants.PUT: + { + var allData = DataModelSerialization.DeserializeV1Schema(messageData); + _updateSink.Init(_context, allData); + if (!_initialized.GetAndSet(true)) + { + _initTask.SetResult(true); + } + break; + } + case Constants.PATCH: + { + try + { + var parsed = LdValue.Parse(messageData); + var flagkey = parsed.Get(Constants.KEY).AsString; + var featureFlag = DataModelSerialization.DeserializeFlag(messageData); + _updateSink.Upsert(_context, flagkey, featureFlag.ToItemDescriptor()); + } + catch (Exception ex) + { + LogHelpers.LogException(_log, "Error parsing PATCH message", ex); + _log.Debug("Message data follows: {0}", messageData); + } + break; + } + case Constants.DELETE: + { + try + { + var parsed = LdValue.Parse(messageData); + int version = parsed.Get(Constants.VERSION).AsInt; + string flagKey = parsed.Get(Constants.KEY).AsString; + var deletedItem = new ItemDescriptor(version, null); + _updateSink.Upsert(_context, flagKey, deletedItem); + } + catch (Exception ex) + { + LogHelpers.LogException(_log, "Error parsing DELETE message", ex); + _log.Debug("Message data follows: {0}", messageData); + } + break; + } + case Constants.PING: + { + Task.Run(async () => + { + try + { + var response = await _requestor.FeatureFlagsAsync(); + var flagsAsJsonString = response.jsonResponse; + var allData = DataModelSerialization.DeserializeV1Schema(flagsAsJsonString); + _updateSink.Init(_context, allData); + if (!_initialized.GetAndSet(true)) + { + _initTask.SetResult(true); + } + } + catch (Exception ex) + { + LogHelpers.LogException(_log, "Error in handling PING message", ex); + } + }); + break; + } + default: + break; + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (disposing) + { + _eventSource?.Close(); + _requestor?.Dispose(); + } + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataStores/ContextIndex.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataStores/ContextIndex.cs new file mode 100644 index 00000000..ff645bcf --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataStores/ContextIndex.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Text; +using System.Text.Json; +using System.Linq; +using LaunchDarkly.Sdk.Internal; + +using static LaunchDarkly.Sdk.Internal.JsonConverterHelpers; + +namespace LaunchDarkly.Sdk.Client.Internal.DataStores +{ + /// + /// Used internally to track which contexts have flag data in the persistent store. + /// + /// + /// + /// This exists because we can't assume that the persistent store mechanism has an "enumerate + /// all the keys that exist under such-and-such prefix" capability, so we need a table of + /// contents at a fixed location. The only information being tracked here is, for each flag + /// data set that exists in storage, 1. a context identifier (hashed fully-qualified key, as + /// defined by FlagDataManager.ContextIdFor) and 2. the millisecond timestamp when it was + /// last accessed, to support the LRU eviction behavior of FlagDataManager. + /// + /// + /// To minimize overhead, this is stored as JSON in a very simple format: a JSON array where + /// each element is a nested JSON array in the form ["contextId", millisecondTimestamp]. + /// + /// + internal class ContextIndex + { + internal ImmutableList Data { get; } + + internal struct IndexEntry + { + public string ContextId { get; set; } + public UnixMillisecondTime Timestamp { get; set; } + } + + internal ContextIndex(ImmutableList data = null) + { + Data = data ?? ImmutableList.Empty; + } + + public ContextIndex UpdateTimestamp(string contextId, UnixMillisecondTime timestamp) + { + var builder = ImmutableList.CreateBuilder(); + builder.AddRange(Data.Where(e => e.ContextId != contextId)); + builder.Add(new IndexEntry { ContextId = contextId, Timestamp = timestamp }); + return new ContextIndex(builder.ToImmutable()); + } + + public ContextIndex Prune(int maxContextsToRetain, out IEnumerable removedUserIds) + { + if (Data.Count <= maxContextsToRetain) + { + removedUserIds = ImmutableList.Empty; + return this; + } + // The data will normally already be in ascending timestamp order, in which case this Sort + // won't do anything, but this is just in case unsorted data somehow got persisted. + var sorted = Data.Sort((e1, e2) => e1.Timestamp.CompareTo(e2.Timestamp)); + var numDrop = Data.Count - maxContextsToRetain; + removedUserIds = ImmutableList.CreateRange(sorted.Take(numDrop).Select(e => e.ContextId)); + return new ContextIndex(ImmutableList.CreateRange(sorted.Skip(numDrop))); + } + + /// + /// Returns a JSON representation of the context index. + /// + /// the JSON representation + public string Serialize() + { + return JsonUtils.WriteJsonAsString(w => + { + w.WriteStartArray(); + { + foreach (var e in Data) + { + w.WriteStartArray(); + w.WriteStringValue(e.ContextId); + w.WriteNumberValue(e.Timestamp.Value); + w.WriteEndArray(); + } + } + w.WriteEndArray(); + }); + } + + /// + /// Parses the context index from a JSON representation. If the JSON string is null or + /// empty, it returns an empty index. + /// + /// the JSON representation + /// the parsed data + /// if the JSON is malformed + public static ContextIndex Deserialize(string json) + { + if (string.IsNullOrEmpty(json)) + { + return new ContextIndex(); + } + var builder = ImmutableList.CreateBuilder(); + try + { + var r = new Utf8JsonReader(Encoding.UTF8.GetBytes(json)); + for (var a0 = RequireArray(ref r); a0.Next(ref r);) + { + var a1 = RequireArray(ref r); + if (a1.Next(ref r)) + { + var contextId = r.GetString(); + if (a1.Next(ref r)) + { + var timeMillis = r.GetInt64(); + builder.Add(new IndexEntry { ContextId = contextId, Timestamp = UnixMillisecondTime.OfMillis(timeMillis) }); + while (a1.Next(ref r)) { } // discard any extra elements + } + } + } + } + catch (Exception e) + { + throw new FormatException("invalid stored context index", e); + } + return new ContextIndex(builder.ToImmutable()); + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataStores/FlagDataManager.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataStores/FlagDataManager.cs new file mode 100644 index 00000000..37caff83 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataStores/FlagDataManager.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Internal; +using LaunchDarkly.Sdk.Client.Subsystems; + +using static LaunchDarkly.Sdk.Client.Subsystems.DataStoreTypes; + +namespace LaunchDarkly.Sdk.Client.Internal.DataStores +{ + /// + /// The component that maintains the state of last known flag values, and manages + /// persistent storage if enabled. + /// + /// + /// + /// The state of the consists of all the + /// s for a specific user, plus optionally persistent + /// storage for some number of other users. + /// + /// + /// This is not a pluggable component - there can only be one implementation. The only + /// piece of behavior that is platform-dependent and customizable is the implementation + /// of persistent storage, which is represented by the + /// interface. + /// + /// + internal sealed class FlagDataManager : IDisposable + { + private readonly int _maxCachedUsers; + private readonly PersistentDataStoreWrapper _persistentStore; + private readonly object _writerLock = new object(); + private readonly Logger _log; + + private volatile ImmutableDictionary _flags = + ImmutableDictionary.Empty; + private volatile ContextIndex _storeIndex = null; + private string _currentContextId = null; + + public PersistentDataStoreWrapper PersistentStore => _persistentStore; + + public FlagDataManager( + string mobileKey, + PersistenceConfiguration persistenceConfiguration, + Logger log + ) + { + _log = log; + + if (persistenceConfiguration is null || persistenceConfiguration.MaxCachedUsers == 0 + || persistenceConfiguration.PersistentDataStore is NullPersistentDataStore) + { + _persistentStore = null; + _maxCachedUsers = 0; + } + else + { + _persistentStore = new PersistentDataStoreWrapper( + persistenceConfiguration.PersistentDataStore, + mobileKey, + log + ); + _maxCachedUsers = persistenceConfiguration.MaxCachedUsers; + _storeIndex = _persistentStore.GetIndex(); + } + } + + /// + /// Attempts to retrieve cached data for the specified context, if any. This does not + /// affect the current context/flags state. + /// + /// an evaluation context + /// that context's data from the persistent store, or null if none + public FullDataSet? GetCachedData(Context context) => + _persistentStore is null ? null : _persistentStore.GetContextData(ContextIdFor(context)); + + /// + /// Replaces the current flag data and updates the current-context state, optionally + /// updating persistent storage as well. + /// + /// the context that should become the current context + /// the full flag data + /// true to also update the flag data in + /// persistent storage (if persistent storage is enabled) + public void Init(Context context, FullDataSet data, bool updatePersistentStorage) + { + var newFlags = data.Items.ToImmutableDictionary(); + IEnumerable removedUserIds = null; + var contextId = ContextIdFor(context); + var updatedIndex = _storeIndex; + + lock (_writerLock) + { + _flags = newFlags; + + if (_storeIndex != null) + { + updatedIndex = _storeIndex.UpdateTimestamp(contextId, UnixMillisecondTime.Now) + .Prune(_maxCachedUsers, out removedUserIds); + _storeIndex = updatedIndex; + } + + _currentContextId = contextId; + } + + if (_persistentStore != null) + { + try + { + if (removedUserIds != null) + { + foreach (var oldId in removedUserIds) + { + _persistentStore.RemoveContextData(oldId); + } + } + if (updatePersistentStorage) + { + _persistentStore.SetContextData(contextId, data); + } + _persistentStore.SetIndex(updatedIndex); + } + catch (Exception e) + { + LogHelpers.LogException(_log, "Failed to write to persistent store", e); + } + } + } + + /// + /// Attempts to get a flag by key from the current flags. This always uses the + /// in-memory cache, not persistent storage. + /// + /// the flag key + /// the flag descriptor, or null if not found + public ItemDescriptor? Get(string key) => + _flags.TryGetValue(key, out var item) ? item : (ItemDescriptor?)null; + + /// + /// Returns all current flags. This always uses the in-memory cache, not + /// persistent storage. + /// + /// the data set + public FullDataSet? GetAll() => + new FullDataSet(_flags); + + /// + /// Attempts to update or insert a flag. + /// + /// + /// This implements the usual versioning logic for updates: the update only succeeds if + /// data.Version is greater than the version of any current data for the same key. + /// If successful, and if persistent storage is enabled, it also updates persistent storage. + /// Therefore implementations do not need to implement + /// their own version checking. + /// + /// the flag key + /// the updated flag data, or a tombstone for a deleted flag + /// true if the update was done; false if it was not done due to a too-low + /// version number + public bool Upsert(string key, ItemDescriptor data) + { + var updatedFlags = _flags; + string contextId = null; + + lock (_writerLock) + { + if (_flags.TryGetValue(key, out var oldItem) && oldItem.Version >= data.Version) + { + return false; + } + updatedFlags = _flags.SetItem(key, data); + _flags = updatedFlags; + contextId = _currentContextId; + } + + _persistentStore?.SetContextData(contextId, new FullDataSet(updatedFlags)); + return true; + } + + public void Dispose() => _persistentStore?.Dispose(); + + internal static string ContextIdFor(Context context) => Base64.UrlSafeSha256Hash(context.FullyQualifiedKey); + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataStores/NullPersistentDataStore.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataStores/NullPersistentDataStore.cs new file mode 100644 index 00000000..661fb377 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataStores/NullPersistentDataStore.cs @@ -0,0 +1,23 @@ +using LaunchDarkly.Sdk.Client.Subsystems; + +namespace LaunchDarkly.Sdk.Client.Internal.DataStores +{ + internal sealed class NullPersistentDataStoreFactory : IComponentConfigurer + { + internal static readonly NullPersistentDataStoreFactory Instance = new NullPersistentDataStoreFactory(); + + public IPersistentDataStore Build(LdClientContext context) => + NullPersistentDataStore.Instance; + } + + internal sealed class NullPersistentDataStore : IPersistentDataStore + { + internal static readonly NullPersistentDataStore Instance = new NullPersistentDataStore(); + + public string GetValue(string storageNamespace, string key) => null; + + public void SetValue(string storageNamespace, string key, string value) { } + + public void Dispose() { } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataStores/PersistenceConfiguration.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataStores/PersistenceConfiguration.cs new file mode 100644 index 00000000..ed66bfab --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataStores/PersistenceConfiguration.cs @@ -0,0 +1,19 @@ +using LaunchDarkly.Sdk.Client.Subsystems; + +namespace LaunchDarkly.Sdk.Client.Internal.DataStores +{ + internal sealed class PersistenceConfiguration + { + public IPersistentDataStore PersistentDataStore { get; } + public int MaxCachedUsers { get; } + + internal PersistenceConfiguration( + IPersistentDataStore persistentDataStore, + int maxCachedUsers + ) + { + PersistentDataStore = persistentDataStore; + MaxCachedUsers = maxCachedUsers; + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataStores/PersistentDataStoreWrapper.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataStores/PersistentDataStoreWrapper.cs new file mode 100644 index 00000000..ee164d99 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/DataStores/PersistentDataStoreWrapper.cs @@ -0,0 +1,143 @@ +using System; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Internal; +using LaunchDarkly.Sdk.Internal.Concurrent; +using LaunchDarkly.Sdk.Client.Subsystems; + +using static LaunchDarkly.Sdk.Client.Subsystems.DataStoreTypes; + +namespace LaunchDarkly.Sdk.Client.Internal.DataStores +{ + /// + /// A facade over some implementation of , which adds + /// behavior that should be the same for all implementations, such as the specific data + /// keys we use, the logging of errors, and how data is serialized and deserialized. This + /// allows FlagDataManager (and other parts of the SDK that may need to access persistent + /// storage) to be written in a clearer way without embedding many implementation details. + /// + /// + /// See for the rules about what namespaces and keys + /// we can use. It is 's responsibility to follow + /// those rules. We are OK as long as we use base64url-encoding for all variables such as + /// user key and mobile key, and use only characters from the base64url set (A-Z, a-z, + /// 0-9, -, and _) for other namespace/key components. + /// + internal sealed class PersistentDataStoreWrapper : IDisposable + { + private const string NamespacePrefix = "LaunchDarkly"; + private const string GlobalAnonContextKey = "anonUser"; + private const string EnvironmentMetadataKey = "index"; + private const string EnvironmentContextDataKeyPrefix = "flags_"; + + private readonly IPersistentDataStore _persistentStore; + private readonly string _globalNamespace; + private readonly string _environmentNamespace; + + private readonly Logger _log; + private readonly object _storeLock = new object(); + private readonly AtomicBoolean _loggedStorageError = new AtomicBoolean(false); + + public PersistentDataStoreWrapper( + IPersistentDataStore persistentStore, + string mobileKey, + Logger log + ) + { + _persistentStore = persistentStore; + _log = log; + + _globalNamespace = NamespacePrefix; + _environmentNamespace = NamespacePrefix + "_" + Base64.UrlSafeSha256Hash(mobileKey); + } + + public FullDataSet? GetContextData(string contextId) + { + var serializedData = HandleErrorsAndLock(() => _persistentStore.GetValue(_environmentNamespace, KeyForContextId(contextId))); + if (serializedData is null) + { + return null; + } + try + { + return DataModelSerialization.DeserializeAll(serializedData); + } + catch (Exception e) + { + LogHelpers.LogException(_log, "Failed to deserialize data from persistent store", e); + return null; + } + } + + public void SetContextData(string contextId, FullDataSet data) => + HandleErrorsAndLock(() => _persistentStore.SetValue(_environmentNamespace, KeyForContextId(contextId), + DataModelSerialization.SerializeAll(data))); + + public void RemoveContextData(string contextId) => + HandleErrorsAndLock(() => _persistentStore.SetValue(_environmentNamespace, KeyForContextId(contextId), null)); + + public ContextIndex GetIndex() + { + string data = HandleErrorsAndLock(() => _persistentStore.GetValue(_environmentNamespace, EnvironmentMetadataKey)); + if (data is null) + { + return new ContextIndex(); + } + try + { + return ContextIndex.Deserialize(data); + } + catch (Exception) + { + _log.Warn("Discarding invalid data from persistent store index"); + return new ContextIndex(); + } + } + + public void SetIndex(ContextIndex index) => + HandleErrorsAndLock(() => _persistentStore.SetValue(_environmentNamespace, EnvironmentMetadataKey, index.Serialize())); + + public string GetGeneratedContextKey(ContextKind contextKind) => + HandleErrorsAndLock(() => _persistentStore.GetValue(_globalNamespace, KeyForGeneratedContextKey(contextKind))); + + public void SetGeneratedContextKey(ContextKind contextKind, string value) => + HandleErrorsAndLock(() => _persistentStore.SetValue(_globalNamespace, + KeyForGeneratedContextKey(contextKind), value)); + + public void Dispose() => + _persistentStore.Dispose(); + + private static string KeyForContextId(string contextId) => EnvironmentContextDataKeyPrefix + contextId; + + private static string KeyForGeneratedContextKey(ContextKind contextKind) => + contextKind.IsDefault ? GlobalAnonContextKey : (GlobalAnonContextKey + ":" + contextKind.Value); + + private void MaybeLogStoreError(Exception e) + { + if (!_loggedStorageError.GetAndSet(true)) + { + LogHelpers.LogException(_log, "Failure in persistent data store", e); + } + } + + private T HandleErrorsAndLock(Func action) + { + try + { + lock (_storeLock) + { + return action(); + } + } + catch (Exception e) + { + MaybeLogStoreError(e); + return default(T); + } + } + + private void HandleErrorsAndLock(Action action) + { + _ = HandleErrorsAndLock(() => { action(); return true; }); + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Events/ClientDiagnosticStore.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Events/ClientDiagnosticStore.cs new file mode 100644 index 00000000..7910b2d0 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Events/ClientDiagnosticStore.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using LaunchDarkly.Sdk.Client.Subsystems; +using LaunchDarkly.Sdk.Internal.Events; +using LaunchDarkly.Sdk.Internal.Http; + +namespace LaunchDarkly.Sdk.Client.Internal.Events +{ + internal class ClientDiagnosticStore : DiagnosticStoreBase + { + private readonly LdClientContext _context; + private readonly Configuration _config; + private readonly TimeSpan _startWaitTime; + + protected override string SdkKeyOrMobileKey => _context.MobileKey; + protected override string SdkName => SdkPackage.Name; + protected override IEnumerable ConfigProperties => GetConfigProperties(); + protected override string DotNetTargetFramework => SdkPackage.DotNetTargetFramework; + protected override HttpProperties HttpProperties => _context.Http.HttpProperties; + protected override Type TypeOfLdClient => typeof(LdClient); + + internal ClientDiagnosticStore(LdClientContext context, Configuration config, TimeSpan startWaitTime) + { + _context = context; + _config = config; + _startWaitTime = startWaitTime; + // We pass in startWaitTime separately because in the client-side SDK, it is not + // part of the configuration - it is a separate parameter to the LdClient + // constructor. That's because this parameter only matters if they use the + // synchronous method LdClient.Init(); if they use LdClient.InitAsync() instead, + // there's no such thing as a startup timeout within the SDK (in which case this + // parameter will be zero and the corresponding property in the diagnostic event + // data will be zero, since there is no meaningful value for it). + } + + private IEnumerable GetConfigProperties() + { + yield return LdValue.BuildObject() + .WithStartWaitTime(_startWaitTime) + .Add("backgroundPollingDisabled", !_config.EnableBackgroundUpdating) + .Add("evaluationReasonsRequested", _config.EvaluationReasons) + .Build(); + + // Allow each pluggable component to describe its own relevant properties. + yield return GetComponentDescription(_config.DataSource ?? Components.StreamingDataSource()); + yield return GetComponentDescription(_config.Events ?? Components.SendEvents()); + yield return GetComponentDescription(_config.HttpConfigurationBuilder ?? Components.HttpConfiguration()); + } + + private LdValue GetComponentDescription(object component) => + component is IDiagnosticDescription dd ? + dd.DescribeConfiguration(_context) : LdValue.Null; + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Events/DefaultEventProcessorWrapper.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Events/DefaultEventProcessorWrapper.cs new file mode 100644 index 00000000..22b75602 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Events/DefaultEventProcessorWrapper.cs @@ -0,0 +1,66 @@ +using System; +using System.Threading.Tasks; +using LaunchDarkly.Sdk.Client.Subsystems; +using LaunchDarkly.Sdk.Internal.Events; + +namespace LaunchDarkly.Sdk.Client.Internal.Events +{ + internal sealed class DefaultEventProcessorWrapper : IEventProcessor + { + private EventProcessor _eventProcessor; + + internal DefaultEventProcessorWrapper(EventProcessor eventProcessor) + { + _eventProcessor = eventProcessor; + } + + public void RecordEvaluationEvent(in EventProcessorTypes.EvaluationEvent e) + { + _eventProcessor.RecordEvaluationEvent(new EventTypes.EvaluationEvent + { + Timestamp = e.Timestamp, + Context = e.Context, + FlagKey = e.FlagKey, + FlagVersion = e.FlagVersion, + Variation = e.Variation, + Value = e.Value, + Default = e.Default, + Reason = e.Reason, + TrackEvents = e.TrackEvents, + DebugEventsUntilDate = e.DebugEventsUntilDate + }); + } + + public void RecordIdentifyEvent(in EventProcessorTypes.IdentifyEvent e) + { + _eventProcessor.RecordIdentifyEvent(new EventTypes.IdentifyEvent + { + Timestamp = e.Timestamp, + Context = e.Context + }); + } + + public void RecordCustomEvent(in EventProcessorTypes.CustomEvent e) + { + _eventProcessor.RecordCustomEvent(new EventTypes.CustomEvent + { + Timestamp = e.Timestamp, + Context = e.Context, + EventKey = e.EventKey, + Data = e.Data, + MetricValue = e.MetricValue + }); + } + + public void SetOffline(bool offline) => + _eventProcessor.SetOffline(offline); + + public void Flush() => _eventProcessor.Flush(); + + public bool FlushAndWait(TimeSpan timeout) => _eventProcessor.FlushAndWait(timeout); + + public Task FlushAndWaitAsync(TimeSpan timeout) => _eventProcessor.FlushAndWaitAsync(timeout); + + public void Dispose() => _eventProcessor.Dispose(); + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Events/DiagnosticDisablerImpl.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Events/DiagnosticDisablerImpl.cs new file mode 100644 index 00000000..77fa82a1 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Events/DiagnosticDisablerImpl.cs @@ -0,0 +1,36 @@ +using System; +using LaunchDarkly.Sdk.Internal.Concurrent; +using LaunchDarkly.Sdk.Internal.Events; + +namespace LaunchDarkly.Sdk.Client.Internal.Events +{ + // The IDiagnosticDisabler interface is defined by the event processor implementation + // in LaunchDarkly.InternalSdk as a hook to give the event processor a way to know + // whether periodic diagnostic events should be temporarily disabled. The event + // processor will register itself as an event handler on the DisabledChanged event. + // In the client-side SDK, we disable periodic diagnostic events whenever the app is + // in the background. (The server-side SDK doesn't have any equivalent behavior so + // we do not implement IDiagnosticDisabler there.) + + internal sealed class DiagnosticDisablerImpl : IDiagnosticDisabler + { + private readonly AtomicBoolean _disabled = new AtomicBoolean(false); + + public bool Disabled => _disabled.Get(); + + public event EventHandler DisabledChanged; + + internal void SetDisabled(bool disabled) + { + if (_disabled.GetAndSet(disabled) != disabled) + { + DisabledChanged?.Invoke(null, new DisabledChangedArgs(disabled)); + // We are not using TaskExecutor to dispatch this event because the + // event handler, if any, was not provided by the application - it is + // always our own internal logic in the LaunchDarkly.InternalSdk + // events code, which doesn't do anything time-consuming. So we are + // not calling out to unknown code and it's safe to be synchronous. + } + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Events/EventFactory.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Events/EventFactory.cs new file mode 100644 index 00000000..933bccd4 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Events/EventFactory.cs @@ -0,0 +1,87 @@ + +using static LaunchDarkly.Sdk.Client.DataModel; +using static LaunchDarkly.Sdk.Client.Subsystems.EventProcessorTypes; + +namespace LaunchDarkly.Sdk.Client.Internal.Events +{ + internal sealed class EventFactory + { + private readonly bool _withReasons; + + internal static readonly EventFactory Default = new EventFactory(false); + internal static readonly EventFactory DefaultWithReasons = new EventFactory(true); + + internal EventFactory(bool withReasons) + { + _withReasons = withReasons; + } + + internal EvaluationEvent NewEvaluationEvent( + string flagKey, + FeatureFlag flag, + Context context, + EvaluationDetail result, + LdValue defaultValue + ) + { + // EventFactory passes the reason parameter to this method because the server-side SDK needs to + // look at the reason; but in this client-side SDK, we don't look at that parameter, because + // LD has already done the relevant calculation for us and sent us the result in trackReason. + var isExperiment = flag.TrackReason; + + return new EvaluationEvent + { + Timestamp = UnixMillisecondTime.Now, + Context = context, + FlagKey = flagKey, + FlagVersion = flag.FlagVersion ?? flag.Version, + Variation = result.VariationIndex, + Value = result.Value, + Default = defaultValue, + Reason = (_withReasons || isExperiment) ? result.Reason : (EvaluationReason?)null, + TrackEvents = flag.TrackEvents || isExperiment, + DebugEventsUntilDate = flag.DebugEventsUntilDate + }; + } + + internal EvaluationEvent NewDefaultValueEvaluationEvent( + string flagKey, + FeatureFlag flag, + Context context, + LdValue defaultValue, + EvaluationErrorKind errorKind + ) + { + return new EvaluationEvent + { + Timestamp = UnixMillisecondTime.Now, + Context = context, + FlagKey = flagKey, + FlagVersion = flag.FlagVersion ?? flag.Version, + Value = defaultValue, + Default = defaultValue, + Reason = _withReasons ? EvaluationReason.ErrorReason(errorKind) : (EvaluationReason?)null, + TrackEvents = flag.TrackEvents, + DebugEventsUntilDate = flag.DebugEventsUntilDate + }; + } + + internal EvaluationEvent NewUnknownFlagEvaluationEvent( + string flagKey, + Context context, + LdValue defaultValue, + EvaluationErrorKind errorKind + ) + { + return new EvaluationEvent + { + Timestamp = UnixMillisecondTime.Now, + Context = context, + FlagKey = flagKey, + Value = defaultValue, + Default = defaultValue, + Reason = _withReasons ? EvaluationReason.ErrorReason(errorKind) : (EvaluationReason?)null, + }; + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Factory.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Factory.cs new file mode 100644 index 00000000..3d82e1c7 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Factory.cs @@ -0,0 +1,14 @@ +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Client.Internal.DataSources; +using LaunchDarkly.Sdk.Client.Internal.Interfaces; + +namespace LaunchDarkly.Sdk.Client.Internal +{ + internal static class Factory + { + internal static IConnectivityStateManager CreateConnectivityStateManager(Configuration configuration) + { + return configuration.ConnectivityStateManager ?? new DefaultConnectivityStateManager(); + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/FlagTrackerImpl.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/FlagTrackerImpl.cs new file mode 100644 index 00000000..a9807795 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/FlagTrackerImpl.cs @@ -0,0 +1,24 @@ +using System; +using LaunchDarkly.Sdk.Client.Interfaces; +using LaunchDarkly.Sdk.Client.Internal.DataSources; + +namespace LaunchDarkly.Sdk.Client.Internal +{ + internal sealed class FlagTrackerImpl : IFlagTracker + { + private readonly DataSourceUpdateSinkImpl _updateSink; + + public event EventHandler FlagValueChanged + { + add =>_updateSink.FlagValueChanged += value; + remove => _updateSink.FlagValueChanged -= value; + } + + internal FlagTrackerImpl( + DataSourceUpdateSinkImpl updateSink + ) + { + _updateSink = updateSink; + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Interfaces/IBackgroundModeManager.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Interfaces/IBackgroundModeManager.cs new file mode 100644 index 00000000..771f2e19 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Interfaces/IBackgroundModeManager.cs @@ -0,0 +1,10 @@ +using System; +using LaunchDarkly.Sdk.Client.PlatformSpecific; + +namespace LaunchDarkly.Sdk.Client.Internal.Interfaces +{ + internal interface IBackgroundModeManager + { + event EventHandler BackgroundModeChanged; + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Interfaces/IConnectivityStateManager.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Interfaces/IConnectivityStateManager.cs new file mode 100644 index 00000000..58a0a884 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/Interfaces/IConnectivityStateManager.cs @@ -0,0 +1,10 @@ +using System; + +namespace LaunchDarkly.Sdk.Client.Internal.Interfaces +{ + internal interface IConnectivityStateManager + { + bool IsConnected { get; set; } + Action ConnectionChanged { get; set; } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/JsonUtils.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/JsonUtils.cs new file mode 100644 index 00000000..d9388c10 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/JsonUtils.cs @@ -0,0 +1,24 @@ +using System; +using System.IO; +using System.Text; +using System.Text.Json; + +namespace LaunchDarkly.Sdk.Internal +{ + internal static class JsonUtils + { + /// + /// Shortcut for creating a Utf8JsonWriter, doing some action with it, and getting the output as a string. + /// + /// action to create some output + /// the output + public static string WriteJsonAsString(Action serializeAction) + { + var stream = new MemoryStream(); + var w = new Utf8JsonWriter(stream); + serializeAction(w); + w.Flush(); + return Encoding.UTF8.GetString(stream.ToArray()); + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/LockUtils.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/LockUtils.cs new file mode 100644 index 00000000..7c55ce93 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/LockUtils.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading; + +namespace LaunchDarkly.Sdk.Client.Internal +{ + internal static class LockUtils + { + public static T WithReadLock(ReaderWriterLockSlim rwLock, Func fn) + { + rwLock.EnterReadLock(); + try + { + return fn(); + } + finally + { + rwLock.ExitReadLock(); + } + } + + public static T WithWriteLock(ReaderWriterLockSlim rwLock, Func fn) + { + rwLock.EnterWriteLock(); + try + { + return fn(); + } + finally + { + rwLock.ExitWriteLock(); + } + } + + public static void WithWriteLock(ReaderWriterLockSlim rwLock, Action a) + { + rwLock.EnterWriteLock(); + try + { + a(); + } + finally + { + rwLock.ExitWriteLock(); + } + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/LogNames.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/LogNames.cs new file mode 100644 index 00000000..fabdf07d --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/LogNames.cs @@ -0,0 +1,14 @@ + +namespace LaunchDarkly.Sdk.Client.Internal +{ + internal static class LogNames + { + internal const string Base = "LaunchDarkly.Sdk"; + + internal const string DataSourceSubLog = "DataSource"; + + internal const string DataStoreSubLog = "DataStore"; + + internal const string EventsSubLog = "Events"; + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/SdkPackage.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/SdkPackage.cs new file mode 100644 index 00000000..f0adcd98 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/SdkPackage.cs @@ -0,0 +1,58 @@ +using LaunchDarkly.Sdk.Internal; + +namespace LaunchDarkly.Sdk.Client.Internal +{ + /// + /// Defines common information about the SDK itself for usage + /// in various components. + /// + internal static class SdkPackage + { + /// + /// The canonical name of this SDK, following the convention of (technology)-(server|client)-sdk. + /// + internal const string Name = "dotnet-client-sdk"; + + /// + /// The prefix for the User-Agent header, omitting the version string. This may be different than the Name + /// due to historical reasons. + /// + private const string UserAgentPrefix = "DotnetClientSide"; + + /// + /// Version of the SDK. + /// + internal static string Version => AssemblyVersions.GetAssemblyVersionStringForType(typeof(LdClient)); + + /// + /// User-Agent suitable for usage in HTTP requests. + /// + internal static string UserAgent => $"{UserAgentPrefix}/{Version}"; + + /// + /// The target framework selected at build time. + /// + /// + /// This is the _target framework_ that was selected at build time based + /// on the application's compatibility requirements; it doesn't tell + /// anything about the actual OS version. + /// + internal static string DotNetTargetFramework => + // We'll need to update this whenever we add or remove supported target frameworks in the .csproj file. + // Order of these conditonals matters. Specific frameworks come before net7.0 intentionally. +#if ANDROID + "net7.0-android"; +#elif IOS + "net7.0-ios"; +#elif MACCATALYST + "net7.0-maccatalyst"; +#elif WINDOWS + "net7.0-windows"; +#elif NET7_0 + "net7.0"; +#else + "unknown"; +#endif + + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/StandardEndpoints.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/StandardEndpoints.cs new file mode 100644 index 00000000..cce13fab --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Internal/StandardEndpoints.cs @@ -0,0 +1,51 @@ +using System; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Client.Interfaces; + +namespace LaunchDarkly.Sdk.Client.Internal +{ + internal static class StandardEndpoints + { + internal static readonly ServiceEndpoints BaseUris = new ServiceEndpoints( + new Uri("https://clientstream.launchdarkly.com"), + new Uri("https://clientsdk.launchdarkly.com"), + new Uri("https://mobile.launchdarkly.com") + ); + + internal static string StreamingGetRequestPath(string contextDataBase64) => + "/meval/" + contextDataBase64; + internal const string StreamingReportRequestPath = "/meval"; + + internal static string PollingRequestGetRequestPath(string contextDataBase64) => + "msdk/evalx/contexts/" + contextDataBase64; + internal const string PollingRequestReportRequestPath = "msdk/evalx/context"; + + internal const string AnalyticsEventsPostRequestPath = "mobile/events/bulk"; + + internal const string DiagnosticEventsPostRequestPath = "mobile/events/diagnostic"; + + internal static Uri SelectBaseUri( + ServiceEndpoints configuredEndpoints, + Func uriGetter, + string description, + Logger errorLogger + ) + { + var configuredUri = uriGetter(configuredEndpoints); + if (configuredUri != null) + { + return configuredUri; + } + errorLogger.Error( + "You have set custom ServiceEndpoints without specifying the {0} base URI; connections may not work properly", + description); + return uriGetter(BaseUris); + } + + internal static bool IsCustomUri( + ServiceEndpoints configuredEndpoints, + Func uriGetter + ) => + !uriGetter(BaseUris).Equals(uriGetter(configuredEndpoints)); + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/LaunchDarkly.ClientSdk.csproj b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/LaunchDarkly.ClientSdk.csproj new file mode 100644 index 00000000..f7f9158f --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/LaunchDarkly.ClientSdk.csproj @@ -0,0 +1,127 @@ + + + + + 5.2.1 + + + + netstandard2.0;net7.0;net7.0-android;net7.0-ios;net7.0-maccatalyst;net7.0-windows + $(BUILDFRAMEWORKS) + true + Library + LaunchDarkly.ClientSdk + LaunchDarkly.ClientSdk + false + bin\$(Configuration)\$(Framework) + 8.0 + False + True + true + bin\$(Configuration)\$(TargetFramework)\LaunchDarkly.ClientSdk.xml + LaunchDarkly + Copyright 2020 LaunchDarkly + Apache-2.0 + https://github.com/launchdarkly/dotnet-client-sdk + https://github.com/launchdarkly/dotnet-client-sdk + master + true + snupkg + LaunchDarkly.Sdk.Client + false + + + 1570,1571,1572,1573,1574,1580,1581,1584,1591,1710,1711,1712 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Code + + + + + + + ../../LaunchDarkly.ClientSdk.snk + true + + + + + + + + + + diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/LdClient.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/LdClient.cs new file mode 100644 index 00000000..5135657d --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/LdClient.cs @@ -0,0 +1,958 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Client.Interfaces; +using LaunchDarkly.Sdk.Client.Internal; +using LaunchDarkly.Sdk.Client.Internal.DataSources; +using LaunchDarkly.Sdk.Client.Internal.DataStores; +using LaunchDarkly.Sdk.Client.Internal.Events; +using LaunchDarkly.Sdk.Client.Internal.Interfaces; +using LaunchDarkly.Sdk.Client.PlatformSpecific; +using LaunchDarkly.Sdk.Client.Subsystems; +using LaunchDarkly.Sdk.Internal; +using LaunchDarkly.Sdk.Internal.Concurrent; + +namespace LaunchDarkly.Sdk.Client +{ + /// + /// A client for the LaunchDarkly API. Client instances are thread-safe. Your application should instantiate + /// a single LdClient for the lifetime of their application. + /// + /// + /// + /// Like all client-side LaunchDarkly SDKs, the LdClient always has a single current . + /// You specify this context at initialization time, and you can change it later with + /// or . All subsequent calls to evaluation methods like + /// refer to the flag values for the current context. + /// + /// + /// Normally, the SDK uses the exact context that you have specified in the . However, + /// you can also tell the SDK to generate a randomized identifier and use this as the context's + /// ; see . + /// + /// + /// If you use more than one in your evaluation contexts, and you request a + /// randomized key as described above, a different key is generated for each kind. + /// + /// + public sealed class LdClient : ILdClient + { + static readonly EventFactory _eventFactoryDefault = EventFactory.Default; + static readonly EventFactory _eventFactoryWithReasons = EventFactory.DefaultWithReasons; + + static readonly object _createInstanceLock = new object(); + static volatile LdClient _instance; + + private readonly TimeSpan ExcessiveInitWaitTime = TimeSpan.FromSeconds(15); + + private const String ExcessiveInitWaitTimeWarning = + "LDClient.Init called with max wait time parameter of {0} seconds. We recommend a timeout of less than {1} seconds."; + + private const String DidNotInitializeTimelyWarning = "Client did not initialize within {0} milliseconds."; + + // Immutable client state + readonly Configuration _config; + readonly LdClientContext _clientContext; + readonly IDataSourceStatusProvider _dataSourceStatusProvider; + readonly IDataSourceUpdateSink _dataSourceUpdateSink; + readonly FlagDataManager _dataStore; + readonly ConnectionManager _connectionManager; + readonly IBackgroundModeManager _backgroundModeManager; + readonly IConnectivityStateManager _connectivityStateManager; + readonly IEventProcessor _eventProcessor; + readonly IFlagTracker _flagTracker; + readonly TaskExecutor _taskExecutor; + readonly AnonymousKeyContextDecorator _anonymousKeyContextDecorator; + private readonly AutoEnvContextDecorator _autoEnvContextDecorator; + + private readonly Logger _log; + + // Mutable client state (some state is also in the ConnectionManager) + readonly ReaderWriterLockSlim _stateLock = new ReaderWriterLockSlim(); + private Context _context; + + /// + /// The singleton instance used by your application throughout its lifetime. Once this exists, you cannot + /// create a new client instance unless you first call on this one. + /// + /// + /// Use the static factory methods or + /// to set this instance. + /// + public static LdClient Instance => _instance; + + /// + /// The current version string of the SDK. + /// + public static Version Version => AssemblyVersions.GetAssemblyVersionForType(typeof(LdClient)); + + /// + /// The instance used to set up the LdClient. + /// + public Configuration Config => _config; + + /// + /// The current evaluation context for all SDK operations. + /// + /// + /// This is initially the context specified for or + /// , but can be changed later with + /// or . + /// + public Context Context => LockUtils.WithReadLock(_stateLock, () => _context); + + /// + public bool Offline => _connectionManager.ForceOffline; + + /// + public bool Initialized => _connectionManager.Initialized; + + /// + public IDataSourceStatusProvider DataSourceStatusProvider => _dataSourceStatusProvider; + + /// + public IFlagTracker FlagTracker => _flagTracker; + + // private constructor prevents initialization of this class + // without using WithConfigAnduser(config, user) + LdClient() + { + } + + LdClient(Configuration configuration, Context initialContext, TimeSpan startWaitTime) + { + _config = configuration ?? throw new ArgumentNullException(nameof(configuration)); + var baseContext = new LdClientContext(_config, initialContext, this); + + var diagnosticStore = _config.DiagnosticOptOut + ? null + : new ClientDiagnosticStore(baseContext, _config, startWaitTime); + var diagnosticDisabler = _config.DiagnosticOptOut ? null : new DiagnosticDisablerImpl(); + _clientContext = baseContext.WithDiagnostics(diagnosticDisabler, diagnosticStore); + + _log = _clientContext.BaseLogger; + _taskExecutor = _clientContext.TaskExecutor; + + _log.Info("Starting LaunchDarkly Client {0} built with target framework {1}", Version, + SdkPackage.DotNetTargetFramework); + + var persistenceConfiguration = (_config.PersistenceConfigurationBuilder ?? Components.Persistence()) + .Build(_clientContext); + _dataStore = new FlagDataManager( + _config.MobileKey, + persistenceConfiguration, + _log.SubLogger(LogNames.DataStoreSubLog) + ); + + _anonymousKeyContextDecorator = + new AnonymousKeyContextDecorator(_dataStore.PersistentStore, _config.GenerateAnonymousKeys); + var decoratedContext = _anonymousKeyContextDecorator.DecorateContext(initialContext); + + if (_config.AutoEnvAttributes) + { + _autoEnvContextDecorator = new AutoEnvContextDecorator(_dataStore.PersistentStore, + _clientContext.EnvironmentReporter, _log); + decoratedContext = _autoEnvContextDecorator.DecorateContext(decoratedContext); + } + + _context = decoratedContext; + + // If we had cached data for the new context, set the current in-memory flag data state to use + // that data, so that any Variation calls made before Identify has completed will use the + // last known values. + var cachedData = _dataStore.GetCachedData(_context); + if (cachedData != null) + { + _log.Debug("Cached flag data is available for this context"); + _dataStore.Init(_context, cachedData.Value, + false); // false means "don't rewrite the flags to persistent storage" + } + + var dataSourceUpdateSink = new DataSourceUpdateSinkImpl( + _dataStore, + _config.Offline, + _taskExecutor, + _log.SubLogger(LogNames.DataSourceSubLog) + ); + _dataSourceUpdateSink = dataSourceUpdateSink; + + _dataSourceStatusProvider = new DataSourceStatusProviderImpl(dataSourceUpdateSink); + _flagTracker = new FlagTrackerImpl(dataSourceUpdateSink); + + var dataSourceFactory = _config.DataSource ?? Components.StreamingDataSource(); + + _connectivityStateManager = Factory.CreateConnectivityStateManager(_config); + var isConnected = _connectivityStateManager.IsConnected; + + diagnosticDisabler?.SetDisabled(!isConnected || _config.Offline); + + _eventProcessor = (_config.Events ?? Components.SendEvents()) + .Build(_clientContext); + _eventProcessor.SetOffline(_config.Offline || !isConnected); + + _connectionManager = new ConnectionManager( + _clientContext, + dataSourceFactory, + _dataSourceUpdateSink, + _eventProcessor, + diagnosticDisabler, + _config.EnableBackgroundUpdating, + _context, + _log + ); + _connectionManager.SetForceOffline(_config.Offline); + _connectionManager.SetNetworkEnabled(isConnected); + if (_config.Offline) + { + _log.Info("Starting LaunchDarkly client in offline mode"); + } + + _connectivityStateManager.ConnectionChanged += networkAvailable => + { + _log.Debug("Setting online to {0} due to a connectivity change event", networkAvailable); + _ = _connectionManager.SetNetworkEnabled(networkAvailable); // do not await the result + }; + + // Send an initial identify event, but only if we weren't explicitly set to be offline + + if (!_config.Offline) + { + _eventProcessor.RecordIdentifyEvent(new EventProcessorTypes.IdentifyEvent + { + Timestamp = UnixMillisecondTime.Now, + Context = _context + }); + } + + _backgroundModeManager = _config.BackgroundModeManager ?? new DefaultBackgroundModeManager(); + _backgroundModeManager.BackgroundModeChanged += OnBackgroundModeChanged; + } + + void Start(TimeSpan maxWaitTime) + { + if (maxWaitTime >= ExcessiveInitWaitTime) + { + _log.Warn(ExcessiveInitWaitTimeWarning, maxWaitTime, ExcessiveInitWaitTime); + } + + var success = AsyncUtils.WaitSafely(() => _connectionManager.Start(), maxWaitTime); + if (!success) + { + _log.Warn(DidNotInitializeTimelyWarning, maxWaitTime.TotalMilliseconds); + } + } + + /// + /// Starts the client and waits up to the wait time to initialize feature flags. If offline, + /// returns immediately. + /// + /// the maximum length of time to wait for the client to initialize + async Task StartAsync(TimeSpan maxWaitTime) + { + if (maxWaitTime >= ExcessiveInitWaitTime) + { + _log.Warn( + ExcessiveInitWaitTimeWarning, + maxWaitTime, ExcessiveInitWaitTime); + } + + var startTask = _connectionManager.Start(); + var completedTask = await Task.WhenAny(startTask, Task.Delay(maxWaitTime)); + if (completedTask != startTask) + { + _log.Warn(DidNotInitializeTimelyWarning, maxWaitTime.TotalMilliseconds); + } + } + + /// + /// Starts the client and waits to initialize feature flags. If offline, returns immediately. + /// + async Task StartAsync() + { + await _connectionManager.Start(); + } + + /// + /// Creates a new singleton instance and attempts to initialize feature flags. + /// + /// + /// + /// The constructor will return the instance once the first response from + /// the LaunchDarkly service is returned, or immediately if offline, or when the the specified + /// wait time elapses. If the max wait time elapses, the returned instance will have + /// an property of , but the instance will continue + /// trying to get fresh feature flags. + /// + /// + /// To specify additional configuration options rather than just the mobile key, use + /// . + /// + /// + /// If you would rather an asynchronous version of this method, use + /// . + /// + /// + /// You must use one of these static factory methods to instantiate the single instance of LdClient + /// for the lifetime of your application. + /// + /// + /// the mobile key given to you by LaunchDarkly + /// Enable / disable Auto Environment Attributes functionality. When enabled, + /// the SDK will automatically provide data about the environment where the application is running. + /// This data makes it simpler to target your mobile customers based on application name or version, or on + /// device characteristics including manufacturer, model, operating system, locale, and so on. We recommend + /// enabling this when you configure the SDK. See + /// our documentation for + /// more details. + /// the initial evaluation context; see for more + /// about setting the context and optionally requesting a unique key for it + /// the maximum length of time to wait for the client to initialize + /// the singleton instance + /// + /// + /// + public static LdClient Init(string mobileKey, ConfigurationBuilder.AutoEnvAttributes autoEnvAttributes, + Context initialContext, TimeSpan maxWaitTime) + { + var config = Configuration.Default(mobileKey, autoEnvAttributes); + + return Init(config, initialContext, maxWaitTime); + } + + /// + /// Creates a new singleton instance and attempts to initialize feature flags. + /// + /// + /// This is equivalent to + /// , but using the + /// type instead of . + /// + /// the mobile key given to you by LaunchDarkly + /// Enable / disable Auto Environment Attributes functionality. When enabled, + /// the SDK will automatically provide data about the environment where the application is running. + /// This data makes it simpler to target your mobile customers based on application name or version, or on + /// device characteristics including manufacturer, model, operating system, locale, and so on. We recommend + /// enabling this when you configure the SDK. See + /// our documentation for + /// more details. + /// the initial user attributes; see for more + /// about setting the context and optionally requesting a unique key for it + /// the maximum length of time to wait for the client to initialize + /// the singleton instance + /// + /// + /// + [Obsolete("User has been superseded by Context, use Init(string, ConfigurationBuilder.AutoEnvAttributes, Context, TimeSpan) instead.")] + public static LdClient Init(string mobileKey, ConfigurationBuilder.AutoEnvAttributes autoEnvAttributes, + User initialUser, TimeSpan maxWaitTime) => + Init(mobileKey, autoEnvAttributes, Context.FromUser(initialUser), maxWaitTime); + + /// + /// Creates a new singleton instance and attempts to initialize feature flags + /// asynchronously. + /// + /// + /// + /// The returned task will yield the instance once the first response from + /// the LaunchDarkly service is returned or immediately if it is offline. + /// + /// + /// To specify additional configuration options rather than just the mobile key, you can use + /// or . + /// + /// + /// If you would rather a synchronous version of this method, use + /// . + /// + /// + /// You must use one of these static factory methods to instantiate the single instance of LdClient + /// for the lifetime of your application. + /// + /// + /// the mobile key given to you by LaunchDarkly + /// Enable / disable Auto Environment Attributes functionality. When enabled, + /// the SDK will automatically provide data about the environment where the application is running. + /// This data makes it simpler to target your mobile customers based on application name or version, or on + /// device characteristics including manufacturer, model, operating system, locale, and so on. We recommend + /// enabling this when you configure the SDK. See + /// our documentation for + /// more details. + /// the initial evaluation context; see for more + /// about setting the context and optionally requesting a unique key for it + /// a Task that resolves to the singleton LdClient instance + [Obsolete("Initializing the LDClient without a timeout is no longer permitted to help prevent " + + "consumers from blocking their application execution by mistake when connectivity is poor. Please " + + "use InitAsync(string, ConfigurationBuilder.AutoEnvAttributes, Context, TimeSpan) and specify a max wait time.")] + public static async Task InitAsync(string mobileKey, + ConfigurationBuilder.AutoEnvAttributes autoEnvAttributes, Context initialContext) + { + var config = Configuration.Default(mobileKey, autoEnvAttributes); + return await InitAsync(config, initialContext); + } + + /// + /// Creates a new singleton instance and attempts to initialize feature flags + /// asynchronously. + /// + /// + /// + /// The returned task will yield the instance once the first response from + /// the LaunchDarkly service is returned, or immediately if offline, or when the the specified + /// wait time elapses. If the max wait time elapses, the returned instance will have + /// an property of , and the instance will continue + /// trying to get fresh feature flags. + /// + /// + /// If you would rather this happen synchronously, use + /// . To + /// specify additional configuration options rather than just the mobile key, you can use + /// or . + /// + /// + /// You must use one of these static factory methods to instantiate the single instance of LdClient + /// for the lifetime of your application. + /// + /// + /// the mobile key given to you by LaunchDarkly + /// Enable / disable Auto Environment Attributes functionality. When enabled, + /// the SDK will automatically provide data about the environment where the application is running. + /// This data makes it simpler to target your mobile customers based on application name or version, or on + /// device characteristics including manufacturer, model, operating system, locale, and so on. We recommend + /// enabling this when you configure the SDK. See + /// our documentation for + /// more details. + /// the initial evaluation context; see for more + /// about setting the context and optionally requesting a unique key for it + /// the maximum length of time to wait for the client to initialize + /// a Task that resolves to the singleton LdClient instance + public static async Task InitAsync(string mobileKey, + ConfigurationBuilder.AutoEnvAttributes autoEnvAttributes, Context initialContext, TimeSpan maxWaitTime) + { + var config = Configuration.Default(mobileKey, autoEnvAttributes); + return await InitAsync(config, initialContext, maxWaitTime); + } + + /// + /// Creates a new singleton instance and attempts to initialize feature flags + /// asynchronously. + /// + /// + /// This is equivalent to + /// , but using the + /// type instead of . + /// + /// the mobile key given to you by LaunchDarkly + /// Enable / disable Auto Environment Attributes functionality. When enabled, + /// the SDK will automatically provide data about the environment where the application is running. + /// This data makes it simpler to target your mobile customers based on application name or version, or on + /// device characteristics including manufacturer, model, operating system, locale, and so on. We recommend + /// enabling this when you configure the SDK. See + /// our documentation for + /// more details. + /// the initial user attributes + /// a Task that resolves to the singleton LdClient instance + [Obsolete("User has been superseded by Context, use Init(string, ConfigurationBuilder.AutoEnvAttributes, Context) instead.")] + public static Task InitAsync(string mobileKey, + ConfigurationBuilder.AutoEnvAttributes autoEnvAttributes, User initialUser) => + InitAsync(mobileKey, autoEnvAttributes, Context.FromUser(initialUser)); + + /// + /// Creates and returns a new LdClient singleton instance, then starts the workflow for + /// fetching Feature Flags. + /// + /// + /// + /// The constructor will return the instance once the first response from + /// the LaunchDarkly service is returned, or immediately if offline, or when the the specified + /// wait time elapses. If the max wait time elapses, the returned instance will have + /// an property of , but the instance will continue + /// trying to get fresh feature flags. + /// + /// + /// If you do not need to specify configuration options other than the mobile key, you can use + /// . + /// + /// + /// If you would rather an asynchronous version of this method, use + /// . + /// + /// + /// You must use one of these static factory methods to instantiate the single instance of LdClient + /// for the lifetime of your application. + /// + /// + /// the client configuration + /// the initial evaluation context; see for more + /// about setting the context and optionally requesting a unique key for it + /// the maximum length of time to wait for the client to initialize; + /// if this time elapses, the method will not throw an exception but will return the client in + /// an uninitialized state + /// the singleton LdClient instance + /// + /// + /// + public static LdClient Init(Configuration config, Context initialContext, TimeSpan maxWaitTime) + { + if (maxWaitTime.Ticks < 0 && maxWaitTime != Timeout.InfiniteTimeSpan) + { + throw new ArgumentOutOfRangeException(nameof(maxWaitTime)); + } + + var c = CreateInstance(config, initialContext, maxWaitTime); + c.Start(maxWaitTime); + return c; + } + + /// + /// Creates and returns a new LdClient singleton instance, then starts the workflow for + /// fetching Feature Flags. + /// + /// + /// This is equivalent to , but using the + /// type instead of . + /// + /// the client configuration + /// the initial user attributes + /// the maximum length of time to wait for the client to initialize; + /// if this time elapses, the method will not throw an exception but will return the client in + /// an uninitialized state + /// the singleton LdClient instance + /// + /// + /// + /// + [Obsolete("User has been superseded by Context, use Init(Configuration, Context, TimeSpan) instead.")] + public static LdClient Init(Configuration config, User initialUser, TimeSpan maxWaitTime) => + Init(config, Context.FromUser(initialUser), maxWaitTime); + + /// + /// Creates a new singleton instance and attempts to initialize feature flags + /// asynchronously. + /// + /// + /// + /// The returned task will yield the instance once the first response from + /// the LaunchDarkly service is returned (or immediately if it is in offline mode). + /// + /// + /// If you would rather this happen synchronously, use . + /// If you do not need to specify configuration options other than the mobile key, you can use + /// . + /// + /// + /// You must use one of these static factory methods to instantiate the single instance of LdClient + /// for the lifetime of your application. + /// + /// + /// the client configuration + /// the initial evaluation context; see for more + /// about setting the context and optionally requesting a unique key for it + /// a Task that resolves to the singleton LdClient instance + /// + /// + /// + [Obsolete("Initializing the LDClient without a timeout is no longer permitted to help prevent " + + "consumers from blocking their application execution by mistake when connectivity is poor. Please " + + "use InitAsync(Configuration, Context, TimeSpan) and specify a max wait time.")] + public static async Task InitAsync(Configuration config, Context initialContext) + { + var c = CreateInstance(config, initialContext, TimeSpan.Zero); + await c.StartAsync(); + return c; + } + + /// + /// Creates a new singleton instance and attempts to initialize feature flags + /// asynchronously. + /// + /// + /// + /// The returned task will yield the instance once the first response from + /// the LaunchDarkly service is returned, or immediately if offline, or when the the specified + /// wait time elapses. If the max wait time elapses, the returned instance will have + /// an property of , and the instance will continue + /// trying to get fresh feature flags. + /// + /// + /// If you would rather this happen synchronously, use + /// + /// + /// You must use one of these static factory methods to instantiate the single instance of LdClient + /// for the lifetime of your application. + /// + /// + /// the client configuration + /// the initial evaluation context; see for more + /// about setting the context and optionally requesting a unique key for it + /// the maximum length of time to wait for the client to initialize + /// a Task that resolves to the singleton LdClient instance + public static async Task InitAsync(Configuration config, Context initialContext, TimeSpan maxWaitTime) + { + var c = CreateInstance(config, initialContext, TimeSpan.Zero); + await c.StartAsync(maxWaitTime); + return c; + } + + /// + /// Creates a new singleton instance and attempts to initialize feature flags + /// asynchronously. + /// + /// + /// This is equivalent to , but using the + /// type instead of . + /// + /// the client configuration + /// the initial user attributes + /// a Task that resolves to the singleton LdClient instance + /// + /// + /// + [Obsolete("User has been superseded by Context, use InitAsync(Configuration, Context) instead.")] + public static Task InitAsync(Configuration config, User initialUser) => + InitAsync(config, Context.FromUser(initialUser)); + + static LdClient CreateInstance(Configuration configuration, Context initialContext, TimeSpan maxWaitTime) + { + lock (_createInstanceLock) + { + if (_instance != null) + { + throw new Exception("LdClient instance already exists."); + } + + var c = new LdClient(configuration, initialContext, maxWaitTime); + _instance = c; + return c; + } + } + + /// + public bool SetOffline(bool value, TimeSpan maxWaitTime) + { + return AsyncUtils.WaitSafely(() => SetOfflineAsync(value), maxWaitTime); + } + + /// + public async Task SetOfflineAsync(bool value) + { + _eventProcessor.SetOffline(value || !_connectionManager.NetworkEnabled); + await _connectionManager.SetForceOffline(value); + } + + /// + public bool BoolVariation(string key, bool defaultValue = false) + { + return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.Bool, true, + _eventFactoryDefault).Value; + } + + /// + public EvaluationDetail BoolVariationDetail(string key, bool defaultValue = false) + { + return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.Bool, true, + _eventFactoryWithReasons); + } + + /// + public string StringVariation(string key, string defaultValue) + { + return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.String, true, + _eventFactoryDefault).Value; + } + + /// + public EvaluationDetail StringVariationDetail(string key, string defaultValue) + { + return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.String, true, + _eventFactoryWithReasons); + } + + /// + public float FloatVariation(string key, float defaultValue = 0) + { + return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.Float, true, + _eventFactoryDefault).Value; + } + + /// + public EvaluationDetail FloatVariationDetail(string key, float defaultValue = 0) + { + return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.Float, true, + _eventFactoryWithReasons); + } + + /// + public double DoubleVariation(string key, double defaultValue = 0) + { + return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.Double, true, + _eventFactoryDefault).Value; + } + + /// + public EvaluationDetail DoubleVariationDetail(string key, double defaultValue = 0) + { + return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.Double, true, + _eventFactoryWithReasons); + } + + /// + public int IntVariation(string key, int defaultValue = 0) + { + return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.Int, true, _eventFactoryDefault) + .Value; + } + + /// + public EvaluationDetail IntVariationDetail(string key, int defaultValue = 0) + { + return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.Int, true, + _eventFactoryWithReasons); + } + + /// + public LdValue JsonVariation(string key, LdValue defaultValue) + { + return VariationInternal(key, defaultValue, LdValue.Convert.Json, false, _eventFactoryDefault).Value; + } + + /// + public EvaluationDetail JsonVariationDetail(string key, LdValue defaultValue) + { + return VariationInternal(key, defaultValue, LdValue.Convert.Json, false, _eventFactoryWithReasons); + } + + EvaluationDetail VariationInternal(string featureKey, LdValue defaultJson, LdValue.Converter converter, + bool checkType, EventFactory eventFactory) + { + T defaultValue = converter.ToType(defaultJson); + + EvaluationDetail errorResult(EvaluationErrorKind kind) => + new EvaluationDetail(defaultValue, null, EvaluationReason.ErrorReason(kind)); + + var flag = _dataStore.Get(featureKey)?.Item; + if (flag == null) + { + if (!Initialized) + { + _log.Warn("LaunchDarkly client has not yet been initialized. Returning default value"); + SendEvaluationEventIfOnline(eventFactory.NewUnknownFlagEvaluationEvent(featureKey, Context, + defaultJson, + EvaluationErrorKind.ClientNotReady)); + return errorResult(EvaluationErrorKind.ClientNotReady); + } + else + { + _log.Info("Unknown feature flag {0}; returning default value", featureKey); + SendEvaluationEventIfOnline(eventFactory.NewUnknownFlagEvaluationEvent(featureKey, Context, + defaultJson, + EvaluationErrorKind.FlagNotFound)); + return errorResult(EvaluationErrorKind.FlagNotFound); + } + } + else + { + if (!Initialized) + { + _log.Warn("LaunchDarkly client has not yet been initialized. Returning cached value"); + } + } + + EvaluationDetail result; + LdValue valueJson; + if (flag.Value.IsNull) + { + valueJson = defaultJson; + result = new EvaluationDetail(defaultValue, flag.Variation, + flag.Reason ?? EvaluationReason.OffReason); + } + else + { + if (checkType && !defaultJson.IsNull && flag.Value.Type != defaultJson.Type) + { + valueJson = defaultJson; + result = new EvaluationDetail(defaultValue, null, + EvaluationReason.ErrorReason(EvaluationErrorKind.WrongType)); + } + else + { + valueJson = flag.Value; + result = new EvaluationDetail(converter.ToType(flag.Value), flag.Variation, + flag.Reason ?? EvaluationReason.OffReason); + } + } + + var featureEvent = eventFactory.NewEvaluationEvent(featureKey, flag, Context, + new EvaluationDetail(valueJson, flag.Variation, flag.Reason ?? EvaluationReason.OffReason), + defaultJson); + SendEvaluationEventIfOnline(featureEvent); + return result; + } + + private void SendEvaluationEventIfOnline(EventProcessorTypes.EvaluationEvent e) + { + EventProcessorIfEnabled().RecordEvaluationEvent(e); + } + + /// + public IDictionary AllFlags() + { + var data = _dataStore.GetAll(); + if (data is null) + { + return ImmutableDictionary.Empty; + } + + return data.Value.Items.Where(entry => entry.Value.Item != null) + .ToDictionary(p => p.Key, p => p.Value.Item.Value); + } + + /// + public void Track(string eventName, LdValue data, double metricValue) + { + EventProcessorIfEnabled().RecordCustomEvent(new EventProcessorTypes.CustomEvent + { + Timestamp = UnixMillisecondTime.Now, + EventKey = eventName, + Context = Context, + Data = data, + MetricValue = metricValue + }); + } + + /// + public void Track(string eventName, LdValue data) + { + EventProcessorIfEnabled().RecordCustomEvent(new EventProcessorTypes.CustomEvent + { + Timestamp = UnixMillisecondTime.Now, + EventKey = eventName, + Context = Context, + Data = data + }); + } + + /// + public void Track(string eventName) => + Track(eventName, LdValue.Null); + + /// + public void Flush() => + _eventProcessor.Flush(); // eventProcessor will ignore this if it is offline + + /// + public bool FlushAndWait(TimeSpan timeout) => + _eventProcessor.FlushAndWait(timeout); + + /// + public Task FlushAndWaitAsync(TimeSpan timeout) => + _eventProcessor.FlushAndWaitAsync(timeout); + + /// + public bool Identify(Context context, TimeSpan maxWaitTime) + { + return AsyncUtils.WaitSafely(() => IdentifyAsync(context), maxWaitTime); + } + + /// + public async Task IdentifyAsync(Context context) + { + Context newContext = _anonymousKeyContextDecorator.DecorateContext(context); + if (_config.AutoEnvAttributes) + { + newContext = _autoEnvContextDecorator.DecorateContext(newContext); + } + + Context + oldContext = + newContext; // this initialization is overwritten below, it's only here to satisfy the compiler + + LockUtils.WithWriteLock(_stateLock, () => + { + oldContext = _context; + _context = newContext; + }); + + // If we had cached data for the new context, set the current in-memory flag data state to use + // that data, so that any Variation calls made before Identify has completed will use the + // last known values. If we did not have cached data, then we update the current in-memory + // state to reflect that there is no flag data, so that Variation calls done before completion + // will receive default values rather than the previous context's values. This does not modify + // any flags in persistent storage, and (currently) it does *not* trigger any FlagValueChanged + // events from FlagTracker. + var cachedData = _dataStore.GetCachedData(newContext); + if (cachedData != null) + { + _log.Debug("Identify found cached flag data for the new context"); + } + + _dataStore.Init( + newContext, + cachedData ?? new DataStoreTypes.FullDataSet(null), + false // false means "don't rewrite the flags to persistent storage" + ); + + EventProcessorIfEnabled().RecordIdentifyEvent(new EventProcessorTypes.IdentifyEvent + { + Timestamp = UnixMillisecondTime.Now, + Context = newContext + }); + + return await _connectionManager.SetContext(newContext); + } + + /// + /// Permanently shuts down the SDK client. + /// + /// + /// + /// This method closes all network collections, shuts down all background tasks, and releases any other + /// resources being held by the SDK. + /// + /// + /// If there are any pending analytics events, and if the SDK is online, it attempts to deliver the events + /// to LaunchDarkly before closing. + /// + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + void Dispose(bool disposing) + { + if (disposing) + { + _log.Info("Shutting down the LaunchDarkly client"); + + _dataSourceUpdateSink.UpdateStatus(DataSourceState.Shutdown, null); + + _backgroundModeManager.BackgroundModeChanged -= OnBackgroundModeChanged; + _connectionManager.Dispose(); + _dataStore.Dispose(); + _eventProcessor.Dispose(); + + // Reset the static Instance to null *if* it was referring to this instance + DetachInstance(); + } + } + + internal void DetachInstance() // exposed for testing + { + Interlocked.CompareExchange(ref _instance, null, this); + } + + internal void OnBackgroundModeChanged(object sender, BackgroundModeChangedEventArgs args) => + _connectionManager.SetInBackground(args.IsInBackground); + + // Returns our configured event processor (which might be the null implementation, if configured + // with NoEvents)-- or, a stub if we have been explicitly put offline. This way, during times + // when the application does not want any network activity, we won't bother buffering events. + internal IEventProcessor EventProcessorIfEnabled() => + Offline ? ComponentsImpl.NullEventProcessor.Instance : _eventProcessor; + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/AppInfo.maui.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/AppInfo.maui.cs new file mode 100644 index 00000000..8c938e32 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/AppInfo.maui.cs @@ -0,0 +1,14 @@ +using System.Globalization; +using Microsoft.Maui.ApplicationModel; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal static partial class AppInfo + { + internal static ApplicationInfo? GetAppInfo() => new ApplicationInfo( + Microsoft.Maui.ApplicationModel.AppInfo.Current.PackageName, + Microsoft.Maui.ApplicationModel.AppInfo.Current.Name, + Microsoft.Maui.ApplicationModel.AppInfo.Current.BuildString, + Microsoft.Maui.ApplicationModel.AppInfo.Current.VersionString); + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/AppInfo.netstandard.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/AppInfo.netstandard.cs new file mode 100644 index 00000000..6ff1fd0f --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/AppInfo.netstandard.cs @@ -0,0 +1,7 @@ +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal static partial class AppInfo + { + internal static ApplicationInfo? GetAppInfo() => null; + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/AsyncScheduler.android.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/AsyncScheduler.android.cs new file mode 100644 index 00000000..9b55f4be --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/AsyncScheduler.android.cs @@ -0,0 +1,19 @@ +using System; +using Android.OS; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal static partial class AsyncScheduler + { + private static void PlatformScheduleAction(Action a) + { + // Note that this logic is different from the implementation of the equivalent method in MAUI Essentials + // (https://github.com/dotnet/maui/blob/main/src/Essentials/src/MainThread/MainThread.android.cs); + // it creates a new Handler object each time rather than lazily creating a static one. This avoids a potential + // race condition, at the expense of creating more ephemeral objects. However, in our use case we do not + // expect this method to be called very frequently since we are using it for flag change listeners only. + var handler = new Handler(Looper.MainLooper); + handler.Post(a); + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/AsyncScheduler.ios.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/AsyncScheduler.ios.cs new file mode 100644 index 00000000..8e8ee8a2 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/AsyncScheduler.ios.cs @@ -0,0 +1,13 @@ +using System; +using Foundation; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal static partial class AsyncScheduler + { + private static void PlatformScheduleAction(Action a) + { + NSRunLoop.Main.BeginInvokeOnMainThread(a.Invoke); + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/AsyncScheduler.netstandard.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/AsyncScheduler.netstandard.cs new file mode 100644 index 00000000..ddaf028e --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/AsyncScheduler.netstandard.cs @@ -0,0 +1,13 @@ +using System; +using System.Threading.Tasks; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal static partial class AsyncScheduler + { + private static void PlatformScheduleAction(Action a) + { + Task.Run(a); + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/AsyncScheduler.shared.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/AsyncScheduler.shared.cs new file mode 100644 index 00000000..4e28715a --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/AsyncScheduler.shared.cs @@ -0,0 +1,17 @@ +using System; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + // This provides a method for asynchronously starting tasks, such as event handlers, using a mechanism + // that may vary by platform. + internal static partial class AsyncScheduler + { + // Queues a task to be executed asynchronously as soon as possible. On platforms that have a notion + // of a "main thread" or "UI thread", the action is guaranteed to run on that thread; otherwise it + // can be any thread. + public static void ScheduleAction(Action a) + { + PlatformScheduleAction(a); + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/BackgroundDetection.android.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/BackgroundDetection.android.cs new file mode 100644 index 00000000..3ab72b38 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/BackgroundDetection.android.cs @@ -0,0 +1,58 @@ +using System; +using Android.App; +using Android.OS; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal static partial class BackgroundDetection + { + private static ActivityLifecycleCallbacks _callbacks; + private static Application _application; + + private static void PlatformStartListening() + { + _callbacks = new ActivityLifecycleCallbacks(); + _application = (Application)Application.Context; + _application.RegisterActivityLifecycleCallbacks(_callbacks); + } + + private static void PlatformStopListening() + { + _callbacks = null; + _application = null; + } + + private class ActivityLifecycleCallbacks : Java.Lang.Object, Application.IActivityLifecycleCallbacks + { + public void OnActivityCreated(Activity activity, Bundle savedInstanceState) + { + } + + public void OnActivityDestroyed(Activity activity) + { + } + + public void OnActivityPaused(Activity activity) + { + UpdateBackgroundMode(true); + } + + public void OnActivityResumed(Activity activity) + { + UpdateBackgroundMode(false); + } + + public void OnActivitySaveInstanceState(Activity activity, Bundle outState) + { + } + + public void OnActivityStarted(Activity activity) + { + } + + public void OnActivityStopped(Activity activity) + { + } + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/BackgroundDetection.ios.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/BackgroundDetection.ios.cs new file mode 100644 index 00000000..164ea412 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/BackgroundDetection.ios.cs @@ -0,0 +1,34 @@ +using System; +using UIKit; +using Foundation; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal static partial class BackgroundDetection + { + private static NSObject _foregroundHandle; + private static NSObject _backgroundHandle; + + private static void PlatformStartListening() + { + _foregroundHandle = NSNotificationCenter.DefaultCenter.AddObserver(UIApplication.WillEnterForegroundNotification, HandleWillEnterForeground); + _backgroundHandle = NSNotificationCenter.DefaultCenter.AddObserver(UIApplication.DidEnterBackgroundNotification, HandleWillEnterBackground); + } + + private static void PlatformStopListening() + { + _foregroundHandle = null; + _backgroundHandle = null; + } + + private static void HandleWillEnterForeground(NSNotification notification) + { + UpdateBackgroundMode(false); + } + + private static void HandleWillEnterBackground(NSNotification notification) + { + UpdateBackgroundMode(true); + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/BackgroundDetection.netstandard.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/BackgroundDetection.netstandard.cs new file mode 100644 index 00000000..30190c98 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/BackgroundDetection.netstandard.cs @@ -0,0 +1,19 @@ +using System; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + // This code is not from MAUI Essentials, though it implements the same abstraction. It is a stub + // that does nothing, since in .NET Standard there is no notion of an application being in the + // background or the foreground. + + internal static partial class BackgroundDetection + { + private static void PlatformStartListening() + { + } + + private static void PlatformStopListening() + { + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/BackgroundDetection.shared.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/BackgroundDetection.shared.cs new file mode 100644 index 00000000..f7211c4d --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/BackgroundDetection.shared.cs @@ -0,0 +1,58 @@ +using System; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal static partial class BackgroundDetection + { + private static event EventHandler _backgroundModeChanged; + + private static object _backgroundModeChangedHandlersLock = new object(); + + private static bool HasHandlers => _backgroundModeChanged != null && _backgroundModeChanged.GetInvocationList().Length != 0; + + public static event EventHandler BackgroundModeChanged + { + add + { + lock (_backgroundModeChangedHandlersLock) + { + var hadHandlers = HasHandlers; + _backgroundModeChanged += value; + if (!hadHandlers) + { + PlatformStartListening(); + } + } + } + remove + { + lock (_backgroundModeChangedHandlersLock) + { + var hadHandlers = HasHandlers; + _backgroundModeChanged -= value; + if (hadHandlers && !HasHandlers) + { + PlatformStopListening(); + } + } + } + } + + private static void UpdateBackgroundMode(bool isInBackground) + { + var args = new BackgroundModeChangedEventArgs(isInBackground); + var handlers = _backgroundModeChanged; + handlers?.Invoke(null, args); + } + } + + internal class BackgroundModeChangedEventArgs + { + public bool IsInBackground { get; private set; } + + public BackgroundModeChangedEventArgs(bool isInBackground) + { + IsInBackground = isInBackground; + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Connectivity.maui.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Connectivity.maui.cs new file mode 100644 index 00000000..ef8acdde --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Connectivity.maui.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Maui.Networking; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal static partial class PlatformConnectivity + { + static PlatformConnectivity() + { + Connectivity.Current.ConnectivityChanged += (sender, args) => ConnectivityChanged?.Invoke(sender, EventArgs.Empty); + } + + /// + /// Gets the current state of network access. + /// + public static LdNetworkAccess LdNetworkAccess => Convert(Connectivity.Current.NetworkAccess); + + /// + /// Gets the active connectivity types for the device. + /// + public static IEnumerable LdConnectionProfiles => Connectivity.Current.ConnectionProfiles.Distinct().Select(Convert); + + /// + /// Occurs when network access or profile has changed. This is just a signal and the + /// event args are empty. This is to avoid the need for an additional event args class + /// container for the list of . + /// + public static event EventHandler ConnectivityChanged; + + private static LdConnectionProfile Convert(ConnectionProfile mauiValue) { + switch (mauiValue) { + case ConnectionProfile.Bluetooth: + return LdConnectionProfile.Bluetooth; + case ConnectionProfile.Cellular: + return LdConnectionProfile.Cellular; + case ConnectionProfile.Ethernet: + return LdConnectionProfile.Ethernet; + case ConnectionProfile.WiFi: + return LdConnectionProfile.WiFi; + default: + return LdConnectionProfile.Unknown; + } + } + + private static LdNetworkAccess Convert(NetworkAccess mauiValue) + { + switch (mauiValue) + { + case NetworkAccess.None: + return LdNetworkAccess.None; + case NetworkAccess.Local: + return LdNetworkAccess.Local; + case NetworkAccess.ConstrainedInternet: + return LdNetworkAccess.ConstrainedInternet; + case NetworkAccess.Internet: + return LdNetworkAccess.Internet; + default: + return LdNetworkAccess.Unknown; + } + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Connectivity.netstandard.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Connectivity.netstandard.cs new file mode 100644 index 00000000..9c11397e --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Connectivity.netstandard.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + // This code is not from MAUI Essentials, though it implements our Connectivity abstraction. + // It is a stub that always reports that we do have network connectivity. + // + // Unfortunately, in .NET Standard that is the best we can do. There is (at least in 2.0) a + // NetworkInterface.GetIsNetworkAvailable() method, but that doesn't test whether we actuually have + // Internet access, just whether we have a network interface (i.e. if we're running a desktop app + // on a laptop, and the wi-fi is turned off, it will still return true as long as the laptop has an + // Ethernet card-- even if it's not plugged in). + + internal static partial class PlatformConnectivity + { + public static LdNetworkAccess LdNetworkAccess => LdNetworkAccess.Internet; + + public static event EventHandler ConnectivityChanged; + + public static IEnumerable LdConnectionProfiles + { + get + { + yield return LdConnectionProfile.Unknown; + } + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Connectivity.shared.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Connectivity.shared.cs new file mode 100644 index 00000000..78a56679 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Connectivity.shared.cs @@ -0,0 +1,49 @@ +/* +The MIT License (MIT) + +Copyright(c).NET Foundation and Contributors + +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +using System; +using System.Collections.Generic; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal enum LdConnectionProfile + { + Unknown = 0, + Bluetooth = 1, + Cellular = 2, + Ethernet = 3, + WiFi = 4 + } + + internal enum LdNetworkAccess + { + Unknown = 0, + None = 1, + Local = 2, + ConstrainedInternet = 3, + Internet = 4 + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/DeviceInfo.maui.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/DeviceInfo.maui.cs new file mode 100644 index 00000000..f15e5363 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/DeviceInfo.maui.cs @@ -0,0 +1,48 @@ +using LaunchDarkly.Sdk.EnvReporting.LayerModels; +using Devices = Microsoft.Maui.Devices; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal static partial class DeviceInfo + { + internal static OsInfo? GetOsInfo() => + new OsInfo( + PlatformToFamilyString(Devices.DeviceInfo.Current.Platform), + Devices.DeviceInfo.Current.Platform.ToString(), + Devices.DeviceInfo.Current.VersionString + ); + + internal static LaunchDarkly.Sdk.EnvReporting.LayerModels.DeviceInfo? GetDeviceInfo() => + new EnvReporting.LayerModels.DeviceInfo( + Devices.DeviceInfo.Current.Manufacturer, + Devices.DeviceInfo.Current.Model + ); + + private static string PlatformToFamilyString(Devices.DevicePlatform platform) { + if (platform == Devices.DevicePlatform.Android) + { + return "Android"; + } + else if (platform == Devices.DevicePlatform.iOS || + platform == Devices.DevicePlatform.watchOS || + platform == Devices.DevicePlatform.tvOS || + platform == Devices.DevicePlatform.macOS || + platform == Devices.DevicePlatform.MacCatalyst) + { + return "Apple"; + } + else if(platform == Devices.DevicePlatform.WinUI) + { + return "Windows"; + } + else if(platform == Devices.DevicePlatform.Tizen) + { + return "Linux"; + } + else + { + return "unknown"; + } + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/DeviceInfo.netstandard.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/DeviceInfo.netstandard.cs new file mode 100644 index 00000000..49445dd1 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/DeviceInfo.netstandard.cs @@ -0,0 +1,11 @@ +using LaunchDarkly.Sdk.EnvReporting.LayerModels; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal static partial class DeviceInfo + { + internal static OsInfo? GetOsInfo() => null; + + internal static LaunchDarkly.Sdk.EnvReporting.LayerModels.DeviceInfo? GetDeviceInfo() => null; + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Http.android.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Http.android.cs new file mode 100644 index 00000000..d289d09f --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Http.android.cs @@ -0,0 +1,30 @@ +using System; +using System.Net; +using System.Net.Http; +using LaunchDarkly.Sdk.Internal.Http; +using Xamarin.Android.Net; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal static partial class Http + { + private static Func PlatformGetHttpMessageHandlerFactory( + TimeSpan connectTimeout, + IWebProxy proxy + ) => + p => new AndroidMessageHandler() + { + ConnectTimeout = connectTimeout, + Proxy = proxy + }; + + private static Exception PlatformTranslateHttpException(Exception e) + { + if (e is Java.Net.SocketTimeoutException) + { + return new TimeoutException(); + } + return e; + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Http.ios.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Http.ios.cs new file mode 100644 index 00000000..155a9710 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Http.ios.cs @@ -0,0 +1,32 @@ +using System; +using System.Net; +using System.Net.Http; +using LaunchDarkly.Sdk.Internal.Http; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal static partial class Http + { + // NSUrlSessionHandler is the preferred native implementation of HttpMessageHandler on iOS. + // However, it does not support programmatically setting a proxy, so if a proxy was specified + // we must fall back to the non-native .NET implementation. + // + // Note that we set DisableCaching to true because we do not want iOS to handle HTTP caching + // for us: in the one area where we want to use it (polling), we keep track of the Etag + // ourselves, and if the server returns a 304 status we want to be able to see that and know + // that we don't have to update anything. If iOS did the caching for us, a 304 would be + // transparently changed to a 200 response with the cached data, and we would end up + // pointlessly re-parsing and reapplying the response. + + private static Func PlatformGetHttpMessageHandlerFactory( + TimeSpan connectTimeout, + IWebProxy proxy + ) => + (proxy is null) + ? (p => (HttpMessageHandler)new NSUrlSessionHandler() { DisableCaching = true }) + : (Func)null; + + // NSUrlSessionHandler doesn't appear to throw platform-specific exceptions that we care about + private static Exception PlatformTranslateHttpException(Exception e) => e; + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Http.netstandard.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Http.netstandard.cs new file mode 100644 index 00000000..82424c34 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Http.netstandard.cs @@ -0,0 +1,18 @@ +using System; +using System.Net; +using System.Net.Http; +using LaunchDarkly.Sdk.Internal.Http; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal static partial class Http + { + private static Func PlatformGetHttpMessageHandlerFactory( + TimeSpan connectTimeout, IWebProxy proxy) => + null; + // Returning null means HttpProperties will use the default .NET implementation, + // which will take care of configuring the HTTP client with timeouts/proxies. + + private static Exception PlatformTranslateHttpException(Exception e) => e; + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Http.shared.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Http.shared.cs new file mode 100644 index 00000000..e3846bb4 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Http.shared.cs @@ -0,0 +1,43 @@ +using System; +using System.Net; +using System.Net.Http; +using LaunchDarkly.Sdk.Internal.Http; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal static partial class Http + { + /// + /// If our default configuration should use a specific + /// implementation, returns a factory for that implementation. + /// + /// + /// The return value is a factory, rather than having this method itself be the factory, + /// because of how our shared HttpProperties class is implemented: if you pass a + /// non-null MessageHandler factory function, then it assumes you will definitely be + /// returning a fully configured handler, so we would not be able to conditionally fall + /// back to the default .NET implementation without duplicating the proxy/timeout setup + /// logic here. + /// + /// an HTTP message handler factory or null + public static Func GetHttpMessageHandlerFactory( + TimeSpan connectTimeout, + IWebProxy proxy + ) => + PlatformGetHttpMessageHandlerFactory(connectTimeout, proxy); + + /// + /// Converts any platform-specific exceptions that might be thrown by the platform-specific + /// HTTP handler to their .NET equivalents. + /// + /// + /// We don't really care about specific network exception classes in our code, but in any case + /// where we might expose the exception to application code, we want to normalize it to use only + /// .NET classes. + /// + /// an exception + /// the same exception or a more .NET-appropriate one + public static Exception TranslateHttpException(Exception e) => + e is HttpRequestException ? e : PlatformTranslateHttpException(e); + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/LocalStorage.android.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/LocalStorage.android.cs new file mode 100644 index 00000000..1519b9ed --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/LocalStorage.android.cs @@ -0,0 +1,43 @@ +using Android.App; +using Android.Content; +using Android.Preferences; +using LaunchDarkly.Sdk.Client.Subsystems; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal sealed partial class LocalStorage : IPersistentDataStore + { + public string GetValue(string storageNamespace, string key) + { + using (var sharedPreferences = GetSharedPreferences(storageNamespace)) + { + return sharedPreferences.GetString(key, null); + } + } + + public void SetValue(string storageNamespace, string key, string value) + { + using (var sharedPreferences = GetSharedPreferences(storageNamespace)) + using (var editor = sharedPreferences.Edit()) + { + if (value is null) + { + editor.Remove(key).Commit(); + } + else + { + editor.PutString(key, value).Commit(); + } + } + } + + static ISharedPreferences GetSharedPreferences(string sharedName) + { + var context = Application.Context; + + return sharedName is null ? + PreferenceManager.GetDefaultSharedPreferences(context) : + context.GetSharedPreferences(sharedName, FileCreationMode.Private); + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/LocalStorage.ios.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/LocalStorage.ios.cs new file mode 100644 index 00000000..6059499c --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/LocalStorage.ios.cs @@ -0,0 +1,56 @@ +using Foundation; +using LaunchDarkly.Sdk.Client.Subsystems; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal sealed partial class LocalStorage : IPersistentDataStore + { + private const string LaunchDarklyMainKey = "com.launchdarkly.sdk"; + + public string GetValue(string storageNamespace, string key) + { + using (var defaults = NSUserDefaults.StandardUserDefaults) + { + var mainDict = defaults.DictionaryForKey(LaunchDarklyMainKey); + if (mainDict is null) + { + return null; + } + var groupDict = mainDict.ObjectForKey(new NSString(storageNamespace)) as NSDictionary; + if (groupDict is null) + { + return null; + } + var value = groupDict.ObjectForKey(new NSString(key)); + return value?.ToString(); + } + } + + public void SetValue(string storageNamespace, string key, string value) + { + using (var defaults = NSUserDefaults.StandardUserDefaults) + { + var mainDict = defaults.DictionaryForKey(LaunchDarklyMainKey) as NSDictionary; + var newMainDict = mainDict is null ? new NSMutableDictionary() : + new NSMutableDictionary(mainDict); + + var groupKey = new NSString(storageNamespace); + var groupDict = newMainDict.ObjectForKey(groupKey) as NSDictionary; + var newGroupDict = groupDict is null ? new NSMutableDictionary() : + new NSMutableDictionary(groupDict); + + if (value is null) + { + newGroupDict.Remove(new NSString(key)); + } + else + { + newGroupDict.SetValueForKey(new NSString(value), new NSString(key)); + } + + newMainDict.SetValueForKey(newGroupDict, groupKey); + defaults.SetValueForKey(newMainDict, new NSString(LaunchDarklyMainKey)); + } + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/LocalStorage.netstandard.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/LocalStorage.netstandard.cs new file mode 100644 index 00000000..b2119110 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/LocalStorage.netstandard.cs @@ -0,0 +1,89 @@ +using System; +using System.IO; +using System.IO.IsolatedStorage; +using LaunchDarkly.Sdk.Client.Subsystems; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + // In .NET Standard 2.0, we use the IsolatedStorage API to store per-user data. The .NET Standard implementation + // of IsolatedStorage puts these files under ~/.local/share/IsolatedStorage followed by a subpath of obfuscated + // strings that are apparently based on the application and assembly name, so the data should be specific to both + // the OS user account and the current app. + // + // This is based on the Plugin.Settings plugin, but greatly simplified since we only need one data type. + // See: https://github.com/jamesmontemagno/SettingsPlugin/blob/master/src/Plugin.Settings/Settings.dotnet.cs + + internal sealed partial class LocalStorage : IPersistentDataStore + { + public string GetValue(string storageNamespace, string key) + { + return WithStore(store => + { + try + { + using (var stream = store.OpenFile(MakeFilePath(storageNamespace, key), FileMode.Open)) + { + using (var sr = new StreamReader(stream)) + { + return sr.ReadToEnd(); + } + } + } + // ignore exceptions that just indicate no value has been set for this namespace/key + catch (IsolatedStorageException e) when (e.InnerException is DirectoryNotFoundException) { } + catch (IsolatedStorageException e) when (e.InnerException is FileNotFoundException) { } + catch (DirectoryNotFoundException) { } + catch (FileNotFoundException) { } + return null; + }); + } + + public void SetValue(string storageNamespace, string key, string value) + { + WithStore(store => + { + var filePath = MakeFilePath(storageNamespace, key); + if (value is null) + { + try + { + store.DeleteFile(filePath); + } + catch (IsolatedStorageException) { } // file didn't exist - that's OK + } + else + { + store.CreateDirectory(storageNamespace); // has no effect if directory already exists + using (var stream = store.OpenFile(filePath, FileMode.Create, FileAccess.Write)) + { + using (var sw = new StreamWriter(stream)) + { + sw.Write(value); + } + } + } + }); + } + + private T WithStore(Func callback) + { + // GetUserStoreForDomain returns a storage object that is specific to the current application and OS user. + using (var store = IsolatedStorageFile.GetUserStoreForDomain()) + { + return callback(store); + } + } + + private void WithStore(Action callback) + { + WithStore(store => + { + callback(store); + return true; + }); + } + + private static string MakeFilePath(string storageNamespace, string key) => + storageNamespace + "/" + key; + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/LocalStorage.shared.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/LocalStorage.shared.cs new file mode 100644 index 00000000..4576eaa1 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/LocalStorage.shared.cs @@ -0,0 +1,17 @@ +using LaunchDarkly.Sdk.Client.Subsystems; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + /// + /// Platform-specific implementations of the IPersistentDataStore interface for + /// storing arbitrary string key-value pairs. On mobile devices, this is + /// implemented with the native preferences API. In .NET Standard, it is + /// implemented with the IsolatedStorage API. + /// + internal sealed partial class LocalStorage : IPersistentDataStore + { + internal static readonly LocalStorage Instance = new LocalStorage(); + + public void Dispose() { } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Logging.android.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Logging.android.cs new file mode 100644 index 00000000..1767152d --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Logging.android.cs @@ -0,0 +1,121 @@ +using System; +using LaunchDarkly.Logging; +using AndroidLog = Android.Util.Log; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal static partial class Logging + { + internal static ILogAdapter PlatformDefaultAdapter => + AndroidLogAdapter.Instance; + + // Implementation of the LaunchDarkly.Logging API for sending output to Android's standard + // logging framework. Notes: + // 1. This sets the log tag to be the same as the LaunchDarkly logger name (see LogNames and + // LoggingConfigurationBuilder.BaseLoggerName). + // 2. The underlying Android API is a little different: Log has methods called "d", "i", "w", + // and "e" rather than Debug, Info, Warn, and Error, and they always take a message string + // rather than a format string plus variables. MAUI's Android layer adds some decoration of + // its own so that we can use .NET-style format strings. + private sealed class AndroidLogAdapter : ILogAdapter + { + internal static readonly AndroidLogAdapter Instance = + new AndroidLogAdapter(); + + public IChannel NewChannel(string name) => new ChannelImpl(name); + + private sealed class ChannelImpl : IChannel + { + private readonly string _name; + + internal ChannelImpl(string name) + { + _name = name; + } + + // As defined in IChannel, IsEnabled really means "is it *potentially* + // enabled" - it's a shortcut to make it easier to skip computing any + // debug-level output if we know for sure that debug is disabled. But + // we don't have a way to find that out here. + public bool IsEnabled(LogLevel level) => true; + + public void Log(LogLevel level, object message) + { + var s = message.ToString(); + switch (level) + { + case LogLevel.Debug: + AndroidLog.Debug(_name, s); + break; + case LogLevel.Info: + AndroidLog.Info(_name, s); + break; + case LogLevel.Warn: + AndroidLog.Warn(_name, s); + break; + case LogLevel.Error: + AndroidLog.Error(_name, s); + break; + } + } + + public void Log(LogLevel level, string format, object param) + { + switch (level) + { + case LogLevel.Debug: + AndroidLog.Debug(_name, format, param); + break; + case LogLevel.Info: + AndroidLog.Info(_name, format, param); + break; + case LogLevel.Warn: + AndroidLog.Warn(_name, format, param); + break; + case LogLevel.Error: + AndroidLog.Error(_name, format, param); + break; + } + } + + public void Log(LogLevel level, string format, object param1, object param2) + { + switch (level) + { + case LogLevel.Debug: + AndroidLog.Debug(_name, format, param1, param2); + break; + case LogLevel.Info: + AndroidLog.Info(_name, format, param1, param2); + break; + case LogLevel.Warn: + AndroidLog.Warn(_name, format, param1, param2); + break; + case LogLevel.Error: + AndroidLog.Error(_name, format, param1, param2); + break; + } + } + + public void Log(LogLevel level, string format, params object[] allParams) + { + switch (level) + { + case LogLevel.Debug: + AndroidLog.Debug(_name, format, allParams); + break; + case LogLevel.Info: + AndroidLog.Info(_name, format, allParams); + break; + case LogLevel.Warn: + AndroidLog.Warn(_name, format, allParams); + break; + case LogLevel.Error: + AndroidLog.Error(_name, format, allParams); + break; + } + } + } + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Logging.ios.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Logging.ios.cs new file mode 100644 index 00000000..50afa20e --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Logging.ios.cs @@ -0,0 +1,84 @@ +using System; +using CoreFoundation; +using LaunchDarkly.Logging; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal static partial class Logging + { + internal static ILogAdapter PlatformDefaultAdapter => + IOsLogAdapter.Instance; + + // Implementation of the LaunchDarkly.Logging API for sending output to iOS's standard + // logging framework, OSLog. + // + // OSLog uses logger names slightly differently: it has two name-like properties, "subsystem" + // and "category", with "category" being the more specific one. We're handling this by + // splitting up our logger name as described in the docs for LoggingConfigurationBuilder. + private sealed class IOsLogAdapter : ILogAdapter + { + internal static readonly IOsLogAdapter Instance = + new IOsLogAdapter(); + + public IChannel NewChannel(string name) => new ChannelImpl(name); + + private sealed class ChannelImpl : IChannel + { + private readonly CoreFoundation.OSLog _log; + + internal ChannelImpl(string name) + { + string subsystem, category; + int pos = name.IndexOf('.'); + if (pos > 0) + { + subsystem = name.Substring(0, pos); + category = name.Substring(pos + 1); + } else + { + subsystem = name; + category = ""; + } + _log = new CoreFoundation.OSLog(subsystem, category); + } + + // As defined in IChannel, IsEnabled really means "is it *potentially* + // enabled" - it's a shortcut to make it easier to skip computing any + // debug-level output if we know for sure that debug is disabled. But + // we don't have a way to find that out here. + public bool IsEnabled(LogLevel level) => true; + + private void LogString(LogLevel level, string s) + { + switch (level) + { + case LogLevel.Debug: + _log.Log(OSLogLevel.Debug, s); + break; + case LogLevel.Info: + _log.Log(OSLogLevel.Info, s); + break; + case LogLevel.Warn: + _log.Log(OSLogLevel.Default, s); + break; + case LogLevel.Error: + _log.Log(OSLogLevel.Error, s); + break; + } + } + + public void Log(LogLevel level, object message) => + LogString(level, message.ToString()); + + public void Log(LogLevel level, string format, object param) => + LogString(level, string.Format(format, param)); + + public void Log(LogLevel level, string format, object param1, object param2) => + LogString(level, string.Format(format, param1, param2)); + + public void Log(LogLevel level, string format, params object[] allParams) => + LogString(level, string.Format(format, allParams)); + } + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Logging.netstandard.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Logging.netstandard.cs new file mode 100644 index 00000000..39fb27ae --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Logging.netstandard.cs @@ -0,0 +1,9 @@ +using LaunchDarkly.Logging; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal static partial class Logging + { + internal static ILogAdapter PlatformDefaultAdapter => Logs.ToConsole; + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Logging.shared.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Logging.shared.cs new file mode 100644 index 00000000..6d08a3a5 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/PlatformSpecific/Logging.shared.cs @@ -0,0 +1,9 @@ +using LaunchDarkly.Logging; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal static partial class Logging + { + public static ILogAdapter DefaultAdapter => PlatformDefaultAdapter; + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Properties/AssemblyInfo.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..1a9acf81 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Properties/AssemblyInfo.cs @@ -0,0 +1,11 @@ +using System; +using System.Runtime.CompilerServices; + +#if DEBUG +// Allow unit tests to see internal classes. The test assemblies are not +// strong-named, so tests must be run against the Debug configuration of +// this assembly. + +[assembly: InternalsVisibleTo("LaunchDarkly.ClientSdk.Tests")] +[assembly: InternalsVisibleTo("LaunchDarkly.ClientSdk.Device.Tests")] +#endif diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/DataStoreTypes.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/DataStoreTypes.cs new file mode 100644 index 00000000..2b3ddc0b --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/DataStoreTypes.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +using static LaunchDarkly.Sdk.Client.DataModel; + +namespace LaunchDarkly.Sdk.Client.Subsystems +{ + /// + /// Types that are used by the data store and related interfaces. + /// + public static class DataStoreTypes + { + /// + /// A versioned item (or placeholder) storeable in a data store. + /// + public struct ItemDescriptor : IEquatable + { + /// + /// The version number of this data, provided by the SDK. + /// + public int Version { get; } + + /// + /// The data item, or null if this is a deleted item placeholder. + /// + public FeatureFlag Item { get; } + + /// + /// Constructs an instance. + /// + /// the version number + /// the data item, or null if this is a deleted item placeholder + public ItemDescriptor(int version, FeatureFlag item) + { + Version = version; + Item = item; + } + + /// + public override bool Equals(object obj) => + obj is ItemDescriptor o && Equals(o); + + + /// + public bool Equals(ItemDescriptor other) => + other.Version == this.Version && + object.Equals(other.Item, this.Item); + + /// + public override int GetHashCode() => + Version + 31 * (Item?.GetHashCode() ?? 0); + + /// + public override string ToString() => "ItemDescriptor(" + Version + "," + Item + ")"; + } + + /// + /// Represents a full set of feature flag data received from LaunchDarkly. + /// + public struct FullDataSet : IEquatable + { + /// + /// The feature flag data. + /// + public IImmutableList> Items { get; } + + /// + /// Creates a new instance. + /// + /// the feature flags, indexed by key + public FullDataSet(IEnumerable> items) + { + Items = items is null ? ImmutableList>.Empty : + ImmutableList.CreateRange(items); + } + + /// + public override bool Equals(object obj) => + obj is FullDataSet o && Equals(o); + + + /// + public bool Equals(FullDataSet other) => + Enumerable.SequenceEqual(other.Items.OrderBy(ItemKey), this.Items.OrderBy(ItemKey)); + + /// + public override int GetHashCode() => Items.OrderBy(ItemKey).GetHashCode(); + + private static string ItemKey(KeyValuePair kv) => kv.Key; + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/EventProcessorTypes.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/EventProcessorTypes.cs new file mode 100644 index 00000000..d1c20279 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/EventProcessorTypes.cs @@ -0,0 +1,125 @@ + +namespace LaunchDarkly.Sdk.Client.Subsystems +{ + /// + /// Parameter types for use by implementations. + /// + /// + /// Application code normally does not need to use these types or interact directly with any + /// functionality. They are provided to allow a custom implementation + /// or test fixture to be substituted for the SDK's normal analytics event logic. + /// + public static class EventProcessorTypes + { + /// + /// Parameters for . + /// + public struct EvaluationEvent + { + /// + /// Date/timestamp of the event. + /// + public UnixMillisecondTime Timestamp { get; set; } + + /// + /// The context for the evaluation. Some attributes may not be sent to LaunchDarkly if they + /// are private. + /// + public Context Context { get; set; } + + /// + /// The unique key of the feature flag involved in the event. + /// + public string FlagKey { get; set; } + + /// + /// The version of the flag. + /// + public int? FlagVersion { get; set; } + + /// + /// The variation index for the computed value of the flag. + /// + public int? Variation { get; set; } + + /// + /// The computed value of the flag. + /// + public LdValue Value { get; set; } + + /// + /// The default value of the flag. + /// + public LdValue Default { get; set; } + + /// + /// An explanation of how the value was calculated, or null if the reason was not requested. + /// + public EvaluationReason? Reason { get; set; } + + /// + /// The key of the flag that this flag is a prerequisite of, if any. + /// + public string PrerequisiteOf { get; set; } + + /// + /// True if full-fidelity analytics events should be sent for this flag. + /// + public bool TrackEvents { get; set; } + + /// + /// If set, debug events are being generated until this date/time. + /// + public UnixMillisecondTime? DebugEventsUntilDate { get; set; } + } + + /// + /// Parameters for . + /// + public struct IdentifyEvent + { + /// + /// Date/timestamp of the event. + /// + public UnixMillisecondTime Timestamp { get; set; } + + /// + /// The evaluation context associated with the event. Some attributes may not be sent + /// to LaunchDarkly if they are private. + /// + public Context Context { get; set; } + } + + /// + /// Parameters for . + /// + public struct CustomEvent + { + /// + /// Date/timestamp of the event. + /// + public UnixMillisecondTime Timestamp { get; set; } + /// + /// The evaluation context associated with the event. Some attributes may not be sent + /// to LaunchDarkly if they are private. + /// + public Context Context { get; set; } + + /// + /// The event key. + /// + public string EventKey { get; set; } + + + /// + /// Custom data provided for the event. + /// + public LdValue Data { get; set; } + + /// + /// An optional numeric value that can be used in analytics. + /// + public double? MetricValue { get; set; } + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/HttpConfiguration.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/HttpConfiguration.cs new file mode 100644 index 00000000..286e697d --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/HttpConfiguration.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using LaunchDarkly.Sdk.Internal.Http; +using LaunchDarkly.Sdk.Client.Integrations; + +namespace LaunchDarkly.Sdk.Client.Subsystems +{ + /// + /// Encapsulates top-level HTTP configuration that applies to all SDK components. + /// + /// + /// Use to construct an instance. + /// + public sealed class HttpConfiguration + { + /// + /// The network connection timeout. + /// + /// + /// + /// This is the time allowed for the underlying HTTP client to connect to the + /// LaunchDarkly server, for any individual network connection. + /// + /// + /// Not all .NET platforms support setting a connection timeout. It is implemented as + /// a property of System.Net.Http.SocketsHttpHandler in .NET Core 2.1+ and .NET + /// 5+, but is unavailable in .NET Framework and .NET Standard. On platforms where it + /// is not supported, only will be used. + /// + /// + /// Since this is implemented only in SocketsHttpHandler, if you have + /// specified some other HTTP handler implementation with , + /// the here will be ignored. + /// + /// + /// + public TimeSpan ConnectTimeout { get; } + + /// + /// HTTP headers to be added to all HTTP requests made by the SDK. + /// + /// + /// These include Authorization, User-Agent, and any headers that were + /// specified with . + /// + public IEnumerable> DefaultHeaders { get; } + + /// + /// A custom handler for HTTP requests, or null to use the platform's default handler. + /// + public HttpMessageHandler MessageHandler { get; } + + /// + /// The proxy configuration, if any. + /// + /// + /// This is only present if a proxy was specified programmatically with + /// , not if it was + /// specified with an environment variable. + /// + public IWebProxy Proxy { get; } + + /// + /// The maximum amount of time to wait for the beginning of an HTTP response. + /// + /// + /// + /// This limits how long the SDK will wait from the time it begins trying to make a + /// network connection for an individual HTTP request to the time it starts receiving + /// any data from the server. It is equivalent to the Timeout property in + /// HttpClient. + /// + /// + /// It is not the same as the timeout parameter to, + /// which limits the time for initializing the SDK regardless of how many individual HTTP + /// requests are done in that time. + /// + /// + /// + public TimeSpan ResponseStartTimeout { get; } + + /// + /// Used internally by SDK code that uses the HttpProperties abstraction from LaunchDarkly.InternalSdk. + /// + internal HttpProperties HttpProperties { get; } + + // NOTE: UseReport is currently internal rather than public because the REPORT verb does not + // work on every platform (ch47341) + /// + /// Whether to use the HTTP REPORT method for feature flag requests. + /// + /// + /// + /// By default, polling and streaming connections are made with the GET method, with the user data + /// encoded into the request URI. Using REPORT allows the user data to be sent in the request body + /// instead, which is somewhat more secure and efficient. + /// + /// + /// However, some mobile platforms and some network gateways do not support REPORT, so it is + /// disabled by default. You can enable it if you know it is supported in your environment. + /// + /// + internal bool UseReport { get; } + + /// + /// Constructs an instance, setting all properties. + /// + /// value for + /// value for + /// value for + /// value for + /// value for + /// value for + public HttpConfiguration( + TimeSpan connectTimeout, + IEnumerable> defaultHeaders, + HttpMessageHandler messageHandler, + IWebProxy proxy, + TimeSpan responseStartTimeout, + bool useReport + ) : + this( + MakeHttpProperties(connectTimeout, defaultHeaders, messageHandler, proxy), + messageHandler, + responseStartTimeout, + useReport + ) + { } + + internal HttpConfiguration( + HttpProperties httpProperties, + HttpMessageHandler messageHandler, + TimeSpan responseStartTimeout, + bool useReport + ) + { + HttpProperties = httpProperties; + ConnectTimeout = httpProperties.ConnectTimeout; + DefaultHeaders = httpProperties.BaseHeaders; + MessageHandler = messageHandler; + Proxy = httpProperties.Proxy; + ResponseStartTimeout = responseStartTimeout; + UseReport = useReport; + } + + /// + /// Helper method for creating an HTTP client instance using the configured properties. + /// + /// a client instance + public HttpClient NewHttpClient() + { + var httpClient = MessageHandler is null ? + new HttpClient() : + new HttpClient(MessageHandler, false); + foreach (var h in DefaultHeaders) + { + httpClient.DefaultRequestHeaders.Add(h.Key, h.Value); + } + httpClient.Timeout = ResponseStartTimeout; + return httpClient; + } + + internal static HttpProperties MakeHttpProperties( + TimeSpan connectTimeout, + IEnumerable> defaultHeaders, + HttpMessageHandler messageHandler, + IWebProxy proxy + ) + { + var ret = HttpProperties.Default + .WithConnectTimeout(connectTimeout) + .WithHttpMessageHandlerFactory(messageHandler is null ? + (Func)null : + _ => messageHandler) + .WithProxy(proxy); + foreach (var kv in defaultHeaders) + { + ret = ret.WithHeader(kv.Key, kv.Value); + } + return ret; + } + + internal static HttpConfiguration Default() => + new HttpConfiguration(HttpProperties.Default, null, HttpConfigurationBuilder.DefaultResponseStartTimeout, false); + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/IComponentConfigurer.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/IComponentConfigurer.cs new file mode 100644 index 00000000..c395ebae --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/IComponentConfigurer.cs @@ -0,0 +1,20 @@ + +namespace LaunchDarkly.Sdk.Client.Subsystems +{ + /// + /// The common interface for SDK component factories and configuration builders. Applications should not + /// need to implement this interface. + /// + /// the type of SDK component or configuration object being constructed + public interface IComponentConfigurer + { + /// + /// Called internally by the SDK to create an implementation instance. Applications should not need + /// to call this method. + /// + /// provides configuration properties and other components from the current + /// SDK client instance + /// a instance of the component type + T Build(LdClientContext context); + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/IDataSource.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/IDataSource.cs new file mode 100644 index 00000000..a723ea56 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/IDataSource.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading.Tasks; + +namespace LaunchDarkly.Sdk.Client.Subsystems +{ + /// + /// Interface for an object that receives updates to feature flags from LaunchDarkly. + /// + /// + /// This component uses a push model. When it is created, the SDK will provide a reference to an + /// component, which is a write-only abstraction of + /// the data store. The SDK never requests feature flag data from the , it + /// only looks at the last known data that was previously put into the store. + /// + public interface IDataSource : IDisposable + { + /// + /// Initializes the data source. This is called once from the constructor. + /// + /// a Task which is completed once the data source has finished starting up + Task Start(); + + /// + /// Checks whether the data source has finished initializing. + /// + /// + /// This is true if it has received at least one full set of feature flag data from LaunchDarkly, + /// or if it is never going to do so because we are deliberately offline. + /// + /// true if fully initialized + bool Initialized { get; } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/IDataSourceUpdateSink.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/IDataSourceUpdateSink.cs new file mode 100644 index 00000000..5ce71d0f --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/IDataSourceUpdateSink.cs @@ -0,0 +1,68 @@ +using LaunchDarkly.Sdk.Client.Interfaces; + +using static LaunchDarkly.Sdk.Client.Subsystems.DataStoreTypes; + +namespace LaunchDarkly.Sdk.Client.Subsystems +{ + // Note: In .NET server-side SDK 6.x, Java SDK 5.x, and Go SDK 5.x, where this component was added, it + // is called "DataSourceUpdates". This name was thought to be a bit confusing, since it receives updates + // rather than providing them, so as of .NET client-side SDK 2.x we are calling it an "update sink". + + /// + /// Interface that an implementation of will use to push data into the SDK. + /// + /// + /// The data source interacts with this object, rather than manipulating the data store directly, so + /// that the SDK can perform any other necessary operations that must happen when data is updated. + /// + public interface IDataSourceUpdateSink + { + /// + /// Completely overwrites the current contents of the data store with a set of items for each collection. + /// + /// the current evaluation context + /// the data set + /// true if the update succeeded, false if it failed + void Init(Context context, FullDataSet data); + + /// + /// Updates or inserts an item. For updates, the object will only be updated if the existing + /// version is less than the new version. + /// + /// the current evaluation context + /// the feature flag key + /// the item data + void Upsert(Context context, string key, ItemDescriptor data); + + /// + /// Informs the SDK of a change in the data source's status. + /// + /// + /// + /// Data source implementations should use this method if they have any concept of being in a valid + /// state, a temporarily disconnected state, or a permanently stopped state. + /// + /// + /// If is different from the previous state, and/or + /// is non-null, the SDK will start returning the new status(adding a timestamp for the change) from + /// , and will trigger status change events to any + /// registered listeners. + /// + /// + /// A special case is that if is , + /// but the previous state was , the state will + /// remain at because + /// is only meaningful after a successful startup. + /// + /// + /// Data source implementations normally should not need to set the state to + /// , because that will happen automatically if they call + /// . + /// + /// + /// the data source state + /// information about a new error, if any + /// + void UpdateStatus(DataSourceState newState, DataSourceStatus.ErrorInfo? newError); + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/IDiagnosticDescription.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/IDiagnosticDescription.cs new file mode 100644 index 00000000..abfd1df6 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/IDiagnosticDescription.cs @@ -0,0 +1,30 @@ + +namespace LaunchDarkly.Sdk.Client.Subsystems +{ + /// + /// Optional interface for components to describe their own configuration. + /// + /// + /// + /// The SDK uses a simplified JSON representation of its configuration when recording diagnostics data. + /// Any class that implements may choose to contribute values to + /// this representation, although the SDK may or may not use them. + /// + /// + /// The method should return either or a + /// JSON value. For custom components, the value must be a string that describes the basic nature of + /// this component implementation (e.g. "Redis"). Built-in LaunchDarkly components may instead return a + /// JSON object containing multiple properties specific to the LaunchDarkly diagnostic schema. + /// + /// + public interface IDiagnosticDescription + { + /// + /// Called internally by the SDK to inspect the configuration. Applications do not need to call + /// this method. + /// + /// the context object that may provide relevant configuration details + /// a JSON value + LdValue DescribeConfiguration(LdClientContext context); + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/IEventProcessor.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/IEventProcessor.cs new file mode 100644 index 00000000..8024fae6 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/IEventProcessor.cs @@ -0,0 +1,73 @@ +using System; +using System.Threading.Tasks; +using LaunchDarkly.Sdk.Client.Interfaces; + +namespace LaunchDarkly.Sdk.Client.Subsystems +{ + /// + /// Interface for an object that can send or store analytics events. + /// + /// + /// + /// Application code normally does not need to interact with or its + /// related parameter types. They are provided to allow a custom implementation or test fixture to be + /// substituted for the SDK's normal analytics event logic. + /// + /// + /// All of the Record methods must return as soon as possible without waiting for events to be + /// delivered; event delivery is done asynchronously by a background task. + /// + /// + public interface IEventProcessor : IDisposable + { + /// + /// Records the action of evaluating a feature flag. + /// + /// + /// Depending on the feature flag properties and event properties, this may be transmitted to the + /// events service as an individual event, or may only be added into summary data. + /// + /// parameters for an evaluation event + void RecordEvaluationEvent(in EventProcessorTypes.EvaluationEvent e); + + /// + /// Records a set of user properties. + /// + /// parameters for an identify event + void RecordIdentifyEvent(in EventProcessorTypes.IdentifyEvent e); + + /// + /// Records a custom event. + /// + /// parameters for a custom event + void RecordCustomEvent(in EventProcessorTypes.CustomEvent e); + + /// + /// Puts the component into offline mode if appropriate. + /// + /// true if the SDK has been put offline + void SetOffline(bool offline); + + /// + /// Specifies that any buffered events should be sent as soon as possible. + /// + /// + void Flush(); + + /// + /// Delivers any pending analytics events synchronously now. + /// + /// the maximum time to wait + /// true if completed, false if timed out + /// + bool FlushAndWait(TimeSpan timeout); + + /// + /// Delivers any pending analytics events now, returning a Task that can be awaited. + /// + /// the maximum time to wait + /// a Task that resolves to true if completed, false if timed out + /// + Task FlushAndWaitAsync(TimeSpan timeout); + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/IPersistentDataStore.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/IPersistentDataStore.cs new file mode 100644 index 00000000..74cbf667 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/IPersistentDataStore.cs @@ -0,0 +1,75 @@ +using System; + +namespace LaunchDarkly.Sdk.Client.Subsystems +{ + /// + /// Interface for a data store that holds feature flag data and other SDK properties in a + /// serialized form. + /// + /// + /// + /// This interface should be used for platform-specific integrations that store data somewhere + /// other than in memory. The SDK has a default implementation which uses the native preferences + /// API on mobile platforms, and the .NET IsolatedStorage API in desktop applications. You + /// only need to use this interface if you want to provide different storage behavior. + /// + /// + /// Each data item is uniquely identified by the combination of a "namespace" and a "key", and has + /// a string value. These are defined as follows: + /// + /// + /// Both the namespace and the key are non-null and non-empty strings. + /// + /// Both the namespace and the key contain only alphanumeric characters, + /// hyphens, and underscores. + /// The namespace always starts with "LaunchDarkly". + /// The value can be any non-null string, including an empty string. + /// + /// + /// + /// Unlike server-side SDKs, the persistent data store in this SDK treats the entire set of flags + /// for a given user as a single value which is written to the store all at once, rather than one + /// value per flag. This is for two reasons: + /// + /// + /// The SDK assumes that the persistent store cannot be written to by any other process, + /// so it does not need to implement read-through behavior when getting individual flags, and can + /// read flags only from the in-memory cache. It only needs to read the persistent store at + /// startup time or when changing users, to get any last known data for all flags at once. + /// On many platforms, reading or writing multiple separate keys may be inefficient or may + /// not be possible to do atomically. + /// + /// + /// The SDK will also provide its own caching layer on top of the persistent data store; the data + /// store implementation should not provide caching, but simply do every query or update that the + /// SDK tells it to do. + /// + /// + /// Implementations do not need to worry about thread-safety; the SDK will ensure that it only + /// calls one store method at a time. + /// + /// + /// Error handling is defined as follows: if any data store operation encounters an I/O error, or + /// is otherwise unable to complete its task, it should throw an exception to make the SDK aware + /// of this. The SDK will decide whether to log the exception. + /// + /// + public interface IPersistentDataStore : IDisposable + { + /// + /// Attempts to retrieve a string value from the store. + /// + /// the namespace identifier + /// the unique key within that namespace + /// the value, or null if not found + string GetValue(string storageNamespace, string key); + + /// + /// Attempts to update or remove a string value in the store. + /// + /// the namespace identifier + /// the unique key within that namespace + /// the new value, or null to remove the key + void SetValue(string storageNamespace, string key, string value); + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/LdClientContext.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/LdClientContext.cs new file mode 100644 index 00000000..eea983d7 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/LdClientContext.cs @@ -0,0 +1,259 @@ +using System; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Client.Interfaces; +using LaunchDarkly.Sdk.Client.Internal; +using LaunchDarkly.Sdk.Client.PlatformSpecific; +using LaunchDarkly.Sdk.EnvReporting; +using LaunchDarkly.Sdk.Internal; +using LaunchDarkly.Sdk.Internal.Events; + +namespace LaunchDarkly.Sdk.Client.Subsystems +{ + /// + /// Encapsulates SDK client context when creating components. + /// + /// + /// + /// The component factory interface receives this class as a parameter. + /// Its public properties provide information about the SDK configuration and environment. The SDK + /// may also include non-public properties that are relevant only when creating one of the built-in + /// component types and are not accessible to custom components. + /// + /// + public sealed class LdClientContext + { + /// + /// The configured mobile key. + /// + public string MobileKey { get; private set; } + + /// + /// The configured logger for the SDK. + /// + public Logger BaseLogger { get; private set; } + + /// + /// The current evaluation context. + /// + public Context CurrentContext { get; private set; } + + /// + /// A component that implementations use to deliver status updates to + /// the SDK. + /// + /// + /// This property is only set when the SDK is calling an factory. + /// Otherwise it is null. + /// + public IDataSourceUpdateSink DataSourceUpdateSink { get; private set; } + + /// + /// Whether to enable feature flag updates when the application is running in the background. + /// + public bool EnableBackgroundUpdating { get; private set; } + + /// + /// True if evaluation reasons are enabled. + /// + public bool EvaluationReasons { get; private set; } + + /// + /// The HTTP configuration properties. + /// + public HttpConfiguration Http { get; private set; } + + /// + /// True if the application is currently in a background state. + /// + public bool InBackground { get; private set; } + + /// + /// The configured service base URIs. + /// + public ServiceEndpoints ServiceEndpoints { get; private set; } + + /// + /// The environment reporter. + /// + internal IEnvironmentReporter EnvironmentReporter { get; private set; } + + internal IDiagnosticDisabler DiagnosticDisabler { get; private set; } + + internal IDiagnosticStore DiagnosticStore { get; private set; } + + internal TaskExecutor TaskExecutor { get; private set; } + + /// + /// Creates an instance. + /// + /// the SDK configuration + /// the current evaluation context + /// + public LdClientContext( + Configuration configuration, + Context currentContext, + object eventSender = null + ) + { + var logger = MakeLogger(configuration); + var environmentReporter = MakeEnvironmentReporter(configuration); + + MobileKey = configuration.MobileKey; + BaseLogger = logger; + CurrentContext = currentContext; + DataSourceUpdateSink = null; + EnableBackgroundUpdating = configuration.EnableBackgroundUpdating; + EvaluationReasons = configuration.EvaluationReasons; + Http = (configuration.HttpConfigurationBuilder ?? Components.HttpConfiguration()) + .CreateHttpConfiguration(configuration.MobileKey, environmentReporter.ApplicationInfo); + InBackground = false; + ServiceEndpoints = configuration.ServiceEndpoints; + EnvironmentReporter = environmentReporter; + DiagnosticDisabler = null; + DiagnosticStore = null; + TaskExecutor = new TaskExecutor( + eventSender, + AsyncScheduler.ScheduleAction, + MakeLogger(configuration) + ); + } + + /// + /// Copy constructor + /// + /// to use as reference for copying + private LdClientContext(LdClientContext toCopy) + { + MobileKey = toCopy.MobileKey; + BaseLogger = toCopy.BaseLogger; + CurrentContext = toCopy.CurrentContext; + DataSourceUpdateSink = toCopy.DataSourceUpdateSink; + EnableBackgroundUpdating = toCopy.EnableBackgroundUpdating; + EvaluationReasons = toCopy.EvaluationReasons; + Http = toCopy.Http; + InBackground = toCopy.InBackground; + ServiceEndpoints = toCopy.ServiceEndpoints; + EnvironmentReporter = toCopy.EnvironmentReporter; + DiagnosticDisabler = toCopy.DiagnosticDisabler; + DiagnosticStore = toCopy.DiagnosticStore; + TaskExecutor = toCopy.TaskExecutor; + } + + internal LdClientContext WithLogger(Logger logger) => + new LdClientContext(this) + { + BaseLogger = logger, + }; + + internal LdClientContext WithContext(Context context) => + new LdClientContext(this) + { + CurrentContext = context, + }; + + internal LdClientContext WithBackgroundUpdatingEnabled(bool enabled) => + new LdClientContext(this) + { + EnableBackgroundUpdating = enabled, + }; + + internal LdClientContext WithEvaluationReasons(bool enabled) => + new LdClientContext(this) + { + EvaluationReasons = enabled, + }; + + internal LdClientContext WithHttpConfiguration(HttpConfiguration configuration) => + new LdClientContext(this) + { + Http = configuration, + }; + + internal LdClientContext WithInBackground(bool inBackground) => + new LdClientContext(this) + { + InBackground = inBackground, + }; + + internal LdClientContext WithServiceEndpoints(ServiceEndpoints endpoints) => + new LdClientContext(this) + { + ServiceEndpoints = endpoints, + }; + + internal LdClientContext WithEnvironmentReporter(IEnvironmentReporter reporter) => + new LdClientContext(this) + { + EnvironmentReporter = reporter, + }; + + internal LdClientContext WithTaskExecutor(TaskExecutor executor) => + new LdClientContext(this) + { + TaskExecutor = executor, + }; + + internal LdClientContext WithMobileKey(string mobileKey) => + new LdClientContext(this) + { + MobileKey = mobileKey, + }; + + internal LdClientContext WithContextAndBackgroundState(Context newCurrentContext, bool newInBackground) => + new LdClientContext(this) + { + CurrentContext = newCurrentContext, + InBackground = newInBackground + }; + + internal LdClientContext WithDataSourceUpdateSink(IDataSourceUpdateSink newDataSourceUpdateSink) => + new LdClientContext(this) + { + DataSourceUpdateSink = newDataSourceUpdateSink + }; + + internal LdClientContext WithDiagnostics( + IDiagnosticDisabler newDiagnosticDisabler, + IDiagnosticStore newDiagnosticStore + ) => + new LdClientContext(this) + { + DiagnosticDisabler = newDiagnosticDisabler, + DiagnosticStore = newDiagnosticStore + }; + + internal static Logger MakeLogger(Configuration configuration) + { + var logConfig = (configuration.LoggingConfigurationBuilder ?? Components.Logging()) + .CreateLoggingConfiguration(); + var logAdapter = logConfig.LogAdapter ?? Logs.None; + return logAdapter.Logger(logConfig.BaseLoggerName ?? LogNames.Base); + } + + internal static IEnvironmentReporter MakeEnvironmentReporter(Configuration configuration) + { + var applicationInfoBuilder = configuration.ApplicationInfo; + + var builder = new EnvironmentReporterBuilder(); + if (applicationInfoBuilder != null) + { + var applicationInfo = applicationInfoBuilder.Build(); + + // If AppInfo is provided by the user, then the Config layer has first priority in the environment reporter. + builder.SetConfigLayer(new ConfigLayerBuilder().SetAppInfo(applicationInfo).Build()); + } + + // Enable the platform layer if auto env attributes is opted in. + if (configuration.AutoEnvAttributes) + { + // The platform layer has second priority if properties aren't set by the Config layer. + builder.SetPlatformLayer(PlatformAttributes.Layer); + } + + // The SDK layer has third priority if properties aren't set by the Platform layer. + builder.SetSdkLayer(SdkAttributes.Layer); + + return builder.Build(); + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/LoggingConfiguration.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/LoggingConfiguration.cs new file mode 100644 index 00000000..b4f7f002 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/LoggingConfiguration.cs @@ -0,0 +1,37 @@ +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Client.Integrations; + +namespace LaunchDarkly.Sdk.Client.Subsystems +{ + /// + /// Encapsulates the SDK's general logging configuration. + /// + public sealed class LoggingConfiguration + { + /// + /// The configured base logger name, or null to use the default. + /// + /// + public string BaseLoggerName { get; } + + /// + /// The implementation of logging that the SDK will use. + /// + /// + public ILogAdapter LogAdapter { get; } + + /// + /// Constructs a new instance. + /// + /// value for + /// value for + public LoggingConfiguration( + string baseLoggerName, + ILogAdapter logAdapter + ) + { + BaseLoggerName = baseLoggerName; + LogAdapter = logAdapter ?? Logs.None; + } + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/PlatformAttributes.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/PlatformAttributes.cs new file mode 100644 index 00000000..ff0b43aa --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/PlatformAttributes.cs @@ -0,0 +1,21 @@ +using System.Globalization; +using LaunchDarkly.Sdk.Client.PlatformSpecific; +using LaunchDarkly.Sdk.EnvReporting; + +namespace LaunchDarkly.Sdk.Client.Subsystems +{ + internal static class PlatformAttributes + { + internal static Layer Layer => new Layer( + AppInfo.GetAppInfo(), + DeviceInfo.GetOsInfo(), + DeviceInfo.GetDeviceInfo(), + // The InvariantCulture is default if none is set by the application. Microsoft says: + // "...it is associated with the English language but not with any country/region.." + // Source: https://learn.microsoft.com/en-us/dotnet/api/system.globalization.cultureinfo.invariantculture + // In order to avoid returning an empty string (their representation of InvariantCulture) as a context attribute, + // we will return "en" instead as the closest representation. + CultureInfo.CurrentCulture.Equals(CultureInfo.InvariantCulture) ? "en" : CultureInfo.CurrentCulture.ToString() + ); + } +} diff --git a/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/SdkAttributes.cs b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/SdkAttributes.cs new file mode 100644 index 00000000..23a2d6f9 --- /dev/null +++ b/pkgs/sdk/client/src/LaunchDarkly.ClientSdk/Subsystems/SdkAttributes.cs @@ -0,0 +1,16 @@ +using System.Globalization; +using LaunchDarkly.Sdk.Client.Internal; +using LaunchDarkly.Sdk.EnvReporting; + +namespace LaunchDarkly.Sdk.Client.Subsystems +{ + internal static class SdkAttributes + { + internal static Layer Layer => new Layer(new ApplicationInfo( + SdkPackage.Name, + SdkPackage.Name, + SdkPackage.Version, + SdkPackage.Version), + null, null, null); + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/LaunchDarkly.ClientSdk.Device.Tests.csproj b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/LaunchDarkly.ClientSdk.Device.Tests.csproj new file mode 100644 index 00000000..87f92cd9 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/LaunchDarkly.ClientSdk.Device.Tests.csproj @@ -0,0 +1,78 @@ + + + + net7.0-android; net7.0-ios + $(TargetFrameworks);net7.0-windows10.0.19041.0 + + + Exe + true + true + enable + true + false + false + + + DotnetSdkTests + + + com.LaunchDarkly.ClientSdk.Device.Tests + 740c3da9-1864-4990-b6b3-d1623e8e8bec + + + 1.0 + 1 + + 11.0 + 13.1 + 21.0 + 10.0.17763.0 + 10.0.17763.0 + + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/LaunchDarkly.ClientSdk.Device.Tests.sln b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/LaunchDarkly.ClientSdk.Device.Tests.sln new file mode 100644 index 00000000..11cb37d6 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/LaunchDarkly.ClientSdk.Device.Tests.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 25.0.1706.6 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LaunchDarkly.ClientSdk.Device.Tests", "LaunchDarkly.ClientSdk.Device.Tests.csproj", "{EAD27208-B680-415B-B93C-3606CD62B302}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {EAD27208-B680-415B-B93C-3606CD62B302}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EAD27208-B680-415B-B93C-3606CD62B302}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EAD27208-B680-415B-B93C-3606CD62B302}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EAD27208-B680-415B-B93C-3606CD62B302}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {6A3D7A01-9533-4035-ADA4-76CA0C14DADC} + EndGlobalSection +EndGlobal diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/LdClientContextTests.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/LdClientContextTests.cs new file mode 100644 index 00000000..c7add35b --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/LdClientContextTests.cs @@ -0,0 +1,56 @@ +using Xunit; + +namespace LaunchDarkly.Sdk.Client.Subsystems +{ + /// + /// These tests are for the . Since some of the behavior is platform dependent + /// and the .NET Standard does not support some platform capabilities we want to test, this test class is + /// in the Android tests package. + /// + public class LdClientContextTests + { + [Fact] + public void TestMakeEnvironmentReporterUsesApplicationInfoWhenSet() + { + var configuration = Configuration.Builder("aKey", ConfigurationBuilder.AutoEnvAttributes.Disabled) + .ApplicationInfo( + Components.ApplicationInfo().ApplicationId("mockId").ApplicationName("mockName") + ).Build(); + + var output = LdClientContext.MakeEnvironmentReporter(configuration); + Assert.Equal("mockId", output.ApplicationInfo?.ApplicationId); + } + + [Fact] + public void TestMakeEnvironmentReporterDefaultsToSdkLayerWhenNothingSet() + { + var configuration = Configuration.Builder("aKey", ConfigurationBuilder.AutoEnvAttributes.Disabled) + .Build(); + + var output = LdClientContext.MakeEnvironmentReporter(configuration); + Assert.Equal(SdkAttributes.Layer.ApplicationInfo?.ApplicationId, output.ApplicationInfo?.ApplicationId); + } + + [Fact] + public void TestMakeEnvironmentReporterUsesPlatformLayerWhenAutoEnvEnabled() + { + var configuration = Configuration.Builder("aKey", ConfigurationBuilder.AutoEnvAttributes.Enabled) + .Build(); + + var output = LdClientContext.MakeEnvironmentReporter(configuration); + Assert.NotEqual(SdkAttributes.Layer.ApplicationInfo?.ApplicationId, output.ApplicationInfo?.ApplicationId); + } + + [Fact] + public void TestMakeEnvironmentReporterUsesApplicationInfoWhenSetAndAutoEnvEnabled() + { + var configuration = Configuration.Builder("aKey", ConfigurationBuilder.AutoEnvAttributes.Enabled) + .ApplicationInfo( + Components.ApplicationInfo().ApplicationId("mockId").ApplicationName("mockName") + ).Build(); + + var output = LdClientContext.MakeEnvironmentReporter(configuration); + Assert.Equal("mockId", output.ApplicationInfo?.ApplicationId); + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/MauiProgram.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/MauiProgram.cs new file mode 100644 index 00000000..037d6f25 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/MauiProgram.cs @@ -0,0 +1,20 @@ +using Xunit.Runners.Maui; + +namespace LaunchDarkly.ClientSdk.Device.Tests +{ + public static class MauiProgram + { + public static MauiApp CreateMauiApp() => + MauiApp + .CreateBuilder() + .ConfigureTests(new TestOptions() + { + Assemblies = + { + typeof(MauiProgram).Assembly + } + }) + .UseVisualRunner() + .Build(); + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Android/AndroidManifest.xml b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Android/AndroidManifest.xml new file mode 100644 index 00000000..13f0305f --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Android/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Android/AndroidSpecificTests.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Android/AndroidSpecificTests.cs new file mode 100644 index 00000000..929fefb4 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Android/AndroidSpecificTests.cs @@ -0,0 +1,34 @@ +using System.Threading; +using LaunchDarkly.Sdk.Client.Integrations; +using LaunchDarkly.TestHelpers; +using Android.OS; +using Xunit; + +namespace LaunchDarkly.Sdk.Client.Android.Tests +{ + public class AndroidSpecificTests : BaseTest + { + [Fact] + public void EventHandlerIsCalledOnUIThread() + { + var td = TestData.DataSource(); + var config = BasicConfig().DataSource(td).Build(); + + var captureMainThread = new EventSink(); + new Handler(Looper.MainLooper).Post(() => captureMainThread.Enqueue(Thread.CurrentThread)); + var mainThread = captureMainThread.ExpectValue(); + + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + var receivedOnThread = new EventSink(); + client.FlagTracker.FlagValueChanged += (sender, args) => + receivedOnThread.Enqueue(Thread.CurrentThread); + + td.Update(td.Flag("flagkey").Variation(true)); + + var t = receivedOnThread.ExpectValue(); + Assert.Equal(mainThread, t); + } + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Android/MainActivity.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Android/MainActivity.cs new file mode 100644 index 00000000..73fa9e1c --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Android/MainActivity.cs @@ -0,0 +1,11 @@ +using Android.App; +using Android.Content.PM; +using Android.OS; + +namespace LaunchDarkly.ClientSdk.Device.Tests +{ + [Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)] + public class MainActivity : MauiAppCompatActivity + { + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Android/MainApplication.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Android/MainApplication.cs new file mode 100644 index 00000000..f2dc99f2 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Android/MainApplication.cs @@ -0,0 +1,16 @@ +using Android.App; +using Android.Runtime; + +namespace LaunchDarkly.ClientSdk.Device.Tests +{ + [Application] + public class MainApplication : MauiApplication + { + public MainApplication(IntPtr handle, JniHandleOwnership ownership) + : base(handle, ownership) + { + } + + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Android/Resources/values/colors.xml b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Android/Resources/values/colors.xml new file mode 100644 index 00000000..c04d7492 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Android/Resources/values/colors.xml @@ -0,0 +1,6 @@ + + + #512BD4 + #2B0B98 + #2B0B98 + \ No newline at end of file diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/MacCatalyst/AppDelegate.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/MacCatalyst/AppDelegate.cs new file mode 100644 index 00000000..1dab09ca --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/MacCatalyst/AppDelegate.cs @@ -0,0 +1,10 @@ +using Foundation; + +namespace LaunchDarkly.ClientSdk.Device.Tests +{ + [Register("AppDelegate")] + public class AppDelegate : MauiUIApplicationDelegate + { + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/MacCatalyst/Info.plist b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/MacCatalyst/Info.plist new file mode 100644 index 00000000..c96dd0a2 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/MacCatalyst/Info.plist @@ -0,0 +1,30 @@ + + + + + UIDeviceFamily + + 1 + 2 + + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + XSAppIconAssets + Assets.xcassets/appicon.appiconset + + diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/MacCatalyst/Program.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/MacCatalyst/Program.cs new file mode 100644 index 00000000..da61b2c4 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/MacCatalyst/Program.cs @@ -0,0 +1,16 @@ +using ObjCRuntime; +using UIKit; + +namespace LaunchDarkly.ClientSdk.Device.Tests +{ + public class Program + { + // This is the main entry point of the application. + static void Main(string[] args) + { + // if you want to use a different Application Delegate class from "AppDelegate" + // you can specify it here. + UIApplication.Main(args, null, typeof(AppDelegate)); + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Tizen/Main.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Tizen/Main.cs new file mode 100644 index 00000000..6cc67e43 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Tizen/Main.cs @@ -0,0 +1,17 @@ +using Microsoft.Maui; +using Microsoft.Maui.Hosting; +using System; + +namespace LaunchDarkly.ClientSdk.Device.Tests +{ + internal class Program : MauiApplication + { + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); + + static void Main(string[] args) + { + var app = new Program(); + app.Run(args); + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Tizen/tizen-manifest.xml b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Tizen/tizen-manifest.xml new file mode 100644 index 00000000..96861cca --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Tizen/tizen-manifest.xml @@ -0,0 +1,15 @@ + + + + + + maui-appicon-placeholder + + + + + http://tizen.org/privilege/internet + + + + \ No newline at end of file diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Windows/App.xaml b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Windows/App.xaml new file mode 100644 index 00000000..b9e5fd04 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Windows/App.xaml @@ -0,0 +1,8 @@ + + + diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Windows/App.xaml.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Windows/App.xaml.cs new file mode 100644 index 00000000..12542959 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Windows/App.xaml.cs @@ -0,0 +1,25 @@ +using Microsoft.UI.Xaml; + +// To learn more about WinUI, the WinUI project structure, +// and more about our project templates, see: http://aka.ms/winui-project-info. + +namespace namespace LaunchDarkly.ClientSdk.Device.Tests.WinUI +{ + /// + /// Provides application-specific behavior to supplement the default Application class. + /// + public partial class App : MauiWinUIApplication + { + /// + /// Initializes the singleton application object. This is the first line of authored code + /// executed, and as such is the logical equivalent of main() or WinMain(). + /// + public App() + { + this.InitializeComponent(); + } + + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); + } + +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Windows/Package.appxmanifest b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Windows/Package.appxmanifest new file mode 100644 index 00000000..7d04bbcb --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Windows/Package.appxmanifest @@ -0,0 +1,46 @@ + + + + + + + + + $placeholder$ + User Name + $placeholder$.png + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Windows/app.manifest b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Windows/app.manifest new file mode 100644 index 00000000..212ec52b --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/Windows/app.manifest @@ -0,0 +1,15 @@ + + + + + + + + true/PM + PerMonitorV2, PerMonitor + + + diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/iOS/AppDelegate.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/iOS/AppDelegate.cs new file mode 100644 index 00000000..1dab09ca --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/iOS/AppDelegate.cs @@ -0,0 +1,10 @@ +using Foundation; + +namespace LaunchDarkly.ClientSdk.Device.Tests +{ + [Register("AppDelegate")] + public class AppDelegate : MauiUIApplicationDelegate + { + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/iOS/IOsSpecificTests.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/iOS/IOsSpecificTests.cs new file mode 100644 index 00000000..03cede0c --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/iOS/IOsSpecificTests.cs @@ -0,0 +1,34 @@ +using System.Threading; +using LaunchDarkly.Sdk.Client.Integrations; +using LaunchDarkly.TestHelpers; +using Foundation; +using Xunit; + +namespace LaunchDarkly.Sdk.Client.iOS.Tests +{ + public class IOsSpecificTests : BaseTest + { + [Fact] + public void EventHandlerIsCalledOnUIThread() + { + var td = TestData.DataSource(); + var config = BasicConfig().DataSource(td).Build(); + + var captureMainThread = new EventSink(); + NSRunLoop.Main.BeginInvokeOnMainThread(() => captureMainThread.Enqueue(Thread.CurrentThread)); + var mainThread = captureMainThread.ExpectValue(); + + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + var receivedOnThread = new EventSink(); + client.FlagTracker.FlagValueChanged += (sender, args) => + receivedOnThread.Enqueue(Thread.CurrentThread); + + td.Update(td.Flag("flagkey").Variation(true)); + + var t = receivedOnThread.ExpectValue(); + Assert.Equal(mainThread, t); + } + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/iOS/Info.plist b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/iOS/Info.plist new file mode 100644 index 00000000..0004a4fd --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/iOS/Info.plist @@ -0,0 +1,32 @@ + + + + + LSRequiresIPhoneOS + + UIDeviceFamily + + 1 + 2 + + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + XSAppIconAssets + Assets.xcassets/appicon.appiconset + + diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/iOS/Program.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/iOS/Program.cs new file mode 100644 index 00000000..da61b2c4 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Platforms/iOS/Program.cs @@ -0,0 +1,16 @@ +using ObjCRuntime; +using UIKit; + +namespace LaunchDarkly.ClientSdk.Device.Tests +{ + public class Program + { + // This is the main entry point of the application. + static void Main(string[] args) + { + // if you want to use a different Application Delegate class from "AppDelegate" + // you can specify it here. + UIApplication.Main(args, null, typeof(AppDelegate)); + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/AppIcon/appicon.svg b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/AppIcon/appicon.svg new file mode 100644 index 00000000..9d63b651 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/AppIcon/appicon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/AppIcon/appiconfg.svg b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/AppIcon/appiconfg.svg new file mode 100644 index 00000000..21dfb25f --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/AppIcon/appiconfg.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/Fonts/OpenSans-Regular.ttf b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/Fonts/OpenSans-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..dadcb015d492fac2cfdd868689bf26ba19e75309 GIT binary patch literal 107168 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�u>gYo2#{i|Un2TZ=vdP|Rt&?MLJNpIhdUHE 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

_h+WLdolH)SU+J!dJi%kN41dyIj(P)fDrP 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 ZhJ?r43db^pa@tc**7&QYm&m`y{U4PhDpUXf literal 0 HcmV?d00001 diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/Images/dotnet_bot.svg b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/Images/dotnet_bot.svg new file mode 100644 index 00000000..abfaff26 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/Images/dotnet_bot.svg @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/Raw/AboutAssets.txt b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/Raw/AboutAssets.txt new file mode 100644 index 00000000..15d62448 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/Raw/AboutAssets.txt @@ -0,0 +1,15 @@ +Any raw assets you want to be deployed with your application can be placed in +this directory (and child directories). Deployment of the asset to your application +is automatically handled by the following `MauiAsset` Build Action within your `.csproj`. + + + +These files will be deployed with you package and will be accessible using Essentials: + + async Task LoadMauiAsset() + { + using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt"); + using var reader = new StreamReader(stream); + + var contents = reader.ReadToEnd(); + } diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/Splash/splash.svg b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/Splash/splash.svg new file mode 100644 index 00000000..21dfb25f --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/Splash/splash.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/Styles/Colors.xaml b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/Styles/Colors.xaml new file mode 100644 index 00000000..245758ba --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/Styles/Colors.xaml @@ -0,0 +1,44 @@ + + + + + #512BD4 + #DFD8F7 + #2B0B98 + White + Black + #E1E1E1 + #C8C8C8 + #ACACAC + #919191 + #6E6E6E + #404040 + #212121 + #141414 + + + + + + + + + + + + + + + #F7B548 + #FFD590 + #FFE5B9 + #28C2D1 + #7BDDEF + #C3F2F4 + #3E8EED + #72ACF1 + #A7CBF6 + + \ No newline at end of file diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/Styles/Styles.xaml b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/Styles/Styles.xaml new file mode 100644 index 00000000..dc4a0347 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Device.Tests/Resources/Styles/Styles.xaml @@ -0,0 +1,405 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/AssertHelpers.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/AssertHelpers.cs new file mode 100644 index 00000000..5f0cb129 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/AssertHelpers.cs @@ -0,0 +1,52 @@ +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Json; +using Xunit; +using Xunit.Sdk; + +using static LaunchDarkly.Sdk.Client.Subsystems.DataStoreTypes; +using static LaunchDarkly.TestHelpers.JsonAssertions; + +namespace LaunchDarkly.Sdk.Client +{ + public class AssertHelpers + { + public static void DataSetsEqual(FullDataSet expected, FullDataSet actual) => + AssertJsonEqual(expected.ToJsonString(), actual.ToJsonString()); + + public static void DataItemsEqual(ItemDescriptor expected, ItemDescriptor actual) + { + AssertJsonEqual(expected.Item is null ? null : expected.Item.ToJsonString(), + actual.Item is null ? null : actual.Item.ToJsonString()); + Assert.Equal(expected.Version, actual.Version); + } + + public static void ContextsEqual(Context expected, Context actual) => + AssertJsonEqual(LdJsonSerialization.SerializeObject(expected), + LdJsonSerialization.SerializeObject(actual)); + + public static void LogMessageRegex(LogCapture logCapture, bool shouldHave, LogLevel level, string pattern) + { + if (logCapture.HasMessageWithRegex(level, pattern) != shouldHave) + { + ThrowLogMatchException(logCapture, shouldHave, level, pattern, true); + } + } + + public static void LogMessageText(LogCapture logCapture, bool shouldHave, LogLevel level, string text) + { + if (logCapture.HasMessageWithText(level, text) != shouldHave) + { + ThrowLogMatchException(logCapture, shouldHave, level, text, true); + } + } + + private static void ThrowLogMatchException(LogCapture logCapture, bool shouldHave, LogLevel level, string text, bool isRegex) => + throw new AssertActualExpectedException(shouldHave, !shouldHave, + string.Format("Expected log {0} the {1} \"{2}\" at level {3}\n\nActual log output follows:\n{4}", + shouldHave ? "to have" : "not to have", + isRegex ? "pattern" : "exact message", + text, + level, + logCapture.ToString())); + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/BaseTest.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/BaseTest.cs new file mode 100644 index 00000000..a0a03822 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/BaseTest.cs @@ -0,0 +1,56 @@ +using System; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Client.Integrations; +using LaunchDarkly.Sdk.Client.Subsystems; +using LaunchDarkly.Sdk.Internal; +using Xunit; +using Xunit.Abstractions; + +namespace LaunchDarkly.Sdk.Client +{ + [Collection("serialize all tests")] + public class BaseTest : IDisposable + { + protected const string BasicMobileKey = "mobile-key"; + protected static readonly Context BasicUser = Context.New("user-key"); + + protected readonly LoggingConfigurationBuilder testLogging; + protected readonly Logger testLogger; + protected readonly LogCapture logCapture = Logs.Capture(); + protected readonly TaskExecutor BasicTaskExecutor; + + public BaseTest() : this(capture => capture) { } + + public BaseTest(ITestOutputHelper testOutput) : + this(capture => Logs.ToMultiple(TestLogging.TestOutputAdapter(testOutput), capture)) + { } + + protected BaseTest(Func adapterFn) + { + var adapter = adapterFn(logCapture); + testLogger = adapter.Level(LogLevel.Debug).Logger(""); + testLogging = Components.Logging(adapter).Level(LogLevel.Debug); + BasicTaskExecutor = new TaskExecutor("test-sender", testLogger); + } + + public void Dispose() + { + TestUtil.ClearClient(); + } + + // Returns a ConfigurationBuilder with no external data source, events disabled, and logging redirected + // to the test output. Using this as a base configuration for tests, and then overriding properties as + // needed, protects against accidental interaction with external services and also makes it easier to + // see which properties are important in a test. + protected ConfigurationBuilder BasicConfig() => + Configuration.Builder(BasicMobileKey, ConfigurationBuilder.AutoEnvAttributes.Disabled) + .BackgroundModeManager(new MockBackgroundModeManager()) + .ConnectivityStateManager(new MockConnectivityStateManager(true)) + .DataSource(new MockDataSource().AsSingletonFactory()) + .Events(Components.NoEvents) + .Logging(testLogging) + .Persistence( + Components.Persistence().Storage(new MockPersistentDataStore().AsSingletonFactory()) + ); + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/ConfigurationTest.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/ConfigurationTest.cs new file mode 100644 index 00000000..beae849e --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/ConfigurationTest.cs @@ -0,0 +1,148 @@ +using System; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Client.Internal; +using LaunchDarkly.TestHelpers; +using Xunit; +using Xunit.Abstractions; + +namespace LaunchDarkly.Sdk.Client +{ + public class ConfigurationTest : BaseTest + { + private readonly BuilderBehavior.BuildTester _tester = + BuilderBehavior.For(() => Configuration.Builder(mobileKey, ConfigurationBuilder.AutoEnvAttributes.Disabled), + b => b.Build()) + .WithCopyConstructor(c => Configuration.Builder(c)); + + const string mobileKey = "any-key"; + + public ConfigurationTest(ITestOutputHelper testOutput) : base(testOutput) + { + } + + [Fact] + public void DefaultSetsKey() + { + var config = Configuration.Default(mobileKey, ConfigurationBuilder.AutoEnvAttributes.Disabled); + Assert.Equal(mobileKey, config.MobileKey); + } + + [Fact] + public void BuilderSetsKey() + { + var config = Configuration.Builder(mobileKey, ConfigurationBuilder.AutoEnvAttributes.Disabled).Build(); + Assert.Equal(mobileKey, config.MobileKey); + } + + [Fact] + public void ApplicationInfo() + { + var prop = _tester.Property(c => c.ApplicationInfo, (b, v) => b.ApplicationInfo(v)); + prop.AssertDefault(null); + prop.AssertCanSet(new ApplicationInfoBuilder()); + } + + [Fact] + public void DataSource() + { + var prop = _tester.Property(c => c.DataSource, (b, v) => b.DataSource(v)); + prop.AssertDefault(null); + prop.AssertCanSet(new ComponentsImpl.NullDataSourceFactory()); + } + + [Fact] + public void DiagnosticOptOut() + { + var prop = _tester.Property(c => c.DiagnosticOptOut, (b, v) => b.DiagnosticOptOut(v)); + prop.AssertDefault(false); + prop.AssertCanSet(true); + } + + [Fact] + public void EnableBackgroundUpdating() + { + var prop = _tester.Property(c => c.EnableBackgroundUpdating, (b, v) => b.EnableBackgroundUpdating(v)); + prop.AssertDefault(true); + prop.AssertCanSet(false); + } + + [Fact] + public void EvaluationReasons() + { + var prop = _tester.Property(c => c.EvaluationReasons, (b, v) => b.EvaluationReasons(v)); + prop.AssertDefault(false); + prop.AssertCanSet(true); + } + + [Fact] + public void Events() + { + var prop = _tester.Property(c => c.Events, (b, v) => b.Events(v)); + prop.AssertDefault(null); + prop.AssertCanSet(new ComponentsImpl.NullEventProcessorFactory()); + } + + [Fact] + public void Http() + { + var prop = _tester.Property(c => c.HttpConfigurationBuilder, (b, v) => b.Http(v)); + prop.AssertDefault(null); + prop.AssertCanSet(Components.HttpConfiguration()); + } + + [Fact] + public void Logging() + { + var prop = _tester.Property(c => c.LoggingConfigurationBuilder, (b, v) => b.Logging(v)); + prop.AssertDefault(null); + prop.AssertCanSet(Components.Logging(Logs.ToWriter(Console.Out))); + } + + [Fact] + public void LoggingAdapterShortcut() + { + var adapter = Logs.ToWriter(Console.Out); + var config = Configuration.Builder("key", ConfigurationBuilder.AutoEnvAttributes.Disabled).Logging(adapter) + .Build(); + var logConfig = config.LoggingConfigurationBuilder.CreateLoggingConfiguration(); + Assert.Same(adapter, logConfig.LogAdapter); + } + + [Fact] + public void MobileKey() + { + var prop = _tester.Property(c => c.MobileKey, (b, v) => b.MobileKey(v)); + prop.AssertCanSet("other-key"); + } + + [Fact] + public void Offline() + { + var prop = _tester.Property(c => c.Offline, (b, v) => b.Offline(v)); + prop.AssertDefault(false); + prop.AssertCanSet(true); + } + + [Fact] + public void Persistence() + { + var prop = _tester.Property(c => c.PersistenceConfigurationBuilder, (b, v) => b.Persistence(v)); + prop.AssertDefault(null); + prop.AssertCanSet(Components.Persistence().MaxCachedContexts(2)); + } + + [Fact] + public void MobileKeyCannotBeNull() + { + Assert.Throws(() => + Configuration.Default(null, ConfigurationBuilder.AutoEnvAttributes.Disabled)); + } + + [Fact] + public void MobileKeyCannotBeEmpty() + { + Assert.Throws(() => + Configuration.Default("", ConfigurationBuilder.AutoEnvAttributes.Disabled)); + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/ILdClientExtensionsTest.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/ILdClientExtensionsTest.cs new file mode 100644 index 00000000..dafeb5a7 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/ILdClientExtensionsTest.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using LaunchDarkly.Sdk.Client.Interfaces; +using Xunit; + +namespace LaunchDarkly.Sdk.Client +{ + public class ILdClientExtensionsTest + { + enum MyEnum + { + Red, + Green, + Blue + }; + + [Fact] + public void EnumVariationConvertsStringToEnum() + { + var client = new MockStringVariationClient(); + client.SetupStringVariation("key", "Blue", "Green"); + + var result = client.EnumVariation("key", MyEnum.Blue); + Assert.Equal(MyEnum.Green, result); + } + + [Fact] + public void EnumVariationReturnsDefaultValueForInvalidFlagValue() + { + var client = new MockStringVariationClient(); + client.SetupStringVariation("key", "Blue", "not-a-color"); + + var defaultValue = MyEnum.Blue; + var result = client.EnumVariation("key", defaultValue); + Assert.Equal(MyEnum.Blue, defaultValue); + } + + [Fact] + public void EnumVariationReturnsDefaultValueForNullFlagValue() + { + var client = new MockStringVariationClient(); + client.SetupStringVariation("key", "Blue", null); + + var defaultValue = MyEnum.Blue; + var result = client.EnumVariation("key", defaultValue); + Assert.Equal(defaultValue, result); + } + + [Fact] + public void EnumVariationDetailConvertsStringToEnum() + { + var client = new MockStringVariationClient(); + client.SetupStringVariationDetail("key", "Blue", + new EvaluationDetail("Green", 1, EvaluationReason.FallthroughReason)); + + var result = client.EnumVariationDetail("key", MyEnum.Blue); + var expected = new EvaluationDetail(MyEnum.Green, 1, EvaluationReason.FallthroughReason); + Assert.Equal(expected, result); + } + + [Fact] + public void EnumVariationDetailReturnsDefaultValueForInvalidFlagValue() + { + var client = new MockStringVariationClient(); + client.SetupStringVariationDetail("key", "Blue", + new EvaluationDetail("not-a-color", 1, EvaluationReason.FallthroughReason)); + + var result = client.EnumVariationDetail("key", MyEnum.Blue); + var expected = new EvaluationDetail(MyEnum.Blue, 1, EvaluationReason.ErrorReason(EvaluationErrorKind.WrongType)); + Assert.Equal(expected, result); + } + + [Fact] + public void EnumVariationDetailReturnsDefaultValueForNullFlagValue() + { + var client = new MockStringVariationClient(); + client.SetupStringVariationDetail("key", "Blue", + new EvaluationDetail(null, 1, EvaluationReason.FallthroughReason)); + + var result = client.EnumVariationDetail("key", MyEnum.Blue); + var expected = new EvaluationDetail(MyEnum.Blue, 1, EvaluationReason.FallthroughReason); + Assert.Equal(expected, result); + } + + private sealed class MockStringVariationClient : ILdClient + { + private Func _stringVariationFn; + private Func> _stringVariationDetailFn; + + public void SetupStringVariation(string expectedKey, string expectedDefault, string result) + { + _stringVariationFn = (key, defaultValue) => + { + Assert.Equal(expectedKey, key); + Assert.Equal(expectedDefault, defaultValue); + return result; + }; + } + + public void SetupStringVariationDetail(string expectedKey, string expectedDefault, EvaluationDetail result) + { + _stringVariationDetailFn = (key, defaultValue) => + { + Assert.Equal(expectedKey, key); + Assert.Equal(expectedDefault, defaultValue); + return result; + }; + } + + public string StringVariation(string key, string defaultValue) => + _stringVariationFn(key, defaultValue); + + public EvaluationDetail StringVariationDetail(string key, string defaultValue) => + _stringVariationDetailFn(key, defaultValue); + + // Other methods aren't relevant to these tests + + public bool Initialized => true; + public bool Offline => false; + public IDataSourceStatusProvider DataSourceStatusProvider => null; + public IFlagTracker FlagTracker => null; + + public IDictionary AllFlags() => + throw new System.NotImplementedException(); + + public bool BoolVariation(string key, bool defaultValue = false) => + throw new System.NotImplementedException(); + + public EvaluationDetail BoolVariationDetail(string key, bool defaultValue = false) => + throw new System.NotImplementedException(); + + public void Dispose() { } + + public float FloatVariation(string key, float defaultValue = 0) => + throw new System.NotImplementedException(); + + public EvaluationDetail FloatVariationDetail(string key, float defaultValue = 0) => + throw new System.NotImplementedException(); + + public double DoubleVariation(string key, double defaultValue = 0) => + throw new System.NotImplementedException(); + + public EvaluationDetail DoubleVariationDetail(string key, double defaultValue = 0) => + throw new System.NotImplementedException(); + + public void Flush() { } + + public bool FlushAndWait(TimeSpan timeout) => true; + + public Task FlushAndWaitAsync(TimeSpan timeout) => Task.FromResult(true); + + public bool Identify(Context context, System.TimeSpan maxWaitTime) => + throw new System.NotImplementedException(); + + public Task IdentifyAsync(Context context) => + throw new System.NotImplementedException(); + + public int IntVariation(string key, int defaultValue = 0) => + throw new System.NotImplementedException(); + + public EvaluationDetail IntVariationDetail(string key, int defaultValue = 0) => + throw new System.NotImplementedException(); + + public LdValue JsonVariation(string key, LdValue defaultValue) => + throw new System.NotImplementedException(); + + public EvaluationDetail JsonVariationDetail(string key, LdValue defaultValue) => + throw new System.NotImplementedException(); + + public bool SetOffline(bool value, System.TimeSpan maxWaitTime) => + throw new System.NotImplementedException(); + + public Task SetOfflineAsync(bool value) => + throw new System.NotImplementedException(); + + public void Track(string eventName) => + throw new System.NotImplementedException(); + + public void Track(string eventName, LdValue data) => + throw new System.NotImplementedException(); + + public void Track(string eventName, LdValue data, double metricValue) => + throw new System.NotImplementedException(); + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/EventProcessorBuilderTest.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/EventProcessorBuilderTest.cs new file mode 100644 index 00000000..f9a8e857 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/EventProcessorBuilderTest.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using LaunchDarkly.TestHelpers; +using Xunit; +using Xunit.Abstractions; + +namespace LaunchDarkly.Sdk.Client.Integrations +{ + public class EventProcessorBuilderTest : BaseTest + { + private readonly BuilderBehavior.InternalStateTester _tester = + BuilderBehavior.For(Components.SendEvents); + + public EventProcessorBuilderTest(ITestOutputHelper testOutput) : base(testOutput) { } + + [Fact] + public void AllAttributesPrivate() + { + var prop = _tester.Property(b => b._allAttributesPrivate, (b, v) => b.AllAttributesPrivate(v)); + prop.AssertDefault(false); + prop.AssertCanSet(true); + } + + [Fact] + public void DiagnosticRecordingInterval() + { + var prop = _tester.Property(b => b._diagnosticRecordingInterval, (b, v) => b.DiagnosticRecordingInterval(v)); + prop.AssertDefault(EventProcessorBuilder.DefaultDiagnosticRecordingInterval); + prop.AssertCanSet(TimeSpan.FromMinutes(7)); + prop.AssertSetIsChangedTo(TimeSpan.FromMinutes(4), EventProcessorBuilder.MinimumDiagnosticRecordingInterval); + prop.AssertSetIsChangedTo(TimeSpan.FromMilliseconds(-1), EventProcessorBuilder.MinimumDiagnosticRecordingInterval); + } + + [Fact] + public void EventCapacity() + { + var prop = _tester.Property(b => b._capacity, (b, v) => b.Capacity(v)); + prop.AssertDefault(EventProcessorBuilder.DefaultCapacity); + prop.AssertCanSet(1); + prop.AssertSetIsChangedTo(0, EventProcessorBuilder.DefaultCapacity); + prop.AssertSetIsChangedTo(-1, EventProcessorBuilder.DefaultCapacity); + } + + [Fact] + public void FlushInterval() + { + var prop = _tester.Property(b => b._flushInterval, (b, v) => b.FlushInterval(v)); + prop.AssertDefault(EventProcessorBuilder.DefaultFlushInterval); + prop.AssertCanSet(TimeSpan.FromMinutes(7)); + prop.AssertSetIsChangedTo(TimeSpan.Zero, EventProcessorBuilder.DefaultFlushInterval); + prop.AssertSetIsChangedTo(TimeSpan.FromMilliseconds(-1), EventProcessorBuilder.DefaultFlushInterval); + } + + [Fact] + public void PrivateAttributes() + { + var b = _tester.New(); + Assert.Empty(b._privateAttributes); + b.PrivateAttributes("name"); + b.PrivateAttributes("/address/street", "other"); + Assert.Equal( + new HashSet + { + AttributeRef.FromPath("name"), AttributeRef.FromPath("/address/street"), AttributeRef.FromPath("other") + }, + b._privateAttributes); + } + } +} \ No newline at end of file diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/HttpConfigurationBuilderTest.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/HttpConfigurationBuilderTest.cs new file mode 100644 index 00000000..48a11034 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/HttpConfigurationBuilderTest.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using LaunchDarkly.Sdk.Internal; +using LaunchDarkly.Sdk.Client.Subsystems; +using LaunchDarkly.Logging; +using LaunchDarkly.TestHelpers; +using Xunit; + +namespace LaunchDarkly.Sdk.Client.Integrations +{ + public class HttpConfigurationBuilderTest + { + private static string mobileKey = "mobile-key"; + private static ApplicationInfo applicationInfo => new ApplicationInfo("mockId", "mockName", "mockVersion", "mockVersionName"); + + private readonly BuilderBehavior.BuildTester _tester = + BuilderBehavior.For(() => Components.HttpConfiguration(), + b => b.CreateHttpConfiguration(mobileKey, applicationInfo)); + + [Fact] + public void ConnectTimeout() + { + var prop = _tester.Property(c => c.ConnectTimeout, (b, v) => b.ConnectTimeout(v)); + prop.AssertDefault(HttpConfigurationBuilder.DefaultConnectTimeout); + prop.AssertCanSet(TimeSpan.FromSeconds(7)); + } + + [Fact] + public void CustomHeaders() + { + var config = Components.HttpConfiguration() + .CustomHeader("header1", "value1") + .CustomHeader("header2", "value2") + .CreateHttpConfiguration(mobileKey, applicationInfo); + Assert.Equal("value1", HeadersAsMap(config.DefaultHeaders)["header1"]); + Assert.Equal("value2", HeadersAsMap(config.DefaultHeaders)["header2"]); + } + + [Fact] + public void MessageHandler() + { + var prop = _tester.Property(c => c.MessageHandler, (b, v) => b.MessageHandler(v)); + // Can't test the default here because the default is platform-dependent. + prop.AssertCanSet(new HttpClientHandler()); + } + + [Fact] + public void MobileKeyHeader() + { + var config = Components.HttpConfiguration().CreateHttpConfiguration(mobileKey, applicationInfo); + Assert.Equal(mobileKey, HeadersAsMap(config.DefaultHeaders)["authorization"]); + } + + [Fact] + public void ResponseStartTimeout() + { + var value = TimeSpan.FromMilliseconds(789); + var prop = _tester.Property(c => c.ResponseStartTimeout, (b, v) => b.ResponseStartTimeout(v)); + prop.AssertDefault(HttpConfigurationBuilder.DefaultResponseStartTimeout); + prop.AssertCanSet(value); + + var config = Components.HttpConfiguration().ResponseStartTimeout(value) + .CreateHttpConfiguration(mobileKey, applicationInfo); + using (var client = config.NewHttpClient()) + { + Assert.Equal(value, client.Timeout); + } + } + + [Fact] + public void UseReport() + { + var prop = _tester.Property(c => c.UseReport, (b, v) => b.UseReport(v)); + prop.AssertDefault(false); + prop.AssertCanSet(true); + } + + [Fact] + public void UserAgentHeader() + { + var config = Components.HttpConfiguration().CreateHttpConfiguration(mobileKey, applicationInfo); + Assert.Equal("DotnetClientSide/" + AssemblyVersions.GetAssemblyVersionStringForType(typeof(LdClient)), + HeadersAsMap(config.DefaultHeaders)["user-agent"]); // not configurable + } + + [Fact] + public void WrapperDefaultNone() + { + var config = Components.HttpConfiguration().CreateHttpConfiguration(mobileKey, applicationInfo); + Assert.False(HeadersAsMap(config.DefaultHeaders).ContainsKey("x-launchdarkly-wrapper")); + } + + [Fact] + public void WrapperNameOnly() + { + var config = Components.HttpConfiguration().Wrapper("w", null) + .CreateHttpConfiguration(mobileKey, applicationInfo); + Assert.Equal("w", HeadersAsMap(config.DefaultHeaders)["x-launchdarkly-wrapper"]); + } + + [Fact] + public void WrapperNameAndVersion() + { + var config = Components.HttpConfiguration().Wrapper("w", "1.0") + .CreateHttpConfiguration(mobileKey, applicationInfo); + Assert.Equal("w/1.0", HeadersAsMap(config.DefaultHeaders)["x-launchdarkly-wrapper"]); + } + + [Fact] + public void ApplicationTagsHeader() + { + var config = Components.HttpConfiguration().CreateHttpConfiguration(mobileKey, applicationInfo); + Assert.Equal("application-id/mockId application-name/mockName application-version/mockVersion application-version-name/mockVersionName", + HeadersAsMap(config.DefaultHeaders)["x-launchdarkly-tags"]); + } + + private static Dictionary HeadersAsMap(IEnumerable> headers) + { + return headers.ToDictionary(kv => kv.Key.ToLower(), kv => kv.Value); + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/LoggingConfigurationBuilderTest.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/LoggingConfigurationBuilderTest.cs new file mode 100644 index 00000000..9f92a9a1 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/LoggingConfigurationBuilderTest.cs @@ -0,0 +1,74 @@ +using LaunchDarkly.Logging; +using Xunit; + +namespace LaunchDarkly.Sdk.Client.Integrations +{ + public class LoggingConfigurationBuilderTest + { + [Fact] + public void HasNonNullDefaultLogAdapter() + { + var logConfig = Components.Logging().CreateLoggingConfiguration(); + Assert.NotNull(logConfig.LogAdapter); + } + + [Fact] + public void CanSpecifyAdapter() + { + var adapter = Logs.ToMultiple(Logs.None); + + var logConfig1 = Components.Logging() + .Adapter(adapter) + .CreateLoggingConfiguration(); + Assert.Same(adapter, logConfig1.LogAdapter); + + var logConfig2 = Components.Logging(adapter) + .CreateLoggingConfiguration(); + Assert.Same(adapter, logConfig2.LogAdapter); + } + + [Fact] + public void CanSpecifyBaseLoggerName() + { + var logConfig1 = Components.Logging().CreateLoggingConfiguration(); + Assert.Null(logConfig1.BaseLoggerName); + + var logConfig2 = Components.Logging().BaseLoggerName("xyz").CreateLoggingConfiguration(); + Assert.Equal("xyz", logConfig2.BaseLoggerName); + } + + [Fact] + public void DoesNotSetDefaultLevelForCustomAdapter() + { + var logCapture = Logs.Capture(); + var logConfig = Components.Logging(logCapture) + .CreateLoggingConfiguration(); + var logger = logConfig.LogAdapter.Logger(""); + logger.Debug("hi"); + Assert.True(logCapture.HasMessageWithText(LogLevel.Debug, "hi")); + } + + [Fact] + public void CanOverrideLevel() + { + var logCapture = Logs.Capture(); + var logConfig = Components.Logging(logCapture) + .Level(LogLevel.Warn) + .CreateLoggingConfiguration(); + var logger = logConfig.LogAdapter.Logger(""); + logger.Debug("d"); + logger.Info("i"); + logger.Warn("w"); + Assert.False(logCapture.HasMessageWithText(LogLevel.Debug, "d")); + Assert.False(logCapture.HasMessageWithText(LogLevel.Info, "i")); + Assert.True(logCapture.HasMessageWithText(LogLevel.Warn, "w")); + } + + [Fact] + public void NoLoggingIsShortcutForLogsNone() + { + var logConfig = Components.NoLogging.CreateLoggingConfiguration(); + Assert.Same(Logs.None, logConfig.LogAdapter); + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/PollingDataSourceBuilderTest.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/PollingDataSourceBuilderTest.cs new file mode 100644 index 00000000..d5f59670 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/PollingDataSourceBuilderTest.cs @@ -0,0 +1,33 @@ +using System; +using LaunchDarkly.TestHelpers; +using Xunit; + +namespace LaunchDarkly.Sdk.Client.Integrations +{ + public class PollingDataSourceBuilderTest + { + private readonly BuilderBehavior.InternalStateTester _tester = + BuilderBehavior.For(Components.PollingDataSource); + + [Fact] + public void BackgroundPollInterval() + { + var prop = _tester.Property(b => b._backgroundPollInterval, (b, v) => b.BackgroundPollInterval(v)); + prop.AssertDefault(Configuration.DefaultBackgroundPollInterval); + prop.AssertCanSet(TimeSpan.FromMinutes(90)); + prop.AssertSetIsChangedTo(TimeSpan.FromMilliseconds(222), Configuration.MinimumBackgroundPollInterval); + } + + + [Fact] + public void PollInterval() + { + var prop = _tester.Property(b => b._pollInterval, (b, v) => b.PollInterval(v)); + prop.AssertDefault(PollingDataSourceBuilder.DefaultPollInterval); + prop.AssertCanSet(TimeSpan.FromMinutes(7)); + prop.AssertSetIsChangedTo( + PollingDataSourceBuilder.DefaultPollInterval.Subtract(TimeSpan.FromMilliseconds(1)), + PollingDataSourceBuilder.DefaultPollInterval); + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/ServiceEndpointsBuilderTest.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/ServiceEndpointsBuilderTest.cs new file mode 100644 index 00000000..ff006907 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/ServiceEndpointsBuilderTest.cs @@ -0,0 +1,82 @@ +using System; +using LaunchDarkly.Sdk.Client.Internal; +using Xunit; + +namespace LaunchDarkly.Sdk.Client.Integrations +{ + public class ServiceEndpointsBuilderTest + { + [Fact] + public void UsesAllDefaultUrisIfNoneAreOverridden() + { + var se = Components.ServiceEndpoints().Build(); + Assert.Equal(StandardEndpoints.BaseUris.EventsBaseUri, se.EventsBaseUri); + Assert.Equal(StandardEndpoints.BaseUris.PollingBaseUri, se.PollingBaseUri); + Assert.Equal(StandardEndpoints.BaseUris.StreamingBaseUri, se.StreamingBaseUri); + } + + [Fact] + public void CanSetAllUrisToCustomValues() + { + var eu = new Uri("http://my-events"); + var pu = new Uri("http://my-polling"); + var su = new Uri("http://my-streaming"); + var se = Components.ServiceEndpoints().Events(eu).Polling(pu).Streaming(su).Build(); + Assert.Equal(eu, se.EventsBaseUri); + Assert.Equal(pu, se.PollingBaseUri); + Assert.Equal(su, se.StreamingBaseUri); + } + + [Fact] + public void IfCustomUrisAreSetAnyUnsetOnesDefaultToNull() + { + // See ServiceEndpointsBuilder.Build() for the rationale here + var eu = new Uri("http://my-events"); + var pu = new Uri("http://my-polling"); + var su = new Uri("http://my-streaming"); + + var se1 = Components.ServiceEndpoints().Events(eu).Build(); + Assert.Equal(eu, se1.EventsBaseUri); + Assert.Null(se1.PollingBaseUri); + Assert.Null(se1.StreamingBaseUri); + + var se2 = Components.ServiceEndpoints().Polling(pu).Build(); + Assert.Null(se2.EventsBaseUri); + Assert.Equal(pu, se2.PollingBaseUri); + Assert.Null(se2.StreamingBaseUri); + + var se3 = Components.ServiceEndpoints().Streaming(su).Build(); + Assert.Null(se3.EventsBaseUri); + Assert.Null(se3.PollingBaseUri); + Assert.Equal(su, se3.StreamingBaseUri); + } + + [Fact] + public void SettingRelayProxyUriSetsAllUris() + { + var ru = new Uri("http://my-relay"); + var se = Components.ServiceEndpoints().RelayProxy(ru).Build(); + Assert.Equal(ru, se.EventsBaseUri); + Assert.Equal(ru, se.PollingBaseUri); + Assert.Equal(ru, se.StreamingBaseUri); + } + + [Fact] + public void StringSettersAreEquivalentToUriSetters() + { + var eu = "http://my-events"; + var pu = "http://my-polling"; + var su = "http://my-streaming"; + var se1 = Components.ServiceEndpoints().Events(eu).Polling(pu).Streaming(su).Build(); + Assert.Equal(new Uri(eu), se1.EventsBaseUri); + Assert.Equal(new Uri(pu), se1.PollingBaseUri); + Assert.Equal(new Uri(su), se1.StreamingBaseUri); + + var ru = "http://my-relay"; + var se2 = Components.ServiceEndpoints().RelayProxy(ru).Build(); + Assert.Equal(new Uri(ru), se2.EventsBaseUri); + Assert.Equal(new Uri(ru), se2.PollingBaseUri); + Assert.Equal(new Uri(ru), se2.StreamingBaseUri); + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/StreamingDataSourceBuilderTest.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/StreamingDataSourceBuilderTest.cs new file mode 100644 index 00000000..f2a9a748 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/StreamingDataSourceBuilderTest.cs @@ -0,0 +1,29 @@ +using System; +using LaunchDarkly.TestHelpers; +using Xunit; + +namespace LaunchDarkly.Sdk.Client.Integrations +{ + public class StreamingDataSourceBuilderTest + { + private readonly BuilderBehavior.InternalStateTester _tester = + BuilderBehavior.For(Components.StreamingDataSource); + + [Fact] + public void BackgroundPollInterval() + { + var prop = _tester.Property(b => b._backgroundPollInterval, (b, v) => b.BackgroundPollInterval(v)); + prop.AssertDefault(Configuration.DefaultBackgroundPollInterval); + prop.AssertCanSet(TimeSpan.FromMinutes(90)); + prop.AssertSetIsChangedTo(TimeSpan.FromMilliseconds(222), Configuration.MinimumBackgroundPollInterval); + } + + [Fact] + public void InitialReconnectDelay() + { + var prop = _tester.Property(b => b._initialReconnectDelay, (b, v) => b.InitialReconnectDelay(v)); + prop.AssertDefault(StreamingDataSourceBuilder.DefaultInitialReconnectDelay); + prop.AssertCanSet(TimeSpan.FromMilliseconds(222)); + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/TestDataTest.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/TestDataTest.cs new file mode 100644 index 00000000..906d8fcb --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/TestDataTest.cs @@ -0,0 +1,252 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using LaunchDarkly.Sdk.Client.Subsystems; +using Xunit; +using Xunit.Abstractions; + +using static LaunchDarkly.Sdk.Client.Subsystems.DataStoreTypes; + +namespace LaunchDarkly.Sdk.Client.Integrations +{ + public class TestDataTest : BaseTest + { + private static readonly Context _initialUser = Context.New("user0"); + private static readonly Context _initialContextWithKind1 = Context.New(ContextKind.Of("kind1"), "key1"); + private static readonly Context _initialContextWithKind2 = Context.New(ContextKind.Of("kind2"), "key1"); + + private readonly TestData _td = TestData.DataSource(); + private readonly MockDataSourceUpdateSink _updates = new MockDataSourceUpdateSink(); + private readonly LdClientContext _context; + + public TestDataTest(ITestOutputHelper testOutput) : base(testOutput) + { + _context = new LdClientContext(Configuration.Builder("key", ConfigurationBuilder.AutoEnvAttributes.Disabled) + .Logging(testLogging).Build(), _initialUser); + } + + [Fact] + public void InitializesWithEmptyData() + { + CreateAndStart(); + + var initData = _updates.ExpectInit(_initialUser); + Assert.Empty(initData.Items); + } + + [Fact] + public void InitializesWithFlags() + { + _td.Update(_td.Flag("flag1").Variation(true)) + .Update(_td.Flag("flag2").Variation(false)); + + CreateAndStart(); + + var initData = _updates.ExpectInit(_initialUser); + var data = initData.Items.OrderBy(kv => kv.Key); + Assert.Collection(data, + FlagItemAssertion("flag1", 1, LdValue.Of(true), 0), + FlagItemAssertion("flag2", 1, LdValue.Of(false), 1) + ); + } + + [Fact] + public void AddsFlag() + { + CreateAndStart(); + _updates.ExpectInit(_initialUser); + + _td.Update(_td.Flag("flag1").Variation(true)); + + var item = _updates.ExpectUpsert(_initialUser, "flag1"); + VerifyUpdate(item, 1, LdValue.Of(true), 0); + } + + [Fact] + public void UpdatesFlag() + { + _td.Update(_td.Flag("flag1").Variation(true)); + + CreateAndStart(); + _updates.ExpectInit(_initialUser); + + _td.Update(_td.Flag("flag1").Variation(false)); + + var item = _updates.ExpectUpsert(_initialUser, "flag1"); + VerifyUpdate(item, 2, LdValue.Of(false), 1); + } + + [Fact] + public void FlagConfigBoolean() + { + var expectTrue = FlagValueAssertion(LdValue.Of(true), 0); + var expectFalse = FlagValueAssertion(LdValue.Of(false), 1); + + VerifyFlag(f => f, expectTrue); + VerifyFlag(f => f.BooleanFlag(), expectTrue); + VerifyFlag(f => f.Variation(true), expectTrue); + VerifyFlag(f => f.Variation(false), expectFalse); + + VerifyFlag(f => f.Variation(true).VariationForUser(_initialUser.Key, false), expectFalse); + VerifyFlag(f => f.Variation(false).VariationForUser(_initialUser.Key, true), expectTrue); + + VerifyFlag(f => f.Variation(true).VariationForKey(_initialContextWithKind1.Kind, + _initialContextWithKind1.Key, false), _initialContextWithKind1, expectFalse); // matched + VerifyFlag(f => f.Variation(true).VariationForKey(_initialContextWithKind1.Kind, + _initialContextWithKind1.Key, false), _initialContextWithKind2, expectTrue); // not matched + VerifyFlag(f => f.Variation(false).VariationForKey(_initialContextWithKind1.Kind, + _initialContextWithKind1.Key, true), _initialContextWithKind1, expectTrue); // matched + VerifyFlag(f => f.Variation(false).VariationForKey(_initialContextWithKind1.Kind, + _initialContextWithKind1.Key, true), _initialContextWithKind2, expectFalse); // not matched + + VerifyFlag(f => f.Variation(true).VariationFunc(u => false), expectFalse); + VerifyFlag(f => f.Variation(false).VariationFunc(u => true), expectTrue); + + // VariationForUser takes precedence over VariationFunc + VerifyFlag(f => f.Variation(true).VariationForUser(_initialUser.Key, false) + .VariationFunc(u => true), expectFalse); + VerifyFlag(f => f.Variation(false).VariationForUser(_initialUser.Key, true) + .VariationFunc(u => false), expectTrue); + } + + [Fact] + public void FlagConfigByVariationIndex() + { + LdValue aVal = LdValue.Of("a"), bVal = LdValue.Of("b"); + int aIndex = 0, bIndex = 1; + var ab = new LdValue[] { aVal, bVal }; + var expectA = FlagValueAssertion(LdValue.Of("a"), aIndex); + var expectB = FlagValueAssertion(LdValue.Of("b"), bIndex); + + VerifyFlag(f => f.Variations(ab), expectA); + VerifyFlag(f => f.Variations(ab).Variation(aIndex), expectA); + VerifyFlag(f => f.Variations(ab).Variation(bIndex), expectB); + + VerifyFlag(f => f.Variations(ab).Variation(aIndex).VariationForUser(_initialUser.Key, bIndex), expectB); + VerifyFlag(f => f.Variations(ab).Variation(bIndex).VariationForUser(_initialUser.Key, aIndex), expectA); + + VerifyFlag(f => f.Variations(ab).Variation(aIndex).VariationForKey(_initialContextWithKind1.Kind, + _initialContextWithKind1.Key, bIndex), _initialContextWithKind1, expectB); // matched + VerifyFlag(f => f.Variations(ab).Variation(aIndex).VariationForKey(_initialContextWithKind1.Kind, + _initialContextWithKind1.Key, bIndex), _initialContextWithKind2, expectA); // not matched + + VerifyFlag(f => f.Variations(ab).Variation(aIndex).VariationFunc(u => bIndex), expectB); + VerifyFlag(f => f.Variations(ab).Variation(bIndex).VariationFunc(u => aIndex), expectA); + + // VariationForUser takes precedence over VariationFunc + VerifyFlag(f => f.Variations(ab).Variation(aIndex).VariationForUser(_initialUser.Key, bIndex) + .VariationFunc(u => aIndex), expectB); + VerifyFlag(f => f.Variations(ab).Variation(bIndex).VariationForUser(_initialUser.Key, aIndex) + .VariationFunc(u => bIndex), expectA); + } + + [Fact] + public void FlagConfigByValue() + { + LdValue aVal = LdValue.Of("a"), bVal = LdValue.Of("b"); + int aIndex = 0, bIndex = 1; + var ab = new LdValue[] { aVal, bVal }; + var expectA = FlagValueAssertion(LdValue.Of("a"), aIndex); + var expectB = FlagValueAssertion(LdValue.Of("b"), bIndex); + + VerifyFlag(f => f.Variations(ab).Variation(aVal), expectA); + VerifyFlag(f => f.Variations(ab).Variation(bVal), expectB); + + VerifyFlag(f => f.Variations(ab).Variation(aVal).VariationForUser(_initialUser.Key, bVal), expectB); + VerifyFlag(f => f.Variations(ab).Variation(bVal).VariationForUser(_initialUser.Key, aVal), expectA); + + VerifyFlag(f => f.Variations(ab).Variation(aVal).VariationForKey(_initialContextWithKind1.Kind, + _initialContextWithKind1.Key, bVal), _initialContextWithKind1, expectB); // matched + VerifyFlag(f => f.Variations(ab).Variation(aVal).VariationForKey(_initialContextWithKind1.Kind, + _initialContextWithKind1.Key, bVal), _initialContextWithKind2, expectA); // not matched + + VerifyFlag(f => f.Variations(ab).Variation(aVal).VariationFunc(u => bVal), expectB); + VerifyFlag(f => f.Variations(ab).Variation(bVal).VariationFunc(u => aVal), expectA); + + // VariationForUser takes precedence over VariationFunc + VerifyFlag(f => f.Variations(ab).Variation(aVal).VariationForUser(_initialUser.Key, bVal) + .VariationFunc(u => aVal), expectB); + VerifyFlag(f => f.Variations(ab).Variation(bVal).VariationForUser(_initialUser.Key, aVal) + .VariationFunc(u => bVal), expectA); + } + + [Fact] + public void UsePreconfiguredFlag() + { + CreateAndStart(); + _updates.ExpectInit(_initialUser); + + var flag = new FeatureFlagBuilder().Version(1).Value(true).Variation(0).Reason(EvaluationReason.OffReason) + .TrackEvents(true).TrackReason(true).DebugEventsUntilDate(UnixMillisecondTime.OfMillis(123)).Build(); + _td.Update(_td.Flag("flag1").PreconfiguredFlag(flag)); + + var item1 = _updates.ExpectUpsert(_initialUser, "flag1"); + Assert.Equal(flag, item1.Item); + + _td.Update(_td.Flag("flag1").PreconfiguredFlag(flag)); + + var item2 = _updates.ExpectUpsert(_initialUser, "flag1"); + var updatedFlag = new FeatureFlagBuilder(flag).Version(2).Build(); + Assert.Equal(updatedFlag, item2.Item); + } + + private void CreateAndStart() + { + var ds = _td.Build(_context.WithDataSourceUpdateSink(_updates).WithContextAndBackgroundState(_initialUser, false)); + var started = ds.Start(); + Assert.True(started.IsCompleted); + } + + private Action> FlagItemAssertion( + string key, + int version, + LdValue value, + int? variation + ) + { + return kv => + { + Assert.Equal(key, kv.Key); + VerifyUpdate(kv.Value, version, value, variation); + }; + } + + private Action FlagValueAssertion( + LdValue value, + int? variation + ) + { + return item => + { + Assert.Equal(value, item.Item.Value); + Assert.Equal(variation, item.Item.Variation); + }; + } + + private void VerifyUpdate(ItemDescriptor item, int version, LdValue value, int? variation) + { + Assert.Equal(version, item.Version); + Assert.Equal(value, item.Item.Value); + Assert.Equal(variation, item.Item.Variation); + } + + private void VerifyFlag(Func builderFn, + Context context, + Action assertion) + { + var tdTemp = TestData.DataSource(); + using (var ds = tdTemp.Build(_context.WithDataSourceUpdateSink(_updates).WithContextAndBackgroundState(context, false))) + { + ds.Start(); + _updates.ExpectInit(context); + tdTemp.Update(builderFn(tdTemp.Flag("flag"))); + var up = _updates.ExpectUpsert(context, "flag"); + assertion(up); + } + } + + private void VerifyFlag(Func builderFn, + Action assertion) => + VerifyFlag(builderFn, _initialUser, assertion); + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/TestDataWithClientTest.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/TestDataWithClientTest.cs new file mode 100644 index 00000000..ff622c7a --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Integrations/TestDataWithClientTest.cs @@ -0,0 +1,116 @@ +using System; +using Xunit; +using Xunit.Abstractions; + +namespace LaunchDarkly.Sdk.Client.Integrations +{ + public class TestDataWithClientTest : BaseTest + { + private readonly TestData _td = TestData.DataSource(); + private readonly Configuration _config; + private readonly Context _user = Context.New("userkey"); + + public TestDataWithClientTest(ITestOutputHelper testOutput) : base(testOutput) + { + _config = Configuration.Builder("mobile-key", ConfigurationBuilder.AutoEnvAttributes.Disabled) + .DataSource(_td) + .Events(Components.NoEvents) + .Build(); + } + + [Fact] + public void InitializesWithEmptyData() + { + using (var client = LdClient.Init(_config, _user, TimeSpan.FromSeconds(1))) + { + Assert.True(client.Initialized); + } + } + + [Fact] + public void InitializesWithFlag() + { + _td.Update(_td.Flag("flag").Variation(true)); + + using (var client = LdClient.Init(_config, _user, TimeSpan.FromSeconds(1))) + { + Assert.True(client.BoolVariation("flag", false)); + } + } + + [Fact] + public void UpdatesFlag() + { + using (var client = LdClient.Init(_config, _user, TimeSpan.FromSeconds(1))) + { + Assert.False(client.BoolVariation("flag", false)); + + _td.Update(_td.Flag("flag").Variation(true)); + + Assert.True(client.BoolVariation("flag", false)); + } + } + + [Fact] + public void CanSetValuePerUser() + { + _td.Update(_td.Flag("flag") + .Variations(LdValue.Of("red"), LdValue.Of("green"), LdValue.Of("blue")) + .Variation(LdValue.Of("red")) + .VariationForUser("user1", LdValue.Of("green")) + .VariationForUser("user2", LdValue.Of("blue")) + .VariationFunc(user => + user.GetValue("favoriteColor") + )); + var user1 = Context.New("user1"); + var user2 = Context.New("user2"); + var user3 = Context.Builder("user3").Set("favoriteColor", "green").Build(); + + using (var client = LdClient.Init(_config, user1, TimeSpan.FromSeconds(1))) + { + Assert.Equal("green", client.StringVariation("flag", "")); + + client.Identify(user2, TimeSpan.FromSeconds(1)); + + Assert.Equal("blue", client.StringVariation("flag", "")); + + client.Identify(user3, TimeSpan.FromSeconds(1)); + + Assert.Equal("green", client.StringVariation("flag", "")); + } + } + + [Fact] + public void CanSetValuePerContext() + { + ContextKind kind1 = ContextKind.Of("kind1"), kind2 = ContextKind.Of("kind2"); + _td.Update(_td.Flag("flag") + .Variations(LdValue.Of("red"), LdValue.Of("green"), LdValue.Of("blue")) + .Variation(LdValue.Of("red")) + .VariationForKey(kind1, "key1", LdValue.Of("green")) + .VariationForKey(kind1, "key2", LdValue.Of("blue")) + .VariationForKey(kind2, "key1", LdValue.Of("blue")) + .VariationFunc(context => + context.GetValue("favoriteColor") + )); + var context1 = Context.New(kind1, "key1"); + var context2 = Context.New(kind1, "key2"); + var context3 = Context.New(kind2, "key1"); + var context4 = Context.Builder("key4").Set("favoriteColor", "green").Build(); + + using (var client = LdClient.Init(_config, context1, TimeSpan.FromSeconds(1))) + { + Assert.Equal("green", client.StringVariation("flag", "")); + + client.Identify(context2, TimeSpan.FromSeconds(1)); + Assert.Equal("blue", client.StringVariation("flag", "")); + + client.Identify(context3, TimeSpan.FromSeconds(1)); + Assert.Equal("blue", client.StringVariation("flag", "")); + + client.Identify(context4, TimeSpan.FromSeconds(1)); + Assert.Equal("green", client.StringVariation("flag", "")); + } + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/AnonymousKeyContextDecoratorTest.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/AnonymousKeyContextDecoratorTest.cs new file mode 100644 index 00000000..cdec94ae --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/AnonymousKeyContextDecoratorTest.cs @@ -0,0 +1,178 @@ +using System.Linq; +using LaunchDarkly.Sdk.Client.Internal.DataStores; +using LaunchDarkly.Sdk.Client.Subsystems; +using Xunit; + +namespace LaunchDarkly.Sdk.Client.Internal +{ + public class AnonymousKeyContextDecoratorTest : BaseTest + { + private static readonly ContextKind Kind1 = ContextKind.Of("kind1"); + private static readonly ContextKind Kind2 = ContextKind.Of("kind2"); + + [Fact] + public void SingleKindNonAnonymousContextIsUnchanged() + { + var context = Context.Builder("key1").Name("name").Build(); + + AssertHelpers.ContextsEqual(context, + MakeDecoratorWithoutPersistence().DecorateContext(context)); + } + + [Fact] + public void SingleKindAnonymousContextIsUnchangedIfConfigOptionIsNotSet() + { + var context = Context.Builder("key1").Anonymous(true).Name("name").Build(); + + AssertHelpers.ContextsEqual(context, + MakeDecoratorWithoutPersistence().DecorateContext(context)); + } + + [Fact] + public void SingleKindAnonymousContextGetsGeneratedKeyIfConfigOptionIsSet() + { + var context = TestUtil.BuildAutoContext().Name("name").Build(); + + var transformed = MakeDecoratorWithoutPersistence(true).DecorateContext(context); + + AssertContextHasBeenTransformedWithNewKey(context, transformed); + } + + [Fact] + public void MultiKindContextIsUnchangedIfNoIndividualContextsNeedGeneratedKey() + { + var c1 = Context.Builder("key1").Kind(Kind1).Name("name1").Build(); + var c2 = Context.Builder("key2").Kind(Kind2).Anonymous(true).Name("name2").Build(); + + var multiContext = Context.NewMulti(c1, c2); + + AssertHelpers.ContextsEqual(multiContext, + MakeDecoratorWithoutPersistence().DecorateContext(multiContext)); + } + + [Fact] + public void MultiKindContextGetsGeneratedKeyForIndividualContext() + { + var c1 = Context.Builder("key1").Kind(Kind1).Name("name1").Build(); + var c2 = TestUtil.BuildAutoContext().Kind(Kind2).Anonymous(true).Name("name2").Build(); + var multiContext = Context.NewMulti(c1, c2); + var transformedMulti = MakeDecoratorWithoutPersistence(true).DecorateContext(multiContext); + + Assert.Equal(multiContext.MultiKindContexts.Select(c => c.Kind).ToList(), + transformedMulti.MultiKindContexts.Select(c => c.Kind).ToList()); + + transformedMulti.TryGetContextByKind(c1.Kind, out var c1Transformed); + AssertHelpers.ContextsEqual(c1, c1Transformed); + + transformedMulti.TryGetContextByKind(c2.Kind, out var c2Transformed); + AssertContextHasBeenTransformedWithNewKey(c2, c2Transformed); + + AssertHelpers.ContextsEqual(Context.NewMulti(c1, c2Transformed), transformedMulti); + } + + [Fact] + public void MultiKindContextGetsSeparateGeneratedKeyForEachKind() + { + var c1 = TestUtil.BuildAutoContext().Kind(Kind1).Anonymous(true).Name("name1").Build(); + var c2 = TestUtil.BuildAutoContext().Kind(Kind2).Anonymous(true).Name("name2").Build(); + var multiContext = Context.NewMulti(c1, c2); + var transformedMulti = MakeDecoratorWithoutPersistence(true).DecorateContext(multiContext); + + Assert.Equal(multiContext.MultiKindContexts.Select(c => c.Kind).ToList(), + transformedMulti.MultiKindContexts.Select(c => c.Kind).ToList()); + + transformedMulti.TryGetContextByKind(c1.Kind, out var c1Transformed); + AssertContextHasBeenTransformedWithNewKey(c1, c1Transformed); + + transformedMulti.TryGetContextByKind(c2.Kind, out var c2Transformed); + AssertContextHasBeenTransformedWithNewKey(c2, c2Transformed); + + Assert.NotEqual(c1Transformed.Key, c2Transformed.Key); + + AssertHelpers.ContextsEqual(Context.NewMulti(c1Transformed, c2Transformed), transformedMulti); + } + + [Fact] + public void GeneratedKeysPersistPerKindIfPersistentStorageIsEnabled() + { + var c1 = TestUtil.BuildAutoContext().Kind(Kind1).Anonymous(true).Name("name1").Build(); + var c2 = TestUtil.BuildAutoContext().Kind(Kind2).Anonymous(true).Name("name2").Build(); + var multiContext = Context.NewMulti(c1, c2); + + var store = new MockPersistentDataStore(); + + var decorator1 = MakeDecoratorWithPersistence(store, true); + + var transformedMultiA = decorator1.DecorateContext(multiContext); + transformedMultiA.TryGetContextByKind(c1.Kind, out var c1Transformed); + AssertContextHasBeenTransformedWithNewKey(c1, c1Transformed); + transformedMultiA.TryGetContextByKind(c2.Kind, out var c2Transformed); + AssertContextHasBeenTransformedWithNewKey(c2, c2Transformed); + + var decorator2 = MakeDecoratorWithPersistence(store, true); + + var transformedMultiB = decorator2.DecorateContext(multiContext); + AssertHelpers.ContextsEqual(transformedMultiA, transformedMultiB); + } + + [Fact] + public void GeneratedKeysAreReusedDuringLifetimeOfSdkEvenIfPersistentStorageIsDisabled() + { + var c1 = TestUtil.BuildAutoContext().Kind(Kind1).Anonymous(true).Name("name1").Build(); + var c2 = TestUtil.BuildAutoContext().Kind(Kind2).Anonymous(true).Name("name2").Build(); + var multiContext = Context.NewMulti(c1, c2); + + var store = new MockPersistentDataStore(); + + var decorator = MakeDecoratorWithoutPersistence(true); + + var transformedMultiA = decorator.DecorateContext(multiContext); + transformedMultiA.TryGetContextByKind(c1.Kind, out var c1Transformed); + AssertContextHasBeenTransformedWithNewKey(c1, c1Transformed); + transformedMultiA.TryGetContextByKind(c2.Kind, out var c2Transformed); + AssertContextHasBeenTransformedWithNewKey(c2, c2Transformed); + + var transformedMultiB = decorator.DecorateContext(multiContext); + AssertHelpers.ContextsEqual(transformedMultiA, transformedMultiB); + } + + [Fact] + public void GeneratedKeysAreNotReusedAcrossRestartsIfPersistentStorageIsDisabled() + { + var c1 = TestUtil.BuildAutoContext().Kind(Kind1).Anonymous(true).Name("name1").Build(); + var c2 = TestUtil.BuildAutoContext().Kind(Kind2).Anonymous(true).Name("name2").Build(); + var multiContext = Context.NewMulti(c1, c2); + + var decorator1 = MakeDecoratorWithoutPersistence(true); + + var transformedMultiA = decorator1.DecorateContext(multiContext); + transformedMultiA.TryGetContextByKind(c1.Kind, out var c1TransformedA); + AssertContextHasBeenTransformedWithNewKey(c1, c1TransformedA); + transformedMultiA.TryGetContextByKind(c2.Kind, out var c2TransformedA); + AssertContextHasBeenTransformedWithNewKey(c2, c2TransformedA); + + var decorator2 = MakeDecoratorWithoutPersistence(true); + + var transformedMultiB = decorator2.DecorateContext(multiContext); + transformedMultiB.TryGetContextByKind(c1.Kind, out var c1TransformedB); + AssertContextHasBeenTransformedWithNewKey(c1, c1TransformedB); + Assert.NotEqual(c1TransformedA.Key, c1TransformedB.Key); + transformedMultiB.TryGetContextByKind(c2.Kind, out var c2TransformedB); + AssertContextHasBeenTransformedWithNewKey(c2, c2TransformedB); + Assert.NotEqual(c2TransformedA.Key, c2TransformedB.Key); + } + + private AnonymousKeyContextDecorator MakeDecoratorWithPersistence(IPersistentDataStore store, bool generateAnonymousKeys = false) => + new AnonymousKeyContextDecorator(new PersistentDataStoreWrapper(store, BasicMobileKey, testLogger), generateAnonymousKeys); + + private AnonymousKeyContextDecorator MakeDecoratorWithoutPersistence(bool generateAnonymousKeys = false) => + MakeDecoratorWithPersistence(new NullPersistentDataStore(), generateAnonymousKeys); + + private void AssertContextHasBeenTransformedWithNewKey(Context original, Context transformed) + { + Assert.NotEqual(original.Key, transformed.Key); + AssertHelpers.ContextsEqual(Context.BuilderFromContext(original).Key(transformed.Key).Build(), + transformed); + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/AutoEnvContextDecoratorTest.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/AutoEnvContextDecoratorTest.cs new file mode 100644 index 00000000..e52bf1e2 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/AutoEnvContextDecoratorTest.cs @@ -0,0 +1,142 @@ +using System.Globalization; +using LaunchDarkly.Sdk.Client.Internal.DataStores; +using LaunchDarkly.Sdk.Client.Subsystems; +using LaunchDarkly.Sdk.EnvReporting; +using LaunchDarkly.Sdk.EnvReporting.LayerModels; +using Xunit; + +namespace LaunchDarkly.Sdk.Client.Internal +{ + public class AutoEnvContextDecoratorTest : BaseTest + { + [Fact] + public void AdheresToSchemaTest() + { + const string osFamily = "family_foo"; + const string osName = "name_bar"; + const string osVersion = null; + + var envReporter = new EnvironmentReporterBuilder().SetSdkLayer(SdkAttributes.Layer) + .SetPlatformLayer(new Layer(null, new OsInfo(osFamily, osName, osVersion), null, null)).Build(); + + var store = MakeMockDataStoreWrapper(); + var decoratorUnderTest = MakeDecoratorWithPersistence(store, envReporter); + + var input = Context.Builder("aKey").Kind(ContextKind.Of("aKind")) + .Set("dontOverwriteMeBro", "really bro").Build(); + var output = decoratorUnderTest.DecorateContext(input); + + // Create the expected context after the code runs + // because there will be persistence side effects + var applicationKind = ContextKind.Of(AutoEnvContextDecorator.LdApplicationKind); + var expectedApplicationKey = Base64.UrlSafeSha256Hash(envReporter.ApplicationInfo?.ApplicationId ?? ""); + var expectedAppContext = Context.Builder(applicationKind, expectedApplicationKey) + .Set(AutoEnvContextDecorator.EnvAttributesVersion, AutoEnvContextDecorator.SpecVersion) + .Set(AutoEnvContextDecorator.AttrId, SdkPackage.Name) + .Set(AutoEnvContextDecorator.AttrName, SdkPackage.Name) + .Set(AutoEnvContextDecorator.AttrVersion, SdkPackage.Version) + .Set(AutoEnvContextDecorator.AttrVersionName, SdkPackage.Version) + .Build(); + + var deviceKind = ContextKind.Of(AutoEnvContextDecorator.LdDeviceKind); + var expectedDeviceContext = Context.Builder(deviceKind, store.GetGeneratedContextKey(deviceKind)) + .Set(AutoEnvContextDecorator.EnvAttributesVersion, AutoEnvContextDecorator.SpecVersion) + .Set(AutoEnvContextDecorator.AttrOs, + LdValue.BuildObject().Set("family", osFamily).Set("name", osName).Build()).Build(); + + var expectedOutput = Context.MultiBuilder() + .Add(input) + .Add(expectedAppContext) + .Add(expectedDeviceContext) + .Build(); + + Assert.Equal(expectedOutput, output); + } + + [Fact] + public void CustomCultureInPlatformLayerIsPropagated() + { + var platform = new Layer(null, null, null, "en-GB"); + + var envReporter = new EnvironmentReporterBuilder().SetPlatformLayer(platform).Build(); + var store = MakeMockDataStoreWrapper(); + var decoratorUnderTest = MakeDecoratorWithPersistence(store, envReporter); + + var input = Context.Builder("aKey").Kind(ContextKind.Of("aKind")).Build(); + var output = decoratorUnderTest.DecorateContext(input); + + + Context outContext; + Assert.True(output.TryGetContextByKind(new ContextKind(AutoEnvContextDecorator.LdApplicationKind), + out outContext)); + + Assert.Equal("en-GB", outContext.GetValue("locale").AsString); + } + + [Fact] + public void DoesNotOverwriteCustomerDataTest() + { + var envReporter = new EnvironmentReporterBuilder().SetSdkLayer(SdkAttributes.Layer).Build(); + var store = MakeMockDataStoreWrapper(); + var decoratorUnderTest = MakeDecoratorWithPersistence(store, envReporter); + + var input = Context.Builder(ContextKind.Of(AutoEnvContextDecorator.LdApplicationKind), "aKey") + .Set("dontOverwriteMeBro", "really bro").Build(); + var output = decoratorUnderTest.DecorateContext(input); + + var expectedOutput = Context.MultiBuilder().Add(input).Build(); + + Assert.Equal(expectedOutput, output); + } + + [Fact] + public void DoesNotOverwriteCustomerDataMultiContextTest() + { + var envReporter = new EnvironmentReporterBuilder().SetSdkLayer(SdkAttributes.Layer).Build(); + var store = MakeMockDataStoreWrapper(); + var decoratorUnderTest = MakeDecoratorWithPersistence(store, envReporter); + + var input1 = Context.Builder(ContextKind.Of(AutoEnvContextDecorator.LdApplicationKind), "aKey") + .Set("dontOverwriteMeBro", "really bro").Build(); + var input2 = Context.Builder(ContextKind.Of(AutoEnvContextDecorator.LdDeviceKind), "anotherKey") + .Set("AndDontOverwriteThisEither", "bro").Build(); + var multiContextInput = Context.MultiBuilder().Add(input1).Add(input2).Build(); + var output = decoratorUnderTest.DecorateContext(multiContextInput); + + // input and output should be the same + Assert.Equal(multiContextInput, output); + } + + [Fact] + public void GeneratesConsistentKeysAcrossMultipleCalls() + { + var envReporter = new EnvironmentReporterBuilder().SetSdkLayer(SdkAttributes.Layer).Build(); + var store = MakeMockDataStoreWrapper(); + var decoratorUnderTest = MakeDecoratorWithPersistence(store, envReporter); + + var input = Context.Builder(ContextKind.Of("aKind"), "aKey") + .Set("dontOverwriteMeBro", "really bro").Build(); + + var output1 = decoratorUnderTest.DecorateContext(input); + output1.TryGetContextByKind(ContextKind.Of("ld_application"), out var appContext1); + var key1 = appContext1.Key; + + var output2 = decoratorUnderTest.DecorateContext(input); + output2.TryGetContextByKind(ContextKind.Of("ld_application"), out var appContext2); + var key2 = appContext2.Key; + + Assert.Equal(key1, key2); + } + + private PersistentDataStoreWrapper MakeMockDataStoreWrapper() + { + return new PersistentDataStoreWrapper(new MockPersistentDataStore(), BasicMobileKey, testLogger); + } + + private AutoEnvContextDecorator MakeDecoratorWithPersistence(PersistentDataStoreWrapper store, + IEnvironmentReporter reporter) + { + return new AutoEnvContextDecorator(store, reporter, testLogger); + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/Base64Test.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/Base64Test.cs new file mode 100644 index 00000000..a0e55dcd --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/Base64Test.cs @@ -0,0 +1,22 @@ +using Xunit; + +namespace LaunchDarkly.Sdk.Client.Internal +{ + public class Base64Test + { + [Fact] + public void TestUrlSafeBase64Encode() + { + Assert.Equal("eyJrZXkiOiJmb28-YmFyX18_In0=", + Base64.UrlSafeEncode(@"{""key"":""foo>bar__?""}")); + } + + [Fact] + public void TestUrlSafeSha256Hash() + { + var input = "OhYeah?HashThis!!!"; // hash is KzDwVRpvTuf//jfMK27M4OMpIRTecNcJoaffvAEi+as= and it has a + and a / + var expectedOutput = "KzDwVRpvTuf__jfMK27M4OMpIRTecNcJoaffvAEi-as="; + Assert.Equal(expectedOutput, Base64.UrlSafeSha256Hash(input)); + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataModelSerializationTest.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataModelSerializationTest.cs new file mode 100644 index 00000000..6384b1ea --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataModelSerializationTest.cs @@ -0,0 +1,97 @@ +using LaunchDarkly.Sdk.Json; +using Xunit; + +using static LaunchDarkly.Sdk.Client.TestUtil; +using static LaunchDarkly.TestHelpers.JsonAssertions; + +namespace LaunchDarkly.Sdk.Client.Internal +{ + public class DataModelSerializationTest + { + [Fact] + public void SerializeContext() + { + var user = Context.Builder("user-key") + .Set("firstName", "Lucy").Set("lastName", "Cat").Build(); + AssertJsonEqual(LdJsonSerialization.SerializeObject(user), + DataModelSerialization.SerializeContext(user)); + } + + [Fact] + public void SerializeFlagWithMinimalProperties() + { + var flag = new FeatureFlagBuilder() + .Version(1) + .Value(LdValue.Of(false)) + .Build(); + AssertJsonEqual(@"{""version"":1,""value"":false}", + DataModelSerialization.SerializeFlag(flag)); + } + + [Fact] + public void SerializeFlagWithAllProperties() + { + var flag1 = new FeatureFlagBuilder() + .Version(1) + .Value(LdValue.Of(false)) + .Variation(2) + .FlagVersion(3) + .Reason(EvaluationReason.OffReason) + .TrackEvents(true) + .DebugEventsUntilDate(UnixMillisecondTime.OfMillis(1234)) + .Build(); + AssertJsonEqual(@"{""version"":1,""value"":false,""variation"":2,""flagVersion"":3," + + @"""reason"":{""kind"":""OFF""},""trackEvents"":true,""debugEventsUntilDate"":1234}", + DataModelSerialization.SerializeFlag(flag1)); + + // make sure we're treating trackReason separately from trackEvents + var flag2 = new FeatureFlagBuilder() + .Version(1) + .Value(LdValue.Of(false)) + .Reason(EvaluationReason.OffReason) + .Variation(2) + .FlagVersion(3) + .TrackReason(true) + .Build(); + AssertJsonEqual(@"{""version"":1,""value"":false,""variation"":2,""flagVersion"":3," + + @"""reason"":{""kind"":""OFF""},""trackReason"":true}", + DataModelSerialization.SerializeFlag(flag2)); + } + + [Fact] + public void SerializeAll() + { + var flag1 = new FeatureFlagBuilder().Version(100).Value(LdValue.Of(false)).Build(); + var flag2 = new FeatureFlagBuilder().Version(200).Value(LdValue.Of(true)).Build(); + var flag1Json = DataModelSerialization.SerializeFlag(flag1); + var flag2Json = DataModelSerialization.SerializeFlag(flag2); + var deletedVersion = 300; + var allData = new DataSetBuilder() + .Add("key1", flag1) + .Add("key2", flag2) + .AddDeleted("deletedKey", deletedVersion) + .Build(); + var actual = DataModelSerialization.SerializeAll(allData); + var expected = MakeJsonData(allData); + AssertJsonEqual(expected, actual); + } + + [Fact] + public void DeserializeAll() + { + var flag1 = new FeatureFlagBuilder().Version(100).Value(LdValue.Of(false)).Build(); + var flag2 = new FeatureFlagBuilder().Version(200).Value(LdValue.Of(true)).Build(); + var expectedData = new DataSetBuilder() + .Add("key1", flag1) + .Add("key2", flag2) + .Build(); + var serialized = MakeJsonData(expectedData); + + var actualData1 = DataModelSerialization.DeserializeV1Schema(serialized); + Assert.Equal(expectedData, actualData1); + + var actualData2 = DataModelSerialization.DeserializeAll(serialized); + Assert.Equal(expectedData, actualData2); + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataSources/DataSourceUpdateSinkImplTest.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataSources/DataSourceUpdateSinkImplTest.cs new file mode 100644 index 00000000..f20cb263 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataSources/DataSourceUpdateSinkImplTest.cs @@ -0,0 +1,258 @@ +using LaunchDarkly.Sdk.Client.Interfaces; +using LaunchDarkly.Sdk.Client.Internal.DataStores; +using LaunchDarkly.TestHelpers; +using Xunit; +using Xunit.Abstractions; + +using static LaunchDarkly.Sdk.Client.Subsystems.DataStoreTypes; + +namespace LaunchDarkly.Sdk.Client.Internal.DataSources +{ + public class DataSourceUpdateSinkImplTest : BaseTest + { + private readonly FlagDataManager _store; + private readonly FlagTrackerImpl _flagTracker; + private readonly DataSourceUpdateSinkImpl _updateSink; + + private readonly Context _basicUser = Context.NewMulti(Context.New(ContextKind.Of("user"), "user-key1"), Context.New(ContextKind.Of("custom-kind"), "custom-key1")); + private readonly Context _otherUser = Context.NewMulti(Context.New(ContextKind.Of("user"), "user-key2"), Context.New(ContextKind.Of("custom-kind"), "custom-key2")); + + public DataSourceUpdateSinkImplTest(ITestOutputHelper testOutput) : base(testOutput) + { + _store = new FlagDataManager(BasicMobileKey, null, testLogger); + _updateSink = new DataSourceUpdateSinkImpl(_store, false, BasicTaskExecutor, testLogger); + _flagTracker = new FlagTrackerImpl(_updateSink); + } + + [Fact] + public void InitPassesDataToStore() + { + var initData = new DataSetBuilder().Add("key1", new FeatureFlagBuilder().Build()).Build(); + _updateSink.Init(_basicUser, initData); + + Assert.Equal(initData.Items, _store.GetAll().Value.Items); + } + + [Fact] + public void UpsertPassesDataToStore() + { + var flag1a = new FeatureFlagBuilder().Version(100).Value(LdValue.Of(false)).Build(); + var initData = new DataSetBuilder().Add("key1", flag1a).Build(); + _updateSink.Init(_basicUser, initData); + + var flag1b = new FeatureFlagBuilder().Version(101).Value(LdValue.Of(true)).Build(); + + _updateSink.Upsert(_basicUser, "key1", flag1b.ToItemDescriptor()); + + Assert.Equal(flag1b.ToItemDescriptor(), _store.Get("key1")); + } + + [Fact] + public void NoEventsAreSentForFirstInit() + { + var events = new EventSink(); + _flagTracker.FlagValueChanged += events.Add; + + var initData = new DataSetBuilder().Add("key1", new FeatureFlagBuilder().Build()).Build(); + _updateSink.Init(_basicUser, initData); + + events.ExpectNoValue(); + } + + [Fact] + public void NoEventsAreSentForUpsertIfNeverInited() + { + var events = new EventSink(); + _flagTracker.FlagValueChanged += events.Add; + + _updateSink.Upsert(_basicUser, "key1", new FeatureFlagBuilder().Build().ToItemDescriptor()); + + events.ExpectNoValue(); + } + + [Fact] + public void EventIsSentForChangedFlagOnInit() + { + var events = new EventSink(); + _flagTracker.FlagValueChanged += events.Add; + + var initData1 = new DataSetBuilder() + .Add("key1", new FeatureFlagBuilder().Value(LdValue.Of(true)).Variation(0).Build()) + .Add("key2", new FeatureFlagBuilder().Value(LdValue.Of(true)).Variation(0).Build()) + .Build(); + _updateSink.Init(_basicUser, initData1); + + var initData2 = new DataSetBuilder() + .Add("key1", new FeatureFlagBuilder().Value(LdValue.Of(false)).Variation(1).Build()) + .Add("key2", new FeatureFlagBuilder().Value(LdValue.Of(true)).Variation(0).Build()) + .Build(); + _updateSink.Init(_basicUser, initData2); + + var e = events.ExpectValue(); + Assert.Equal("key1", e.Key); + Assert.Equal(LdValue.Of(true), e.OldValue); + Assert.Equal(LdValue.Of(false), e.NewValue); + Assert.False(e.Deleted); + } + + [Fact] + public void EventIsSentForAddedFlagOnInit() + { + var events = new EventSink(); + _flagTracker.FlagValueChanged += events.Add; + + var initData1 = new DataSetBuilder() + .Add("key2", new FeatureFlagBuilder().Value(LdValue.Of(true)).Variation(0).Build()) + .Build(); + _updateSink.Init(_basicUser, initData1); + + var initData2 = new DataSetBuilder() + .Add("key1", new FeatureFlagBuilder().Value(LdValue.Of(false)).Variation(1).Build()) + .Add("key2", new FeatureFlagBuilder().Value(LdValue.Of(true)).Variation(0).Build()) + .Build(); + _updateSink.Init(_basicUser, initData2); + + var e = events.ExpectValue(); + Assert.Equal("key1", e.Key); + Assert.Equal(LdValue.Null, e.OldValue); + Assert.Equal(LdValue.Of(false), e.NewValue); + Assert.False(e.Deleted); + } + + [Fact] + public void EventIsSentForDeletedFlagOnInit() + { + var events = new EventSink(); + _flagTracker.FlagValueChanged += events.Add; + + var initData1 = new DataSetBuilder() + .Add("key1", new FeatureFlagBuilder().Value(LdValue.Of(true)).Variation(0).Build()) + .Add("key2", new FeatureFlagBuilder().Value(LdValue.Of(true)).Variation(0).Build()) + .Build(); + _updateSink.Init(_basicUser, initData1); + + var initData2 = new DataSetBuilder() + .Add("key2", new FeatureFlagBuilder().Value(LdValue.Of(true)).Variation(0).Build()) + .Build(); + _updateSink.Init(_basicUser, initData2); + + var e = events.ExpectValue(); + Assert.Equal("key1", e.Key); + Assert.Equal(LdValue.Of(true), e.OldValue); + Assert.Equal(LdValue.Null, e.NewValue); + Assert.True(e.Deleted); + } + + [Fact] + public void EventIsSentForChangedFlagOnUpsert() + { + var events = new EventSink(); + _flagTracker.FlagValueChanged += events.Add; + + var initData = new DataSetBuilder() + .Add("key1", new FeatureFlagBuilder().Version(100).Value(LdValue.Of(true)).Variation(0).Build()) + .Add("key2", new FeatureFlagBuilder().Version(200).Value(LdValue.Of(true)).Variation(0).Build()) + .Build(); + _updateSink.Init(_basicUser, initData); + + _updateSink.Upsert(_basicUser, "key1", + new FeatureFlagBuilder().Version(101).Value(LdValue.Of(false)).Variation(1).Build().ToItemDescriptor()); + + var e = events.ExpectValue(); + Assert.Equal("key1", e.Key); + Assert.Equal(LdValue.Of(true), e.OldValue); + Assert.Equal(LdValue.Of(false), e.NewValue); + Assert.False(e.Deleted); + } + + [Fact] + public void EventIsSentForAddedFlagOnUpsert() + { + var events = new EventSink(); + _flagTracker.FlagValueChanged += events.Add; + + var initData = new DataSetBuilder() + .Add("key2", new FeatureFlagBuilder().Version(200).Value(LdValue.Of(true)).Variation(0).Build()) + .Build(); + _updateSink.Init(_basicUser, initData); + + _updateSink.Upsert(_basicUser, "key1", + new FeatureFlagBuilder().Version(100).Value(LdValue.Of(false)).Variation(1).Build().ToItemDescriptor()); + + var e = events.ExpectValue(); + Assert.Equal("key1", e.Key); + Assert.Equal(LdValue.Null, e.OldValue); + Assert.Equal(LdValue.Of(false), e.NewValue); + Assert.False(e.Deleted); + } + + [Fact] + public void EventIsSentForDeletedFlagOnUpsert() + { + var events = new EventSink(); + _flagTracker.FlagValueChanged += events.Add; + + var initData = new DataSetBuilder() + .Add("key1", new FeatureFlagBuilder().Version(100).Value(LdValue.Of(true)).Variation(0).Build()) + .Add("key2", new FeatureFlagBuilder().Version(200).Value(LdValue.Of(true)).Variation(0).Build()) + .Build(); + _updateSink.Init(_basicUser, initData); + + _updateSink.Upsert(_basicUser, "key1", new ItemDescriptor(101, null)); + + var e = events.ExpectValue(); + Assert.Equal("key1", e.Key); + Assert.Equal(LdValue.Of(true), e.OldValue); + Assert.Equal(LdValue.Null, e.NewValue); + Assert.True(e.Deleted); + } + + [Fact] + public void EventIsNotSentIfUpsertFailsDueToLowerVersion() + { + var events = new EventSink(); + _flagTracker.FlagValueChanged += events.Add; + + var initData = new DataSetBuilder() + .Add("key1", new FeatureFlagBuilder().Version(100).Value(LdValue.Of(true)).Variation(0).Build()) + .Add("key2", new FeatureFlagBuilder().Version(200).Value(LdValue.Of(true)).Variation(0).Build()) + .Build(); + _updateSink.Init(_basicUser, initData); + + _updateSink.Upsert(_basicUser, "key1", + new FeatureFlagBuilder().Version(99).Value(LdValue.Of(false)).Variation(1).Build().ToItemDescriptor()); + + events.ExpectNoValue(); + } + + [Fact] + public void ValueChangesAreTrackedSeparatelyForEachUser() + { + var events = new EventSink(); + _flagTracker.FlagValueChanged += events.Add; + + var initDataForBasicUser = new DataSetBuilder() + .Add("key1", new FeatureFlagBuilder().Version(100).Value(LdValue.Of("a")).Variation(1).Build()) + .Add("key2", new FeatureFlagBuilder().Version(200).Value(LdValue.Of("b")).Variation(2).Build()) + .Build(); + _updateSink.Init(_basicUser, initDataForBasicUser); + + var initDataForOtherUser = new DataSetBuilder() + .Add("key1", new FeatureFlagBuilder().Version(100).Value(LdValue.Of("c")).Variation(3).Build()) + .Add("key2", new FeatureFlagBuilder().Version(200).Value(LdValue.Of("d")).Variation(4).Build()) + .Build(); + _updateSink.Init(_otherUser, initDataForOtherUser); + + events.ExpectNoValue(); + + _updateSink.Upsert(_basicUser, "key1", + new FeatureFlagBuilder().Version(101).Value(LdValue.Of("c")).Variation(3).Build().ToItemDescriptor()); + + var e = events.ExpectValue(); + + Assert.Equal("key1", e.Key); + Assert.Equal(LdValue.Of("a"), e.OldValue); + Assert.Equal(LdValue.Of("c"), e.NewValue); + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataSources/FeatureFlagRequestorTests.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataSources/FeatureFlagRequestorTests.cs new file mode 100644 index 00000000..59f8f776 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataSources/FeatureFlagRequestorTests.cs @@ -0,0 +1,122 @@ +using System; +using System.Threading.Tasks; +using LaunchDarkly.Sdk.Json; +using LaunchDarkly.Sdk.Client.Subsystems; +using LaunchDarkly.TestHelpers.HttpTest; +using Xunit; +using Xunit.Abstractions; +using static LaunchDarkly.TestHelpers.JsonAssertions; + +namespace LaunchDarkly.Sdk.Client.Internal.DataSources +{ + // End-to-end tests of this component against an embedded HTTP server. This is covered + // in more detail by PollingDataSourceTest, but FeatureFlagRequestor can also be used from + // StreamingDataSource. + + public class FeatureFlagRequestorTests : BaseTest + { + public FeatureFlagRequestorTests(ITestOutputHelper testOutput) : base(testOutput) + { + } + + private const string _mobileKey = "FAKE_KEY"; + + // User key constructed to test base64 encoding that differs between the standard and "URL and Filename safe" + // base64 encodings from RFC4648. We need to use the URL safe encoding for flag requests. + private static readonly Context _context = Context.New("foo_bar__?"); + // Note that in a real use case, the user encoding may vary depending on the target platform, because the SDK adds custom + // user attributes like "os". But the lower-level FeatureFlagRequestor component does not do that. + + private const string + _allDataJson = + "{}"; // Note that in this implementation, unlike the .NET SDK, FeatureFlagRequestor does not unmarshal the response + + [Theory] + [InlineData("", false, "/msdk/evalx/contexts/", "")] + [InlineData("", true, "/msdk/evalx/contexts/", "?withReasons=true")] + [InlineData("/basepath", false, "/basepath/msdk/evalx/contexts/", "")] + [InlineData("/basepath", true, "/basepath/msdk/evalx/contexts/", "?withReasons=true")] + [InlineData("/basepath/", false, "/basepath/msdk/evalx/contexts/", "")] + [InlineData("/basepath/", true, "/basepath/msdk/evalx/contexts/", "?withReasons=true")] + public async Task GetFlagsUsesCorrectUriAndMethodInGetModeAsync( + string baseUriExtraPath, + bool withReasons, + string expectedPathWithoutUser, + string expectedQuery + ) + { + using (var server = HttpServer.Start(Handlers.BodyJson(_allDataJson))) + { + var baseUri = new Uri(server.Uri.ToString().TrimEnd('/') + baseUriExtraPath); + + var config = Configuration.Default(_mobileKey, ConfigurationBuilder.AutoEnvAttributes.Disabled); + + using (var requestor = new FeatureFlagRequestor( + baseUri, + _context, + withReasons, + new LdClientContext(config, _context).Http, + testLogger)) + { + var resp = await requestor.FeatureFlagsAsync(); + Assert.Equal(200, resp.statusCode); + Assert.Equal(_allDataJson, resp.jsonResponse); + + var req = server.Recorder.RequireRequest(); + Assert.Equal("GET", req.Method); + AssertHelpers.ContextsEqual(_context, + TestUtil.Base64ContextFromUrlPath(req.Path, expectedPathWithoutUser)); + Assert.Equal(expectedQuery, req.Query); + Assert.Equal(_mobileKey, req.Headers["Authorization"]); + Assert.Equal("", req.Body); + } + } + } + + // REPORT mode is known to fail in Android (ch47341) +#if !ANDROID + [Theory] + [InlineData("", false, "/msdk/evalx/context", "")] + [InlineData("", true, "/msdk/evalx/context", "?withReasons=true")] + [InlineData("/basepath", false, "/basepath/msdk/evalx/context", "")] + [InlineData("/basepath", true, "/basepath/msdk/evalx/context", "?withReasons=true")] + [InlineData("/basepath/", false, "/basepath/msdk/evalx/context", "")] + [InlineData("/basepath/", true, "/basepath/msdk/evalx/context", "?withReasons=true")] + public async Task GetFlagsUsesCorrectUriAndMethodInReportModeAsync( + string baseUriExtraPath, + bool withReasons, + string expectedPath, + string expectedQuery + ) + { + using (var server = HttpServer.Start(Handlers.BodyJson(_allDataJson))) + { + var baseUri = new Uri(server.Uri.ToString().TrimEnd('/') + baseUriExtraPath); + + var config = Configuration.Builder(_mobileKey, ConfigurationBuilder.AutoEnvAttributes.Disabled) + .Http(Components.HttpConfiguration().UseReport(true)) + .Build(); + + using (var requestor = new FeatureFlagRequestor( + baseUri, + _context, + withReasons, + new LdClientContext(config, _context).Http, + testLogger)) + { + var resp = await requestor.FeatureFlagsAsync(); + Assert.Equal(200, resp.statusCode); + Assert.Equal(_allDataJson, resp.jsonResponse); + + var req = server.Recorder.RequireRequest(); + Assert.Equal("REPORT", req.Method); + Assert.Equal(expectedPath, req.Path); + Assert.Equal(expectedQuery, req.Query); + Assert.Equal(_mobileKey, req.Headers["Authorization"]); + AssertJsonEqual(LdJsonSerialization.SerializeObject(_context), req.Body); + } + } + } +#endif + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataSources/PollingDataSourceTest.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataSources/PollingDataSourceTest.cs new file mode 100644 index 00000000..f6d559bf --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataSources/PollingDataSourceTest.cs @@ -0,0 +1,275 @@ +using System; +using LaunchDarkly.Sdk.Client.Subsystems; +using LaunchDarkly.Sdk.Internal.Concurrent; +using LaunchDarkly.TestHelpers.HttpTest; +using Xunit; +using Xunit.Abstractions; + +using static LaunchDarkly.Sdk.Client.DataModel; +using static LaunchDarkly.Sdk.Client.MockResponses; +using static LaunchDarkly.Sdk.Client.Subsystems.DataStoreTypes; +using static LaunchDarkly.Sdk.Client.TestHttpUtils; + +namespace LaunchDarkly.Sdk.Client.Internal.DataSources +{ + public class PollingDataSourceTest : BaseTest + { + private static readonly FeatureFlag Flag = new FeatureFlagBuilder() + .Version(2).Value(true).Variation(1).Build(); + private static FullDataSet AllData => + new DataSetBuilder().Add("flag1", Flag).Build(); + private static readonly TimeSpan BriefInterval = TimeSpan.FromMilliseconds(20); + private static readonly Context simpleUser = Context.New("me"); + + private readonly MockDataSourceUpdateSink _updateSink = new MockDataSourceUpdateSink(); + + private IDataSource MakeDataSource(Uri baseUri, Context context, Action modConfig = null) + { + var builder = BasicConfig() + .DataSource(Components.PollingDataSource()) + .ServiceEndpoints(Components.ServiceEndpoints().Polling(baseUri)); + modConfig?.Invoke(builder); + var config = builder.Build(); + return config.DataSource.Build(new LdClientContext(config, context).WithDataSourceUpdateSink(_updateSink)); + } + + public PollingDataSourceTest(ITestOutputHelper testOutput) : base(testOutput) { } + + [Theory] + [InlineData("", false, "/msdk/evalx/contexts/", "")] + [InlineData("", true, "/msdk/evalx/contexts/", "?withReasons=true")] + [InlineData("/basepath", false, "/basepath/msdk/evalx/contexts/", "")] + [InlineData("/basepath", true, "/basepath/msdk/evalx/contexts/", "?withReasons=true")] + [InlineData("/basepath/", false, "/basepath/msdk/evalx/contexts/", "")] + [InlineData("/basepath/", true, "/basepath/msdk/evalx/contexts/", "?withReasons=true")] + public void PollingRequestHasCorrectUri( + string baseUriExtraPath, + bool withReasons, + string expectedPathWithoutUser, + string expectedQuery + ) + { + using (var server = HttpServer.Start(PollingResponse(AllData))) + { + var baseUri = new Uri(server.Uri.ToString().TrimEnd('/') + baseUriExtraPath); + using (var dataSource = MakeDataSource(baseUri, simpleUser, + c => c.EvaluationReasons(withReasons))) + { + var task = dataSource.Start(); + + var request = server.Recorder.RequireRequest(); + Assert.Equal("GET", request.Method); + AssertHelpers.ContextsEqual(simpleUser, TestUtil.Base64ContextFromUrlPath(request.Path, expectedPathWithoutUser)); + Assert.Equal(expectedQuery, request.Query); + } + } + } + + [Fact] + public void SuccessfulRequestCausesDataToBeStoredAndDataSourceInitialized() + { + using (var server = HttpServer.Start(PollingResponse(AllData))) + { + using (var dataSource = MakeDataSource(server.Uri, BasicUser)) + { + var initTask = dataSource.Start(); + + var receivedData = _updateSink.ExpectInit(BasicUser); + AssertHelpers.DataSetsEqual(AllData, receivedData); + + Assert.True(AsyncUtils.WaitSafely(() => initTask, TimeSpan.FromSeconds(1))); + Assert.False(initTask.IsFaulted); + Assert.True(dataSource.Initialized); + } + } + } + + [Theory] + [InlineData(401)] + [InlineData(403)] + public void VerifyUnrecoverableHttpError(int errorStatus) + { + var errorCondition = ServerErrorCondition.FromStatus(errorStatus); + + WithServerErrorCondition(errorCondition, null, (uri, httpConfig, recorder) => + { + using (var dataSource = MakeDataSource(uri, BasicUser, + c => c.DataSource(Components.PollingDataSource().PollInterval(BriefInterval)) + .Http(httpConfig))) + { + var initTask = dataSource.Start(); + bool completed = initTask.Wait(TimeSpan.FromSeconds(1)); + Assert.True(completed); + Assert.False(dataSource.Initialized); + + var status = _updateSink.ExpectStatusUpdate(); + errorCondition.VerifyDataSourceStatusError(status); + + recorder.RequireRequest(); + recorder.RequireNoRequests(TimeSpan.FromMilliseconds(100)); // did not retry + + errorCondition.VerifyLogMessage(logCapture); + } + }); + } + + [Theory] + [InlineData(408)] + [InlineData(429)] + [InlineData(500)] + [InlineData(ServerErrorCondition.FakeIOException)] + public void VerifyRecoverableError(int errorStatus) + { + var errorCondition = ServerErrorCondition.FromStatus(errorStatus); + var successResponse = PollingResponse(AllData); + + // Verify that it does not immediately retry the failed request + + WithServerErrorCondition(errorCondition, successResponse, (uri, httpConfig, recorder) => + { + using (var dataSource = MakeDataSource(uri, BasicUser, + c => c.DataSource(Components.PollingDataSource().PollInterval(TimeSpan.FromHours(1))) + .Http(httpConfig))) + { + dataSource.Start(); + + var status = _updateSink.ExpectStatusUpdate(); + errorCondition.VerifyDataSourceStatusError(status); + + recorder.RequireRequest(); + recorder.RequireNoRequests(TimeSpan.FromMilliseconds(100)); + + errorCondition.VerifyLogMessage(logCapture); + } + }); + + // Verify (with a small polling interval) that it does do another request at the next interval + + WithServerErrorCondition(errorCondition, successResponse, (uri, httpConfig, recorder) => + { + using (var dataSource = MakeDataSource(uri, BasicUser, + c => c.DataSource(Components.PollingDataSource().PollIntervalNoMinimum(BriefInterval)) + .Http(httpConfig))) + { + var initTask = dataSource.Start(); + bool completed = initTask.Wait(TimeSpan.FromSeconds(1)); + Assert.True(completed); + Assert.True(dataSource.Initialized); + + var status = _updateSink.ExpectStatusUpdate(); + errorCondition.VerifyDataSourceStatusError(status); + + // We don't check here for a second status update to the Valid state, because that was + // done by DataSourceUpdatesImpl when Init was called - our test fixture doesn't do it. + + recorder.RequireRequest(); + recorder.RequireRequest(); + + errorCondition.VerifyLogMessage(logCapture); + } + }); + } + + [Fact] + public void EtagIsStoredAndSentWithNextRequest() + { + var etag = @"""abc123"""; // note that etag strings must be quoted + var resp = Handlers.Header("Etag", etag).Then(PollingResponse(AllData)); + + using (var server = HttpServer.Start(resp)) + { + using (var dataSource = MakeDataSource(server.Uri, BasicUser, + c => c.DataSource(Components.PollingDataSource().PollIntervalNoMinimum(BriefInterval)))) + { + dataSource.Start(); + + var req1 = server.Recorder.RequireRequest(); + var req2 = server.Recorder.RequireRequest(); + Assert.Null(req1.Headers.Get("If-None-Match")); + Assert.Equal(etag, req2.Headers.Get("If-None-Match")); + } + } + } + + [Fact] + public void InitIsNotRepeatedIfServerReturnsNotModifiedStatus() + { + var etag = @"""abc123"""; // note that etag strings must be quoted + var responses = Handlers.SequentialWithLastRepeating( + Handlers.Header("Etag", etag).Then(PollingResponse(AllData)), + Handlers.Status(304) + ); + + using (var server = HttpServer.Start(responses)) + { + using (var dataSource = MakeDataSource(server.Uri, BasicUser, + c => c.DataSource(Components.PollingDataSource().PollIntervalNoMinimum(BriefInterval)))) + { + dataSource.Start(); + + var receivedData = _updateSink.ExpectInit(BasicUser); + AssertHelpers.DataSetsEqual(AllData, receivedData); + + // We've set it up above so that all requests except the first one return a 304 + // status, so the data source should *not* push a new data set with Init. + _updateSink.ExpectNoMoreActions(); + + var req1 = server.Recorder.RequireRequest(); + var req2 = server.Recorder.RequireRequest(); + var req3 = server.Recorder.RequireRequest(); + Assert.Null(req1.Headers.Get("If-None-Match")); + Assert.Equal(etag, req2.Headers.Get("If-None-Match")); + Assert.Equal(etag, req3.Headers.Get("If-None-Match")); + } + } + } + + [Fact] + public void ResponseWithNewEtagUpdatesEtag() + { + var etag1 = @"""abc123"""; // note that etag strings must be quoted + var etag2 = @"""def456"""; + var data1 = AllData; + var data2 = new DataSetBuilder().Add("flag2", new FeatureFlagBuilder().Build()).Build(); + var data3 = new DataSetBuilder().Add("flag3", new FeatureFlagBuilder().Build()).Build(); + var responses = Handlers.SequentialWithLastRepeating( + Handlers.Header("Etag", etag1).Then(PollingResponse(data1)), + Handlers.Status(304), + Handlers.Header("Etag", etag2).Then(PollingResponse(data2)), + Handlers.Status(304), + PollingResponse(data3) // no etag - even though the server will normally send one + ); + + using (var server = HttpServer.Start(responses)) + { + using (var dataSource = MakeDataSource(server.Uri, BasicUser, + c => c.DataSource(Components.PollingDataSource().PollIntervalNoMinimum(BriefInterval)))) + { + dataSource.Start(); + + var receivedData1 = _updateSink.ExpectInit(BasicUser); + AssertHelpers.DataSetsEqual(data1, receivedData1); + + var receivedData2 = _updateSink.ExpectInit(BasicUser); + AssertHelpers.DataSetsEqual(data2, receivedData2); + + var receivedData3 = _updateSink.ExpectInit(BasicUser); + AssertHelpers.DataSetsEqual(data3, receivedData3); + + var req1 = server.Recorder.RequireRequest(); + var req2 = server.Recorder.RequireRequest(); + var req3 = server.Recorder.RequireRequest(); + var req4 = server.Recorder.RequireRequest(); + var req5 = server.Recorder.RequireRequest(); + var req6 = server.Recorder.RequireRequest(); + Assert.Null(req1.Headers.Get("If-None-Match")); + Assert.Equal(etag1, req2.Headers.Get("If-None-Match")); + Assert.Equal(etag1, req3.Headers.Get("If-None-Match")); + Assert.Equal(etag2, req4.Headers.Get("If-None-Match")); // etag was updated by 3rd response + Assert.Equal(etag2, req5.Headers.Get("If-None-Match")); // etag was updated by 3rd response + Assert.Null(req6.Headers.Get("If-None-Match")); // etag was cleared by 5th response + } + } + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataSources/StreamingDataSourceTest.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataSources/StreamingDataSourceTest.cs new file mode 100644 index 00000000..2f43f4dc --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataSources/StreamingDataSourceTest.cs @@ -0,0 +1,350 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using LaunchDarkly.Sdk.Client.Subsystems; +using LaunchDarkly.Sdk.Internal.Concurrent; +using LaunchDarkly.Sdk.Internal.Events; +using LaunchDarkly.Sdk.Json; +using LaunchDarkly.TestHelpers.HttpTest; +using Xunit; +using Xunit.Abstractions; + +using static LaunchDarkly.Sdk.Client.MockResponses; +using static LaunchDarkly.Sdk.Client.TestHttpUtils; +using static LaunchDarkly.TestHelpers.JsonAssertions; + +namespace LaunchDarkly.Sdk.Client.Internal.DataSources +{ + public class StreamingDataSourceTest : BaseTest + { + private static readonly TimeSpan BriefReconnectDelay = TimeSpan.FromMilliseconds(10); + + private readonly Context simpleUser = Context.New("me"); + + private MockDataSourceUpdateSink _updateSink = new MockDataSourceUpdateSink(); + + public StreamingDataSourceTest(ITestOutputHelper testOutput) : base(testOutput) { } + + private IDataSource MakeDataSource(Uri baseUri, Context context, Action modConfig = null) + { + var builder = BasicConfig() + .DataSource(Components.StreamingDataSource().InitialReconnectDelay(BriefReconnectDelay)) + .ServiceEndpoints(Components.ServiceEndpoints().Streaming(baseUri).Polling(baseUri)); + modConfig?.Invoke(builder); + var config = builder.Build(); + return config.DataSource.Build(new LdClientContext(config, context).WithDataSourceUpdateSink(_updateSink)); + } + + private IDataSource MakeDataSourceWithDiagnostics(Uri baseUri, Context context, IDiagnosticStore diagnosticStore) + { + var config = BasicConfig() + .ServiceEndpoints(Components.ServiceEndpoints().Streaming(baseUri).Polling(baseUri)) + .Build(); + var clientContext = new LdClientContext(config, context).WithDiagnostics(null, diagnosticStore) + .WithDataSourceUpdateSink(_updateSink); + return Components.StreamingDataSource().InitialReconnectDelay(BriefReconnectDelay).Build(clientContext); + } + + private void WithDataSourceAndServer(Handler responseHandler, Action action) + { + using (var server = HttpServer.Start(AllowOnlyStreamRequests(responseHandler))) + { + using (var dataSource = MakeDataSource(server.Uri, BasicUser)) + { + var initTask = dataSource.Start(); + action(dataSource, server, initTask); + } + } + } + + [Theory] + [InlineData("", false, "/meval/", "")] + [InlineData("", true, "/meval/", "?withReasons=true")] + [InlineData("/basepath", false, "/basepath/meval/", "")] + [InlineData("/basepath", true, "/basepath/meval/", "?withReasons=true")] + [InlineData("/basepath/", false, "/basepath/meval/", "")] + [InlineData("/basepath/", true, "/basepath/meval/", "?withReasons=true")] + public void RequestHasCorrectUriAndMethodInGetMode( + string baseUriExtraPath, + bool withReasons, + string expectedPathWithoutUser, + string expectedQuery + ) + { + using (var server = HttpServer.Start(StreamWithEmptyData)) + { + var baseUri = new Uri(server.Uri.ToString().TrimEnd('/') + baseUriExtraPath); + using (var dataSource = MakeDataSource(baseUri, simpleUser, + c => c.EvaluationReasons(withReasons))) + { + dataSource.Start(); + var req = server.Recorder.RequireRequest(); + AssertHelpers.ContextsEqual(simpleUser, TestUtil.Base64ContextFromUrlPath(req.Path, expectedPathWithoutUser)); + Assert.Equal(expectedQuery, req.Query); + Assert.Equal("GET", req.Method); + } + } + } + + // REPORT mode is known to fail in Android (ch47341) +#if !ANDROID + [Theory] + [InlineData("", false, "/meval", "")] + [InlineData("", true, "/meval", "?withReasons=true")] + [InlineData("/basepath", false, "/basepath/meval", "")] + [InlineData("/basepath", true, "/basepath/meval", "?withReasons=true")] + [InlineData("/basepath/", false, "/basepath/meval", "")] + [InlineData("/basepath/", true, "/basepath/meval", "?withReasons=true")] + public void RequestHasCorrectUriAndMethodAndBodyInReportMode( + string baseUriExtraPath, + bool withReasons, + string expectedPath, + string expectedQuery + ) + { + using (var server = HttpServer.Start(StreamWithEmptyData)) + { + var baseUri = new Uri(server.Uri.ToString().TrimEnd('/') + baseUriExtraPath); + using (var dataSource = MakeDataSource(baseUri, simpleUser, + c => c.EvaluationReasons(withReasons) + .Http(Components.HttpConfiguration().UseReport(true)))) + { + dataSource.Start(); + var req = server.Recorder.RequireRequest(); + Assert.Equal(expectedPath, req.Path); + Assert.Equal(expectedQuery, req.Query); + Assert.Equal("REPORT", req.Method); + AssertJsonEqual(LdJsonSerialization.SerializeObject(simpleUser), req.Body); + } + } + } +#endif + + [Fact] + public void PutCausesDataToBeStoredAndDataSourceInitialized() + { + var data = new DataSetBuilder() + .Add("flag1", 1, LdValue.Of(true), 0) + .Build(); + + WithDataSourceAndServer(StreamWithInitialData(data), (dataSource, _, initTask) => + { + var receivedData = _updateSink.ExpectInit(BasicUser); + AssertHelpers.DataSetsEqual(data, receivedData); + + Assert.True(AsyncUtils.WaitSafely(() => initTask, TimeSpan.FromSeconds(1))); + Assert.False(initTask.IsFaulted); + Assert.True(dataSource.Initialized); + }); + } + + [Fact] + public void DataSourceIsNotInitializedByDefault() + { + WithDataSourceAndServer(StreamThatStaysOpenWithNoEvents, (dataSource, _, initTask) => + { + Assert.False(dataSource.Initialized); + Assert.False(initTask.IsCompleted); + }); + } + + [Fact] + public void PatchUpdatesFlag() + { + var flag = new FeatureFlagBuilder().Version(1).Build(); + var patchEvent = PatchEvent(flag.ToJsonString("flag1")); + + WithDataSourceAndServer(StreamWithEmptyInitialDataAndThen(patchEvent), (dataSource, s, t) => + { + _updateSink.ExpectInit(BasicUser); + + var receivedItem = _updateSink.ExpectUpsert(BasicUser, "flag1"); + AssertHelpers.DataItemsEqual(flag.ToItemDescriptor(), receivedItem); + }); + } + + [Fact] + public void DeleteDeletesFlag() + { + var deleteEvent = DeleteEvent("flag1", 2); + + WithDataSourceAndServer(StreamWithEmptyInitialDataAndThen(deleteEvent), (dataSource, s, t) => + { + _updateSink.ExpectInit(BasicUser); + + var receivedItem = _updateSink.ExpectUpsert(BasicUser, "flag1"); + Assert.Null(receivedItem.Item); + Assert.Equal(2, receivedItem.Version); + }); + } + + [Fact] + public void PingCausesPoll() + { + var data = new DataSetBuilder() + .Add("flag1", 1, LdValue.Of(true), 0) + .Build(); + var streamWithPing = Handlers.SSE.Start() + .Then(PingEvent) + .Then(Handlers.SSE.LeaveOpen()); + + using (var pollingServer = HttpServer.Start(PollingResponse(data))) + { + using (var streamingServer = HttpServer.Start(streamWithPing)) + { + using (var dataSource = MakeDataSource(streamingServer.Uri, BasicUser, + c => c.ServiceEndpoints(Components.ServiceEndpoints() + .Streaming(streamingServer.Uri).Polling(pollingServer.Uri)))) + { + var initTask = dataSource.Start(); + + pollingServer.Recorder.RequireRequest(); + + var receivedData = _updateSink.ExpectInit(BasicUser); + AssertHelpers.DataSetsEqual(data, receivedData); + + Assert.True(AsyncUtils.WaitSafely(() => initTask, TimeSpan.FromSeconds(1))); + Assert.False(initTask.IsFaulted); + Assert.True(dataSource.Initialized); + } + } + } + } + + [Theory] + [InlineData(408)] + [InlineData(429)] + [InlineData(500)] + [InlineData(503)] + [InlineData(ServerErrorCondition.FakeIOException)] + public void VerifyRecoverableHttpError(int errorStatus) + { + var errorCondition = ServerErrorCondition.FromStatus(errorStatus); + + WithServerErrorCondition(errorCondition, StreamWithEmptyData, (uri, httpConfig, recorder) => + { + using (var dataSource = MakeDataSource(uri, BasicUser, + c => c.DataSource(Components.StreamingDataSource().InitialReconnectDelay(TimeSpan.Zero)) + .Http(httpConfig))) + { + var initTask = dataSource.Start(); + + var status = _updateSink.ExpectStatusUpdate(); + errorCondition.VerifyDataSourceStatusError(status); + + // We don't check here for a second status update to the Valid state, because that was + // done by DataSourceUpdatesImpl when Init was called - our test fixture doesn't do it. + + _updateSink.ExpectInit(BasicUser); + + recorder.RequireRequest(); + recorder.RequireRequest(); + + Assert.True(AsyncUtils.WaitSafely(() => initTask, TimeSpan.FromSeconds(1))); + + errorCondition.VerifyLogMessage(logCapture); + } + }); + } + + [Theory] + [InlineData(401)] + [InlineData(403)] + public void VerifyUnrecoverableHttpError(int errorStatus) + { + var errorCondition = ServerErrorCondition.FromStatus(errorStatus); + + WithServerErrorCondition(errorCondition, StreamWithEmptyData, (uri, httpConfig, recorder) => + { + using (var dataSource = MakeDataSource(uri, BasicUser, + c => c.DataSource(Components.StreamingDataSource().InitialReconnectDelay(TimeSpan.Zero)) + .Http(httpConfig))) + { + var initTask = dataSource.Start(); + var status = _updateSink.ExpectStatusUpdate(); + errorCondition.VerifyDataSourceStatusError(status); + + _updateSink.ExpectNoMoreActions(); + + recorder.RequireRequest(); + recorder.RequireNoRequests(TimeSpan.FromMilliseconds(100)); + + Assert.True(AsyncUtils.WaitSafely(() => initTask, TimeSpan.FromSeconds(1))); + + errorCondition.VerifyLogMessage(logCapture); + } + }); + } + + [Fact] + public async void StreamInitDiagnosticRecordedOnOpen() + { + var mockDiagnosticStore = new MockDiagnosticStore(); + + using (var server = HttpServer.Start(StreamWithEmptyData)) + { + using (var dataSource = MakeDataSourceWithDiagnostics(server.Uri, BasicUser, mockDiagnosticStore)) + { + await dataSource.Start(); + + var streamInit = mockDiagnosticStore.StreamInits.ExpectValue(); + Assert.False(streamInit.Failed); + } + } + } + + [Fact] + public async void StreamInitDiagnosticRecordedOnError() + { + var mockDiagnosticStore = new MockDiagnosticStore(); + + using (var server = HttpServer.Start(Error401Response)) + { + using (var dataSource = MakeDataSourceWithDiagnostics(server.Uri, BasicUser, mockDiagnosticStore)) + { + await dataSource.Start(); + + var streamInit = mockDiagnosticStore.StreamInits.ExpectValue(); + Assert.True(streamInit.Failed); + } + } + } + + [Fact] + public void UnknownEventTypeDoesNotCauseError() + { + VerifyEventDoesNotCauseStreamRestart("weird", "data"); + } + + private void VerifyEventDoesNotCauseStreamRestart(string eventName, string eventData) + { + // We'll end another event after that event, so we can see when we've got past the first one + var events = Handlers.SSE.Event(eventName, eventData) + .Then(PatchEvent(new FeatureFlagBuilder().Build().ToJsonString("ignore"))); + + DoTestAfterEmptyPut(events, server => + { + _updateSink.ExpectUpsert(BasicUser, "ignore"); + + server.Recorder.RequireNoRequests(TimeSpan.FromMilliseconds(100)); + + Assert.Empty(logCapture.GetMessages().Where(m => m.Level == Logging.LogLevel.Error)); + }); + } + + private void DoTestAfterEmptyPut(Handler contentHandler, Action action) + { + var useContentForFirstRequestOnly = Handlers.Sequential( + StreamWithEmptyInitialDataAndThen(contentHandler), + StreamThatStaysOpenWithNoEvents + ); + WithDataSourceAndServer(useContentForFirstRequestOnly, (dataSource, server, initTask) => + { + _updateSink.ExpectInit(BasicUser); + server.Recorder.RequireRequest(); + + action(server); + }); + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataStores/ContextIndexTest.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataStores/ContextIndexTest.cs new file mode 100644 index 00000000..94e46c79 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataStores/ContextIndexTest.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Immutable; +using Xunit; + +using static LaunchDarkly.TestHelpers.JsonAssertions; + +namespace LaunchDarkly.Sdk.Client.Internal.DataStores +{ + public class ContextIndexTest + { + [Fact] + public void EmptyConstructor() + { + var ui = new ContextIndex(); + Assert.NotNull(ui.Data); + Assert.Empty(ui.Data); + } + + [Fact] + public void Serialize() + { + var ui = new ContextIndex() + .UpdateTimestamp("user1", UnixMillisecondTime.OfMillis(1000)) + .UpdateTimestamp("user2", UnixMillisecondTime.OfMillis(2000)); + + var json = ui.Serialize(); + var expected = @"[[""user1"",1000],[""user2"",2000]]"; + + AssertJsonEqual(expected, json); + } + + [Fact] + public void Deserialize() + { + var json = @"[[""user1"",1000],[""user2"",2000]]"; + var ui = ContextIndex.Deserialize(json); + + Assert.NotNull(ui.Data); + Assert.Collection(ui.Data, + AssertEntry("user1", 1000), + AssertEntry("user2", 2000)); + } + + [Fact] + public void DeserializeMalformedJson() + { + Assert.ThrowsAny(() => + ContextIndex.Deserialize("}")); + + Assert.ThrowsAny(() => + ContextIndex.Deserialize("[")); + + Assert.ThrowsAny(() => + ContextIndex.Deserialize("[[true,1000]]")); + + Assert.ThrowsAny(() => + ContextIndex.Deserialize(@"[[""user1"",false]]")); + + Assert.ThrowsAny(() => + ContextIndex.Deserialize("[3]")); + } + + [Fact] + public void UpdateTimestampForExistingUser() + { + var ui = new ContextIndex() + .UpdateTimestamp("user1", UnixMillisecondTime.OfMillis(1000)) + .UpdateTimestamp("user2", UnixMillisecondTime.OfMillis(2000)); + + ui = ui.UpdateTimestamp("user1", UnixMillisecondTime.OfMillis(2001)); + + Assert.Collection(ui.Data, + AssertEntry("user2", 2000), + AssertEntry("user1", 2001)); + } + + [Fact] + public void PruneRemovesLeastRecentUsers() + { + var ui = new ContextIndex() + .UpdateTimestamp("user1", UnixMillisecondTime.OfMillis(1000)) + .UpdateTimestamp("user2", UnixMillisecondTime.OfMillis(2000)) + .UpdateTimestamp("user3", UnixMillisecondTime.OfMillis(1111)) // deliberately out of order + .UpdateTimestamp("user4", UnixMillisecondTime.OfMillis(3000)) + .UpdateTimestamp("user5", UnixMillisecondTime.OfMillis(4000)); + + var ui1 = ui.Prune(3, out var removed); + Assert.Equal(ImmutableList.Create("user1", "user3"), removed); + Assert.Collection(ui1.Data, + AssertEntry("user2", 2000), + AssertEntry("user4", 3000), + AssertEntry("user5", 4000)); + } + + [Fact] + public void PruneWhenLimitIsNotExceeded() + { + var ui = new ContextIndex() + .UpdateTimestamp("user1", UnixMillisecondTime.OfMillis(1000)) + .UpdateTimestamp("user2", UnixMillisecondTime.OfMillis(2000)); + + Assert.Same(ui, ui.Prune(3, out var removed1)); + Assert.Empty(removed1); + + Assert.Same(ui, ui.Prune(2, out var removed2)); + Assert.Empty(removed2); + } + + private Action AssertEntry(string id, int millis) => + e => + { + Assert.Equal(id, e.ContextId); + Assert.Equal(UnixMillisecondTime.OfMillis(millis), e.Timestamp); + }; + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataStores/FlagDataManagerTest.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataStores/FlagDataManagerTest.cs new file mode 100644 index 00000000..d2cd44d6 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataStores/FlagDataManagerTest.cs @@ -0,0 +1,205 @@ +using Xunit; +using Xunit.Abstractions; + +using static LaunchDarkly.Sdk.Client.Subsystems.DataStoreTypes; + +namespace LaunchDarkly.Sdk.Client.Internal.DataStores +{ + public class FlagDataManagerTest : BaseTest + { + private readonly FlagDataManager _store; + + public FlagDataManagerTest(ITestOutputHelper testOutput) : base(testOutput) + { + _store = new FlagDataManager(BasicMobileKey, null, testLogger); + } + + [Fact] + public void GetCachedDataReturnsNullWithPersistenceDisabled() + { + Assert.Null(_store.GetCachedData(BasicUser)); + } + + [Fact] + public void PersistentStoreIsNullWithPersistenceDisabled() + { + Assert.Null(_store.PersistentStore); + } + + [Fact] + public void GetUnknownFlagWhenNotInitialized() + { + Assert.Null(_store.Get("flagkey")); + } + + [Fact] + public void GetUnknownFlagKeyAfterInitialized() + { + var initData = new DataSetBuilder() + .Add("flag1", 1, LdValue.Of(true), 0) + .Build(); + _store.Init(BasicUser, initData, false); + + Assert.Null(_store.Get("flag2")); + } + + [Fact] + public void GetKnownFlag() + { + var flag1 = new FeatureFlagBuilder().Version(100).Value(LdValue.Of(true)).Build(); + var initData = new DataSetBuilder() + .Add("flag1", flag1) + .Build(); + _store.Init(BasicUser, initData, false); + + Assert.Equal(flag1.ToItemDescriptor(), _store.Get("flag1")); + } + + [Fact] + public void GetDeletedFlagForKnownUser() + { + var initData = new DataSetBuilder() + .AddDeleted("flag1", 200) + .Build(); + _store.Init(BasicUser, initData, false); + + Assert.Equal(new ItemDescriptor(200, null), _store.Get("flag1")); + } + + [Fact] + public void GetAllWhenNotInitialized() + { + var data = _store.GetAll(); + Assert.NotNull(data); + AssertHelpers.DataSetsEqual(DataSetBuilder.Empty, data.Value); + } + + [Fact] + public void GetAllWithEmptyFlags() + { + _store.Init(BasicUser, DataSetBuilder.Empty, false); + + var data = _store.GetAll(); + Assert.NotNull(data); + AssertHelpers.DataSetsEqual(DataSetBuilder.Empty, data.Value); + } + + [Fact] + public void GetAllReturnsFlags() + { + var initData = new DataSetBuilder() + .Add("flag1", 1, LdValue.Of(true), 0) + .Add("flag2", 2, LdValue.Of(false), 1) + .Build(); + _store.Init(BasicUser, initData, false); + + var data = _store.GetAll(); + Assert.NotNull(data); + AssertHelpers.DataSetsEqual(initData, data.Value); + } + + [Fact] + public void UpsertAddsFlag() + { + var flag1 = new FeatureFlagBuilder().Version(100).Value(LdValue.Of(true)).Build(); + var flag2 = new FeatureFlagBuilder().Version(200).Value(LdValue.Of(false)).Build(); + var initData = new DataSetBuilder() + .Add("flag1", flag1) + .Build(); + _store.Init(BasicUser, initData, false); + + var updated = _store.Upsert("flag2", flag2.ToItemDescriptor()); + Assert.True(updated); + + Assert.Equal(flag2.ToItemDescriptor(), _store.Get("flag2")); + } + + [Fact] + public void UpsertUpdatesFlag() + { + var flag1a = new FeatureFlagBuilder().Version(100).Value(LdValue.Of(true)).Build(); + var initData = new DataSetBuilder() + .Add("flag1", flag1a) + .Build(); + _store.Init(BasicUser, initData, false); + + var flag1b = new FeatureFlagBuilder().Version(101).Value(LdValue.Of(false)).Build(); + var updated = _store.Upsert("flag1", flag1b.ToItemDescriptor()); + Assert.True(updated); + + Assert.Equal(flag1b.ToItemDescriptor(), _store.Get("flag1")); + } + + [Fact] + public void UpsertDeletesFlag() + { + var flag1 = new FeatureFlagBuilder().Version(100).Value(LdValue.Of(true)).Build(); + var initData = new DataSetBuilder() + .Add("flag1", flag1) + .Build(); + _store.Init(BasicUser, initData, false); + + var flag1Deleted = new ItemDescriptor(101, null); + var updated = _store.Upsert("flag1", flag1Deleted); + Assert.True(updated); + + Assert.Equal(flag1Deleted, _store.Get("flag1")); + } + + [Fact] + public void UpsertUndeletesFlag() + { + var initData = new DataSetBuilder() + .AddDeleted("flag1", 100) + .Build(); + _store.Init(BasicUser, initData, false); + + var flag1 = new FeatureFlagBuilder().Version(101).Value(LdValue.Of(true)).Build(); + + var updated = _store.Upsert("flag1", flag1.ToItemDescriptor()); + Assert.True(updated); + + Assert.Equal(flag1.ToItemDescriptor(), _store.Get("flag1")); + } + + [Theory] + [InlineData(100, 100)] + [InlineData(100, 99)] + public void UpsertDoesNotUpdateFlagWithEqualOrLowerVersion(int previousVersion, int newVersion) + { + var flag1a = new FeatureFlagBuilder().Version(previousVersion).Value(LdValue.Of(true)).Build(); + var initData = new DataSetBuilder() + .Add("flag1", flag1a) + .Build(); + + _store.Init(BasicUser, initData, false); + + var flag1b = new FeatureFlagBuilder().Version(newVersion).Value(LdValue.Of(false)).Build(); + + var updated = _store.Upsert("flag1", flag1b.ToItemDescriptor()); + Assert.False(updated); + + Assert.Equal(flag1a.ToItemDescriptor(), _store.Get("flag1")); + } + + [Theory] + [InlineData(100, 100)] + [InlineData(100, 99)] + public void UpsertDoesNotDeleteFlagWithEqualOrLowerVersion(int previousVersion, int newVersion) + { + var flag1a = new FeatureFlagBuilder().Version(previousVersion).Value(LdValue.Of(true)).Build(); + var initData = new DataSetBuilder() + .Add("flag1", flag1a) + .Build(); + + _store.Init(BasicUser, initData, false); + + var deletedDesc = new ItemDescriptor(newVersion, null); + + var updated = _store.Upsert("flag1", deletedDesc); + Assert.False(updated); + + Assert.Equal(flag1a.ToItemDescriptor(), _store.Get("flag1")); + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataStores/FlagDataManagerWithPersistenceTest.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataStores/FlagDataManagerWithPersistenceTest.cs new file mode 100644 index 00000000..11971e57 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataStores/FlagDataManagerWithPersistenceTest.cs @@ -0,0 +1,253 @@ +using System.Collections.Immutable; +using System.Linq; +using Xunit; +using Xunit.Abstractions; + +using static LaunchDarkly.Sdk.Client.Subsystems.DataStoreTypes; + +namespace LaunchDarkly.Sdk.Client.Internal.DataStores +{ + public class FlagDataManagerWithPersistenceTest : BaseTest + { + private static readonly Context OtherUser = Context.New("other-user"); + private static readonly FullDataSet DataSet1 = new DataSetBuilder() + .Add("flag1", 1, LdValue.Of(true), 0) + .Add("flag2", 2, LdValue.Of(false), 1) + .Build(); + private static readonly FullDataSet DataSet2 = new DataSetBuilder() + .Add("flag3", 1, LdValue.Of(true), 0) + .Build(); + + private readonly MockPersistentDataStore _persistentStore; + + public FlagDataManagerWithPersistenceTest(ITestOutputHelper testOutput) : base(testOutput) + { + _persistentStore = new MockPersistentDataStore(); + } + + internal FlagDataManager MakeStore(int maxCachedUsers) => + new FlagDataManager(BasicMobileKey, + new PersistenceConfiguration(_persistentStore, maxCachedUsers), testLogger); + + [Fact] + public void PersistentStoreIsNullIfMaxCachedUsersIsZero() + { + Assert.Null(MakeStore(0).PersistentStore); + } + + [Fact] + public void PersistentStoreIsNullWithNoOpStoreImplementation() + { + var store = new FlagDataManager(BasicMobileKey, + new PersistenceConfiguration(NullPersistentDataStore.Instance, 5), testLogger); + Assert.Null(store.PersistentStore); + } + + [Fact] + public void PersistentStoreIsNotNullWithValidStoreImplementationAndNonZeroUsers() + { + Assert.NotNull(MakeStore(5).PersistentStore); + } + + [Fact] + public void GetCachedDataForUnknownUser() + { + var store = MakeStore(1); + Assert.Null(store.GetCachedData(BasicUser)); + } + + [Fact] + public void GetCachedDataForKnownUser() + { + _persistentStore.SetupUserData(BasicMobileKey, BasicUser.Key, DataSet1); + _persistentStore.SetupUserData(BasicMobileKey, OtherUser.Key, DataSet2); + + var store = MakeStore(1); + + var data = store.GetCachedData(BasicUser); + Assert.NotNull(data); + AssertHelpers.DataSetsEqual(DataSet1, data.Value); + } + + [Fact] + public void InitWritesToPersistentStoreIfToldTo() + { + var store = MakeStore(1); + store.Init(BasicUser, DataSet1, true); + + var data = _persistentStore.InspectUserData(BasicMobileKey, BasicUser.Key); + Assert.NotNull(data); + AssertHelpers.DataSetsEqual(DataSet1, data.Value); + } + + [Fact] + public void InitDoesNotWriteToPersistentStoreIfToldNotTo() + { + _persistentStore.SetupUserData(BasicMobileKey, BasicUser.Key, DataSet1); + + var store = MakeStore(1); + + var data = store.GetCachedData(BasicUser); + Assert.NotNull(data); + AssertHelpers.DataSetsEqual(DataSet1, data.Value); + + // Hack the underlying store to remove the data so we can tell if it gets rewritten + _persistentStore.SetupUserData(BasicMobileKey, BasicUser.Key, DataSetBuilder.Empty); + + store.Init(BasicUser, data.Value, false); + + // Because we passed false in Init, it does not rewrite the data - this behavior is to + // avoid unnecessary writes on startup when we've just read the data from the cache. + var underlyingData = _persistentStore.InspectUserData(BasicMobileKey, BasicUser.Key); + Assert.NotNull(underlyingData); + AssertHelpers.DataSetsEqual(DataSetBuilder.Empty, underlyingData.Value); + } + + [Fact] + public void InitUpdatesIndex() + { + var store = MakeStore(2); + + store.Init(BasicUser, DataSet1, true); + store.Init(OtherUser, DataSet2, true); + + var index = _persistentStore.InspectContextIndex(BasicMobileKey); + Assert.Equal( + ImmutableList.Create(Base64.UrlSafeSha256Hash(BasicUser.Key), Base64.UrlSafeSha256Hash(OtherUser.Key)), + index.Data.Select(e => e.ContextId).ToImmutableList() + ); + } + + [Fact] + public void InitEvictsLeastRecentUser() + { + var dataSet3 = new DataSetBuilder() + .Add("flag4", 4, LdValue.Of(false), 1) + .Build(); + var user3 = Context.New("third-user"); + + var store = MakeStore(2); + store.Init(BasicUser, DataSet1, true); + store.Init(OtherUser, DataSet2, true); + store.Init(user3, dataSet3, true); + + var index = _persistentStore.InspectContextIndex(BasicMobileKey); + Assert.Equal( + ImmutableList.Create(Base64.UrlSafeSha256Hash(OtherUser.Key), Base64.UrlSafeSha256Hash(user3.Key)), + index.Data.Select(e => e.ContextId).ToImmutableList() + ); + } + + [Fact] + public void GetDoesNotReadFromPersistentStore() + { + var flag1a = new FeatureFlagBuilder().Version(1).Value(true).Build(); + var flag1b = new FeatureFlagBuilder().Version(2).Value(false).Build(); + var data1a = new DataSetBuilder().Add("flag1", flag1a).Build(); + var data1b = new DataSetBuilder().Add("flag1", flag1b).Build(); + + var store = MakeStore(1); + store.Init(BasicUser, data1a, true); + + // Hack the underlying store to change the data so we can verify it isn't being reread + _persistentStore.SetupUserData(BasicMobileKey, BasicUser.Key, data1b); + + var item = store.Get("flag1"); + Assert.Equal(flag1a.ToItemDescriptor(), item); + } + + [Fact] + public void GetAllDoesNotReadFromPersistentStore() + { + var flag1 = new FeatureFlagBuilder().Version(1).Value(true).Build(); + var flag2 = new FeatureFlagBuilder().Version(2).Value(false).Build(); + var data1 = new DataSetBuilder().Add("flag1", flag1).Build(); + var data2 = new DataSetBuilder().Add("flag2", flag2).Build(); + + var store = MakeStore(1); + store.Init(BasicUser, data1, true); + + // Hack the underlying store to change the data so we can verify it isn't being reread + _persistentStore.SetupUserData(BasicMobileKey, BasicUser.Key, data2); + + var data = store.GetAll(); + Assert.NotNull(data); + AssertHelpers.DataSetsEqual(data1, data.Value); + } + + [Fact] + public void UpsertUpdatesPersistentStore() + { + var flag1a = new FeatureFlagBuilder().Version(1).Value(true).Build(); + var flag1b = new FeatureFlagBuilder().Version(2).Value(true).Build(); + var flag2 = new FeatureFlagBuilder().Version(1).Value(false).Build(); + var data1a = new DataSetBuilder().Add("flag1", flag1a).Add("flag2", flag2).Build(); + var data1b = new DataSetBuilder().Add("flag1", flag1b).Add("flag2", flag2).Build(); + + var store = MakeStore(1); + store.Init(BasicUser, data1a, true); + + var updated = store.Upsert("flag1", flag1b.ToItemDescriptor()); + Assert.True(updated); + + var item = store.Get("flag1"); // this is reading only from memory, not the persistent store + Assert.Equal(flag1b.ToItemDescriptor(), item); + + var data = _persistentStore.InspectUserData(BasicMobileKey, BasicUser.Key); + Assert.NotNull(data); + AssertHelpers.DataSetsEqual(data1b, data.Value); + } + + [Fact] + public void UpsertDoesNotUpdatePersistentStoreIfUpdateIsUnsuccessful() + { + var flag1a = new FeatureFlagBuilder().Version(100).Value(true).Build(); + var flag1b = new FeatureFlagBuilder().Version(99).Value(true).Build(); + var flag2 = new FeatureFlagBuilder().Version(1).Value(false).Build(); + var data1a = new DataSetBuilder().Add("flag1", flag1a).Add("flag2", flag2).Build(); + + var store = MakeStore(1); + store.Init(BasicUser, data1a, true); + + var updated = store.Upsert("flag1", flag1b.ToItemDescriptor()); + Assert.False(updated); + + var item = store.Get("flag1"); // this is reading only from memory, not the persistent store + Assert.Equal(flag1a.ToItemDescriptor(), item); + + var data = _persistentStore.InspectUserData(BasicMobileKey, BasicUser.Key); + Assert.NotNull(data); + AssertHelpers.DataSetsEqual(data1a, data.Value); + } + + [Fact] + public void FlagsAreStoredByFullyQualifiedKeyForSingleAndMultiKindContexts() + { + // This tests that we are correctly disambiguating contexts based on their FullyQualifiedKey, + // which includes both key and kind information (and, for multi-kind contexts, is based on + // concatenating the individual kinds). If we were using only the Key property, these users + // would collide. + var contexts = new Context[] + { + Context.New("key1"), + Context.New("key2"), + Context.New(ContextKind.Of("kind2"), "key1"), + Context.NewMulti(Context.New(ContextKind.Of("kind1"), "key1"), Context.New(ContextKind.Of("kind2"), "key2")), + Context.NewMulti(Context.New(ContextKind.Of("kind1"), "key1"), Context.New(ContextKind.Of("kind2"), "key3")) + }; + var store = MakeStore(contexts.Length); + for (var i = 0; i < contexts.Length; i++) + { + var initData = new DataSetBuilder().Add("flag", 1, LdValue.Of(i), 0).Build(); + store.Init(contexts[i], initData, true); + } + for (var i = 0; i < contexts.Length; i++) + { + var data = store.GetCachedData(contexts[i]); + Assert.NotNull(data); + var flagValue = data.Value.Items[0].Value.Item.Value; + Assert.Equal(LdValue.Of(i), flagValue); + } + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataStores/PersistentDataStoreWrapperTest.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataStores/PersistentDataStoreWrapperTest.cs new file mode 100644 index 00000000..c6bd888a --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataStores/PersistentDataStoreWrapperTest.cs @@ -0,0 +1,118 @@ +using Xunit; +using Xunit.Abstractions; + +using static LaunchDarkly.TestHelpers.JsonAssertions; + +namespace LaunchDarkly.Sdk.Client.Internal.DataStores +{ + public class PersistentDataStoreWrapperTest : BaseTest + { + // This verifies non-platform-dependent behavior, such as what keys we store particular + // things under, using a mock persistent storage implementation. + + private static readonly string MobileKeyHash = Base64.UrlSafeSha256Hash(BasicMobileKey); + private static readonly string ExpectedGlobalNamespace = "LaunchDarkly"; + private static readonly string ExpectedEnvironmentNamespace = "LaunchDarkly_" + MobileKeyHash; + private const string UserKey = "user-key"; + private static readonly string UserHash = Base64.UrlSafeSha256Hash(UserKey); + private static readonly string ExpectedUserFlagsKey = "flags_" + UserHash; + private static readonly string ExpectedIndexKey = "index"; + private static readonly string ExpectedGeneratedContextKey = "anonUser"; + + private readonly MockPersistentDataStore _persistentStore; + private readonly PersistentDataStoreWrapper _wrapper; + + public PersistentDataStoreWrapperTest(ITestOutputHelper testOutput) : base(testOutput) + { + _persistentStore = new MockPersistentDataStore(); + _wrapper = new PersistentDataStoreWrapper( + _persistentStore, + BasicMobileKey, + testLogger + ); + } + + [Fact] + public void GetContextDataForUnknownContext() + { + var data = _wrapper.GetContextData(UserKey); + Assert.Null(data); + Assert.Empty(logCapture.GetMessages()); + } + + [Fact] + public void GetContextDataForKnownContextWithValidData() + { + var expectedData = new DataSetBuilder().Add("flagkey", 1, LdValue.Of(true), 0).Build(); + var serializedData = expectedData.ToJsonString(); + _persistentStore.SetValue(ExpectedEnvironmentNamespace, ExpectedUserFlagsKey, serializedData); + + var data = _wrapper.GetContextData(UserHash); + Assert.NotNull(data); + AssertHelpers.DataSetsEqual(expectedData, data.Value); + Assert.Empty(logCapture.GetMessages()); + } + + [Fact] + public void SetUserData() + { + var data = new DataSetBuilder().Add("flagkey", 1, LdValue.Of(true), 0).Build(); + + _wrapper.SetContextData(UserHash, data); + + var serializedData = _persistentStore.GetValue(ExpectedEnvironmentNamespace, ExpectedUserFlagsKey); + AssertJsonEqual(data.ToJsonString(), serializedData); + } + + [Fact] + public void RemoveUserData() + { + var data = new DataSetBuilder().Add("flagkey", 1, LdValue.Of(true), 0).Build(); + + _wrapper.SetContextData(UserHash, data); + Assert.NotNull(_persistentStore.GetValue(ExpectedEnvironmentNamespace, ExpectedUserFlagsKey)); + + _wrapper.RemoveContextData(UserHash); + Assert.Null(_persistentStore.GetValue(ExpectedEnvironmentNamespace, ExpectedUserFlagsKey)); + } + + [Fact] + public void GetIndex() + { + var expectedIndex = new ContextIndex().UpdateTimestamp("user1", UnixMillisecondTime.OfMillis(1000)); + _persistentStore.SetValue(ExpectedEnvironmentNamespace, ExpectedIndexKey, expectedIndex.Serialize()); + + var index = _wrapper.GetIndex(); + AssertJsonEqual(expectedIndex.Serialize(), index.Serialize()); + } + + [Fact] + public void SetIndex() + { + var index = new ContextIndex().UpdateTimestamp("user1", UnixMillisecondTime.OfMillis(1000)); + + _wrapper.SetIndex(index); + + var serializedData = _persistentStore.GetValue(ExpectedEnvironmentNamespace, ExpectedIndexKey); + AssertJsonEqual(index.Serialize(), serializedData); + } + + [Fact] + public void GetGeneratedContextKey() + { + _persistentStore.SetValue(ExpectedGlobalNamespace, ExpectedGeneratedContextKey, "key1"); + _persistentStore.SetValue(ExpectedGlobalNamespace, ExpectedGeneratedContextKey + ":org", "key2"); + Assert.Equal("key1", _wrapper.GetGeneratedContextKey(ContextKind.Default)); + Assert.Equal("key2", _wrapper.GetGeneratedContextKey(ContextKind.Of("org"))); + } + + [Fact] + public void SetGeneratedContextKey() + { + _wrapper.SetGeneratedContextKey(ContextKind.Default, "key1"); + _wrapper.SetGeneratedContextKey(ContextKind.Of("org"), "key2"); + Assert.Equal("key1", _persistentStore.GetValue(ExpectedGlobalNamespace, ExpectedGeneratedContextKey)); + Assert.Equal("key2", _persistentStore.GetValue(ExpectedGlobalNamespace, ExpectedGeneratedContextKey + ":org")); + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataStores/PlatformLocalStorageTest.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataStores/PlatformLocalStorageTest.cs new file mode 100644 index 00000000..7fcccf4f --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataStores/PlatformLocalStorageTest.cs @@ -0,0 +1,100 @@ +using System.Linq; +using LaunchDarkly.Sdk.Client.Subsystems; +using Xunit; +using Xunit.Abstractions; + +namespace LaunchDarkly.Sdk.Client.Internal.DataStores +{ + public class PlatformLocalStorageTest : BaseTest + { + private const string TestNamespacePrefix = "LaunchDarkly.PlatformLocalStorageTest."; + private const string TestNamespace1 = TestNamespacePrefix + "Things1"; + private const string TestNamespace2 = TestNamespacePrefix + "Things2"; + private const string TestNamespaceThatIsNeverSet = TestNamespacePrefix + "Unused"; + private const string TestKeyThatIsNeverSet = "unused-key"; + + private static readonly IPersistentDataStore _storage = PlatformSpecific.LocalStorage.Instance; + + public PlatformLocalStorageTest(ITestOutputHelper testOutput) : base(testOutput) { } + + [Fact] + public void GetValueUnknownNamespace() + { + _storage.SetValue(TestNamespace1, "key1", "x"); + Assert.Null(_storage.GetValue(TestNamespaceThatIsNeverSet, "key1")); + } + + [Fact] + public void GetValueUnknownKey() + { + _storage.SetValue(TestNamespace1, "key1", "x"); + Assert.Null(_storage.GetValue(TestNamespace1, TestKeyThatIsNeverSet)); + } + + [Fact] + public void GetAndSetValues() + { + _storage.SetValue(TestNamespace1, "key1", "value1a"); + _storage.SetValue(TestNamespace1, "key2", "value2a"); + _storage.SetValue(TestNamespace2, "key1", "value1b"); + _storage.SetValue(TestNamespace2, "key2", "value2b"); + + Assert.Equal("value1a", _storage.GetValue(TestNamespace1, "key1")); + Assert.Equal("value2a", _storage.GetValue(TestNamespace1, "key2")); + Assert.Equal("value1b", _storage.GetValue(TestNamespace2, "key1")); + Assert.Equal("value2b", _storage.GetValue(TestNamespace2, "key2")); + } + + [Fact] + public void RemoveValues() + { + _storage.SetValue(TestNamespace1, "key1", "value1a"); + _storage.SetValue(TestNamespace1, "key2", "value2a"); + _storage.SetValue(TestNamespace2, "key1", "value1b"); + _storage.SetValue(TestNamespace2, "key2", "value2b"); + + _storage.SetValue(TestNamespace1, "key1", null); + _storage.SetValue(TestNamespace2, "key2", null); + + Assert.Null(_storage.GetValue(TestNamespace1, "key1")); + Assert.Equal("value2a", _storage.GetValue(TestNamespace1, "key2")); + Assert.Equal("value1b", _storage.GetValue(TestNamespace2, "key1")); + Assert.Null(_storage.GetValue(TestNamespace2, "key2")); + } + + [Fact] + public void RemoveUnknownKey() + { + _storage.SetValue(TestNamespace1, "key1", "x"); + _storage.SetValue(TestNamespace1, "key2", null); + + Assert.Equal("x", _storage.GetValue(TestNamespace1, "key1")); + } + + [Fact] + public void KeysWithSpecialCharacters() + { + var keys = new string[] + { + "-", + "_", + "key-with-dashes", + "key_with_underscores" + }; + var keysAndValues = keys.ToDictionary(key => key, key => "value-" + key); + foreach (var k in keysAndValues.Keys) + { + var ns = TestNamespacePrefix + nameof(KeysWithSpecialCharacters) + k; + foreach (var kv in keysAndValues) + { + testLogger.Info("*** setting {0} to {1}", kv.Key, kv.Value); + _storage.SetValue(ns, kv.Key, kv.Value); + } + foreach (var kv in keysAndValues) + { + Assert.Equal(kv.Value, _storage.GetValue(ns, kv.Key)); + } + } + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LDClientEndToEndTests.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LDClientEndToEndTests.cs new file mode 100644 index 00000000..6f8f3634 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LDClientEndToEndTests.cs @@ -0,0 +1,600 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using LaunchDarkly.Sdk.Client.Interfaces; +using LaunchDarkly.Sdk.Client.Subsystems; +using LaunchDarkly.TestHelpers.HttpTest; +using Xunit; +using Xunit.Abstractions; + +using static LaunchDarkly.Sdk.Client.Subsystems.DataStoreTypes; +using static LaunchDarkly.Sdk.Client.MockResponses; + +namespace LaunchDarkly.Sdk.Client +{ + // Tests of an LDClient instance doing actual HTTP against an embedded server. These aren't intended to cover + // every possible type of interaction, since the lower-level component tests like FeatureFlagRequestorTests + // (and the DefaultEventProcessor and StreamManager tests in LaunchDarkly.CommonSdk) cover those more thoroughly. + // These are more of a smoke test to ensure that the SDK is initializing and using those components in the + // expected ways. + public class LdClientEndToEndTests : BaseTest + { + private static readonly Context _user = Context.New("foo"); + private static readonly Context _otherUser = Context.New("bar"); + + private static readonly FullDataSet _flagData1 = new DataSetBuilder() + .Add("flag1", 1, LdValue.Of("value1"), 0) + .Build(); + + private static readonly FullDataSet _flagData2 = new DataSetBuilder() + .Add("flag1", 2, LdValue.Of("value2"), 1) + .Build(); + + public static readonly IEnumerable PollingAndStreaming = new List + { + { new object[] { UpdateMode.Polling } }, + { new object[] { UpdateMode.Streaming } } + }; + + public LdClientEndToEndTests(ITestOutputHelper testOutput) : base(testOutput) { } + + [Theory] + [MemberData(nameof(PollingAndStreaming))] + public void InitGetsFlagsSync(UpdateMode mode) + { + using (var server = HttpServer.Start(SetupResponse(_flagData1, mode))) + { + var config = BaseConfig(server.Uri, mode); + using (var client = TestUtil.CreateClient(config, _user, TimeSpan.FromSeconds(10))) + { + VerifyRequest(server.Recorder, mode); + VerifyFlagValues(client, _flagData1); + } + } + } + + [Theory] + [MemberData(nameof(PollingAndStreaming))] + public async Task InitGetsFlagsAsync(UpdateMode mode) + { + using (var server = HttpServer.Start(SetupResponse(_flagData1, mode))) + { + var config = BaseConfig(server.Uri, mode); + using (var client = await TestUtil.CreateClientAsync(config, _user)) + { + VerifyRequest(server.Recorder, mode); + } + } + } + + [Fact] + public void StreamingInitMakesPollRequestIfStreamSendsPing() + { + Handler streamHandler = Handlers.SSE.Start() + .Then(Handlers.SSE.Event("ping", "")) + .Then(Handlers.SSE.LeaveOpen()); + using (var streamServer = HttpServer.Start(streamHandler)) + { + using (var pollServer = HttpServer.Start(SetupResponse(_flagData1, UpdateMode.Polling))) + { + var config = BaseConfig(b => + b.DataSource(Components.StreamingDataSource()) + .ServiceEndpoints(Components.ServiceEndpoints().Streaming(streamServer.Uri).Polling(pollServer.Uri))); + using (var client = TestUtil.CreateClient(config, _user, TimeSpan.FromSeconds(5))) + { + VerifyRequest(streamServer.Recorder, UpdateMode.Streaming); + VerifyRequest(pollServer.Recorder, UpdateMode.Polling); + VerifyFlagValues(client, _flagData1); + } + } + } + } + + [Fact] + public void InitCanTimeOutSync() + { + var handler = Handlers.Delay(TimeSpan.FromSeconds(2)).Then(SetupResponse(_flagData1, UpdateMode.Polling)); + using (var server = HttpServer.Start(handler)) + { + var config = BaseConfig(builder => + builder.DataSource(Components.PollingDataSource()) + .ServiceEndpoints(Components.ServiceEndpoints().Polling(server.Uri))); + using (var client = TestUtil.CreateClient(config, _user, TimeSpan.FromMilliseconds(200))) + { + Assert.False(client.Initialized); + Assert.Null(client.StringVariation(_flagData1.Items.First().Key, null)); + Assert.True(logCapture.HasMessageWithText(Logging.LogLevel.Warn, + "Client did not initialize within 200 milliseconds.")); + } + } + } + + [Fact] + public async void InitCanTimeOutAsync() + { + var handler = Handlers.Delay(TimeSpan.FromSeconds(2)).Then(SetupResponse(_flagData1, UpdateMode.Polling)); + using (var server = HttpServer.Start(handler)) + { + var config = BaseConfig(builder => + builder.DataSource(Components.PollingDataSource()) + .ServiceEndpoints(Components.ServiceEndpoints().Polling(server.Uri))); + using (var client = await TestUtil.CreateClientAsync(config, _user, TimeSpan.FromMilliseconds(200))) + { + Assert.False(client.Initialized); + Assert.Null(client.StringVariation(_flagData1.Items.First().Key, null)); + Assert.True(logCapture.HasMessageWithText(Logging.LogLevel.Warn, + "Client did not initialize within 200 milliseconds.")); + } + } + } + + [Theory] + [MemberData(nameof(PollingAndStreaming))] + public void InitFailsOn401Sync(UpdateMode mode) + { + using (var server = HttpServer.Start(Handlers.Status(401))) + { + var config = BaseConfig(server.Uri, mode); + using (var client = TestUtil.CreateClient(config, _user)) + { + Assert.False(client.Initialized); + } + } + } + + [Theory] + [MemberData(nameof(PollingAndStreaming))] + public async Task InitFailsOn401Async(UpdateMode mode) + { + using (var server = HttpServer.Start(Handlers.Status(401))) + { + var config = BaseConfig(server.Uri, mode); + + // Currently the behavior of LdClient.InitAsync is somewhat inconsistent with LdClient.Init if there is + // an unrecoverable error: LdClient.Init throws an exception, but LdClient.InitAsync returns a task that + // will complete successfully with an uninitialized client. + using (var client = await TestUtil.CreateClientAsync(config, _user)) + { + Assert.False(client.Initialized); + } + } + } + + [Theory] + [MemberData(nameof(PollingAndStreaming))] + public void IdentifySwitchesUserAndGetsFlagsSync(UpdateMode mode) + { + using (var server = HttpServer.Start(Handlers.Switchable(out var switchable))) + { + switchable.Target = SetupResponse(_flagData1, mode); + + var config = BaseConfig(server.Uri, mode); + using (var client = TestUtil.CreateClient(config, _user)) + { + var req1 = VerifyRequest(server.Recorder, mode); + VerifyFlagValues(client, _flagData1); + var user1RequestPath = req1.Path; + + switchable.Target = SetupResponse(_flagData2, mode); + + var success = client.Identify(_otherUser, TimeSpan.FromSeconds(5)); + Assert.True(success); + Assert.True(client.Initialized); + Assert.Equal(_otherUser.FullyQualifiedKey, client.Context.FullyQualifiedKey); // don't compare entire user, because SDK may have added device/os attributes + + var req2 = VerifyRequest(server.Recorder, mode); + Assert.NotEqual(user1RequestPath, req2.Path); + VerifyFlagValues(client, _flagData2); + } + } + } + + [Theory] + [MemberData(nameof(PollingAndStreaming))] + public async Task IdentifySwitchesUserAndGetsFlagsAsync(UpdateMode mode) + { + using (var server = HttpServer.Start(Handlers.Switchable(out var switchable))) + { + switchable.Target = SetupResponse(_flagData1, mode); + + var config = BaseConfig(server.Uri, mode); + using (var client = await TestUtil.CreateClientAsync(config, _user)) + { + var req1 = VerifyRequest(server.Recorder, mode); + VerifyFlagValues(client, _flagData1); + var user1RequestPath = req1.Path; + + switchable.Target = SetupResponse(_flagData2, mode); + + var success = await client.IdentifyAsync(_otherUser); + Assert.True(success); + Assert.True(client.Initialized); + Assert.Equal(_otherUser.FullyQualifiedKey, client.Context.FullyQualifiedKey); // don't compare entire user, because SDK may have added device/os attributes + + var req2 = VerifyRequest(server.Recorder, mode); + Assert.NotEqual(user1RequestPath, req2.Path); + VerifyFlagValues(client, _flagData2); + } + } + } + + [Theory] + [MemberData(nameof(PollingAndStreaming))] + public void IdentifyCanTimeOutSync(UpdateMode mode) + { + using (var server = HttpServer.Start(Handlers.Switchable(out var switchable))) + { + switchable.Target = SetupResponse(_flagData1, mode); + + var config = BaseConfig(server.Uri, mode); + using (var client = TestUtil.CreateClient(config, _user)) + { + var req1 = VerifyRequest(server.Recorder, mode); + VerifyFlagValues(client, _flagData1); + + switchable.Target = Handlers.Delay(TimeSpan.FromSeconds(2)) + .Then(SetupResponse(_flagData1, mode)); + + var success = client.Identify(_otherUser, TimeSpan.FromMilliseconds(100)); + Assert.False(success); + Assert.False(client.Initialized); + Assert.Null(client.StringVariation(_flagData1.Items.First().Key, null)); + } + } + } + + [Theory] + [InlineData("", "/mobile/events/bulk", "/mobile/events/diagnostic")] + [InlineData("/basepath", "/basepath/mobile/events/bulk", "/basepath/mobile/events/diagnostic")] + [InlineData("/basepath/", "/basepath/mobile/events/bulk", "/basepath/mobile/events/diagnostic")] + public void EventsAreSentToCorrectEndpointAsync( + string baseUriExtraPath, + string expectedAnalyticsPath, + string expectedDiagnosticsPath + ) + { + using (var server = HttpServer.Start(Handlers.Status(202))) + { + var config = BasicConfig() + .DataSource(MockPollingProcessor.Factory(DataSetBuilder.Empty)) + .Events(Components.SendEvents()) + .ServiceEndpoints(Components.ServiceEndpoints().Events(server.Uri.ToString().TrimEnd('/') + baseUriExtraPath)) + .Build(); + + using (var client = TestUtil.CreateClient(config, _user)) + { + client.Flush(); + var req1 = server.Recorder.RequireRequest(TimeSpan.FromSeconds(5)); + var req2 = server.Recorder.RequireRequest(TimeSpan.FromSeconds(5)); + + if (req1.Path.EndsWith("diagnostic")) + { + var temp = req1; + req1 = req2; + req2 = temp; + } + + Assert.Equal("POST", req1.Method); + Assert.Equal(expectedAnalyticsPath, req1.Path); + Assert.Equal(LdValueType.Array, LdValue.Parse(req1.Body).Type); + + Assert.Equal("POST", req2.Method); + Assert.Equal(expectedDiagnosticsPath, req2.Path); + Assert.Equal(LdValueType.Object, LdValue.Parse(req2.Body).Type); + } + } + } + + [Fact] + public void OfflineClientUsesCachedFlagsSync() + { + var sharedPersistenceConfig = Components.Persistence() + .Storage(new MockPersistentDataStore().AsSingletonFactory()); + + // streaming vs. polling should make no difference for this + using (var server = HttpServer.Start(SetupResponse(_flagData1, UpdateMode.Polling))) + { + var config = BaseConfig(server.Uri, UpdateMode.Polling, c => c.Persistence(sharedPersistenceConfig)); + using (var client = TestUtil.CreateClient(config, _user)) + { + VerifyFlagValues(client, _flagData1); + } + + // At this point the SDK should have written the flags to persistent storage for this user key. + // We'll now start over in offline mode, and we should still see the earlier flag values. + var offlineConfig = BasicConfig().Offline(true).Persistence(sharedPersistenceConfig).Build(); + using (var client = TestUtil.CreateClient(offlineConfig, _user)) + { + VerifyFlagValues(client, _flagData1); + } + } + } + + [Fact] + public async Task OfflineClientUsesCachedFlagsAsync() + { + var sharedPersistenceConfig = Components.Persistence() + .Storage(new MockPersistentDataStore().AsSingletonFactory()); + + // streaming vs. polling should make no difference for this + using (var server = HttpServer.Start(SetupResponse(_flagData1, UpdateMode.Polling))) + { + var config = BaseConfig(server.Uri, UpdateMode.Polling, c => c.Persistence(sharedPersistenceConfig)); + using (var client = await TestUtil.CreateClientAsync(config, _user)) + { + VerifyFlagValues(client, _flagData1); + } + + // At this point the SDK should have written the flags to persistent storage for this user key. + var offlineConfig = BasicConfig().Offline(true).Persistence(sharedPersistenceConfig).Build(); + using (var client = await TestUtil.CreateClientAsync(offlineConfig, _user)) + { + VerifyFlagValues(client, _flagData1); + } + } + } + + [Fact] + public async Task BackgroundModeForcesPollingAsync() + { + var mockBackgroundModeManager = new MockBackgroundModeManager(); + var backgroundInterval = TimeSpan.FromMilliseconds(50); + + using (var server = HttpServer.Start(Handlers.Switchable(out var switchable))) + { + switchable.Target = SetupResponse(_flagData1, UpdateMode.Streaming); + + var config = BaseConfig(builder => builder + .BackgroundModeManager(mockBackgroundModeManager) + .DataSource(Components.StreamingDataSource().BackgroundPollingIntervalWithoutMinimum(backgroundInterval)) + .ServiceEndpoints(Components.ServiceEndpoints().Streaming(server.Uri).Polling(server.Uri)) + ); + + using (var client = await TestUtil.CreateClientAsync(config, _user)) + { + VerifyFlagValues(client, _flagData1); + + // Set it up so that when the client switches to background mode and does a polling request, it will + // receive _flagData2, and we will be notified of that via a change event. SetupResponse will only + // configure the polling endpoint, so if the client makes a streaming request here it'll fail. + switchable.Target = SetupResponse(_flagData2, UpdateMode.Polling); + var receivedChangeSignal = new SemaphoreSlim(0, 1); + client.FlagTracker.FlagValueChanged += (sender, args) => + { + receivedChangeSignal.Release(); + }; + + mockBackgroundModeManager.UpdateBackgroundMode(true); + + Assert.True(await receivedChangeSignal.WaitAsync(TimeSpan.FromSeconds(5))); + VerifyFlagValues(client, _flagData2); + + // Now switch back to streaming + switchable.Target = SetupResponse(_flagData1, UpdateMode.Streaming); + mockBackgroundModeManager.UpdateBackgroundMode(false); + + Assert.True(await receivedChangeSignal.WaitAsync(TimeSpan.FromSeconds(5))); + VerifyFlagValues(client, _flagData1); + } + } + } + + [Fact] + public async Task BackgroundModePollingCanBeDisabledAsync() + { + var mockBackgroundModeManager = new MockBackgroundModeManager(); + var backgroundInterval = TimeSpan.FromMilliseconds(50); + var hackyUpdateDelay = TimeSpan.FromMilliseconds(200); + + using (var server = HttpServer.Start(Handlers.Switchable(out var switchable))) + { + switchable.Target = SetupResponse(_flagData1, UpdateMode.Streaming); + + var config = BaseConfig(builder => builder + .BackgroundModeManager(mockBackgroundModeManager) + .EnableBackgroundUpdating(false) + .DataSource(Components.StreamingDataSource().BackgroundPollInterval(backgroundInterval)) + .ServiceEndpoints(Components.ServiceEndpoints().Streaming(server.Uri).Polling(server.Uri)) + ); + + using (var client = await TestUtil.CreateClientAsync(config, _user)) + { + VerifyFlagValues(client, _flagData1); + + // The SDK should *not* hit this polling endpoint, but we're providing some data there so we can + // detect whether it does. + switchable.Target = SetupResponse(_flagData2, UpdateMode.Polling); + mockBackgroundModeManager.UpdateBackgroundMode(true); + + await Task.Delay(hackyUpdateDelay); + VerifyFlagValues(client, _flagData1); // we should *not* have done a poll + + var receivedChangeSignal = new SemaphoreSlim(0, 1); + client.FlagTracker.FlagValueChanged += (sender, args) => + { + receivedChangeSignal.Release(); + }; + + // Now switch back to streaming + switchable.Target = SetupResponse(_flagData2, UpdateMode.Streaming); + mockBackgroundModeManager.UpdateBackgroundMode(false); + + Assert.True(await receivedChangeSignal.WaitAsync(TimeSpan.FromSeconds(5))); + VerifyFlagValues(client, _flagData2); + } + } + } + + [Theory] + [MemberData(nameof(PollingAndStreaming))] + public async Task OfflineClientGoesOnlineAndGetsFlagsAsync(UpdateMode mode) + { + using (var server = HttpServer.Start(SetupResponse(_flagData1, mode))) + { + var config = BaseConfig(server.Uri, mode, builder => builder.Offline(true)); + using (var client = await TestUtil.CreateClientAsync(config, _user)) + { + VerifyNoFlagValues(client, _flagData1); + Assert.Equal(0, server.Recorder.Count); + + await client.SetOfflineAsync(false); + + VerifyFlagValues(client, _flagData1); + } + } + } + + [Fact] + public void HttpConfigurationIsAppliedToStreaming() + { + TestHttpUtils.TestWithSpecialHttpConfigurations( + StreamWithInitialData(_flagData1), + (targetUri, httpConfig, server) => + { + var config = BasicConfig() + .DataSource(Components.StreamingDataSource()) + .Http(httpConfig) + .ServiceEndpoints(Components.ServiceEndpoints().Streaming(targetUri)) + .Build(); + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + VerifyFlagValues(client, _flagData1); + } + }, + testLogger + ); + } + + [Fact] + public void HttpConfigurationIsAppliedToPolling() + { + TestHttpUtils.TestWithSpecialHttpConfigurations( + PollingResponse(_flagData1), + (targetUri, httpConfig, server) => + { + var config = BasicConfig() + .DataSource(Components.PollingDataSource()) + .Http(httpConfig) + .ServiceEndpoints(Components.ServiceEndpoints().Polling(targetUri)) + .Build(); + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + VerifyFlagValues(client, _flagData1); + } + }, + testLogger + ); + } + + [Fact] + public void HttpConfigurationIsAppliedToEvents() + { + TestHttpUtils.TestWithSpecialHttpConfigurations( + EventsAcceptedResponse, + (targetUri, httpConfig, server) => + { + var config = BasicConfig() + .DiagnosticOptOut(true) + .Events(Components.SendEvents()) + .Http(httpConfig) + .ServiceEndpoints(Components.ServiceEndpoints().Events(targetUri)) + .Build(); + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + client.Flush(); + server.Recorder.RequireRequest(); + } + }, + testLogger + ); + } + + private Configuration BaseConfig(Func extraConfig = null) + { + var builder = BasicConfig() + .Events(new MockEventProcessor().AsSingletonFactory()); + builder = extraConfig is null ? builder : extraConfig(builder); + return builder.Build(); + } + + private Configuration BaseConfig(Uri serverUri, UpdateMode mode, Func extraConfig = null) + { + return BaseConfig(builder => + { + builder.ServiceEndpoints(Components.ServiceEndpoints().Streaming(serverUri).Polling(serverUri)); + if (mode.IsStreaming) + { + builder.DataSource(Components.StreamingDataSource()); + } + else + { + builder.DataSource(Components.PollingDataSource()); + } + return extraConfig == null ? builder : extraConfig(builder); + }); + } + + private Handler SetupResponse(FullDataSet data, UpdateMode mode) => + mode.IsStreaming + ? StreamWithInitialData(data) + : PollingResponse(data); + + private RequestInfo VerifyRequest(RequestRecorder recorder, UpdateMode mode) + { + var req = recorder.RequireRequest(TimeSpan.FromSeconds(5)); + Assert.Equal("GET", req.Method); + + // Note, we don't check for an exact match of the encoded user string in Req.Path because it is not determinate - the + // SDK may add custom attributes to the user ("os" etc.) and since we don't canonicalize the JSON representation, + // properties could be serialized in any order causing the encoding to vary. Also, we don't test REPORT mode here + // because it is already covered in FeatureFlagRequestorTest. + Assert.Matches(mode.FlagsPathRegex, req.Path); + + Assert.Equal("", req.Query); + Assert.Equal(BasicMobileKey, req.Headers["Authorization"]); + Assert.Equal("", req.Body); + + return req; + } + + private void VerifyFlagValues(ILdClient client, FullDataSet flags) + { + Assert.True(client.Initialized); + foreach (var e in flags.Items) + { + Assert.Equal(e.Value.Item.Value, client.JsonVariation(e.Key, LdValue.Null)); + } + } + + private void VerifyNoFlagValues(ILdClient client, FullDataSet flags) + { + Assert.True(client.Initialized); + foreach (var e in flags.Items) + { + Assert.Equal(LdValue.Null, client.JsonVariation(e.Key, LdValue.Null)); + } + } + } + + public class UpdateMode + { + public bool IsStreaming { get; private set; } + public string FlagsPathRegex { get; private set; } + + public static readonly UpdateMode Streaming = new UpdateMode + { + IsStreaming = true, + FlagsPathRegex = "^/meval/[^/?]+" + }; + + public static readonly UpdateMode Polling = new UpdateMode + { + IsStreaming = false, + FlagsPathRegex = "^/msdk/evalx/contexts/[^/?]+" + }; + + public override string ToString() => IsStreaming ? "Streaming" : "Polling"; + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LaunchDarkly.ClientSdk.Tests.csproj b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LaunchDarkly.ClientSdk.Tests.csproj new file mode 100644 index 00000000..0b044929 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LaunchDarkly.ClientSdk.Tests.csproj @@ -0,0 +1,33 @@ + + + + net7.0 + $(TESTFRAMEWORK) + LaunchDarkly.ClientSdk.Tests + LaunchDarkly.Sdk.Client + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientDataSourceStatusTests.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientDataSourceStatusTests.cs new file mode 100644 index 00000000..62c9e4fa --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientDataSourceStatusTests.cs @@ -0,0 +1,280 @@ +using System; +using LaunchDarkly.Sdk.Client.Integrations; +using LaunchDarkly.Sdk.Client.Interfaces; +using LaunchDarkly.Sdk.Client.Subsystems; +using LaunchDarkly.TestHelpers; +using Xunit; +using Xunit.Abstractions; + +namespace LaunchDarkly.Sdk.Client +{ + public class LdClientDataSourceStatusTests : BaseTest + { + // This is separate from LdClientListenersTest because the client-side .NET SDK has + // more complicated connection-status behavior than the server-side one and we need + // to test more scenarios. For basic scenarios, we can just use TestData to inject + // status changes; for others, we need to use mock components to simulate the + // inputs that can lead to status changes. + + public LdClientDataSourceStatusTests(ITestOutputHelper testOutput) : base(testOutput) { } + + [Fact] + public void DataSourceStatusProviderReturnsLatestStatus() + { + var testData = TestData.DataSource(); + var config = BasicConfig().DataSource(testData).Build(); + var timeBeforeStarting = DateTime.Now; + + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + var initialStatus = client.DataSourceStatusProvider.Status; + Assert.Equal(DataSourceState.Valid, initialStatus.State); + Assert.True(initialStatus.StateSince >= timeBeforeStarting); + Assert.Null(initialStatus.LastError); + + var errorInfo = DataSourceStatus.ErrorInfo.FromHttpError(401); + testData.UpdateStatus(DataSourceState.Shutdown, errorInfo); + + var newStatus = client.DataSourceStatusProvider.Status; + Assert.Equal(DataSourceState.Shutdown, newStatus.State); + Assert.True(newStatus.StateSince >= errorInfo.Time); + Assert.Equal(errorInfo, newStatus.LastError); + } + } + + [Fact] + public void DataSourceStatusProviderSendsStatusUpdates() + { + var testData = TestData.DataSource(); + var config = BasicConfig().DataSource(testData).Build(); + + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + var statuses = new EventSink(); + client.DataSourceStatusProvider.StatusChanged += statuses.Add; + + var errorInfo = DataSourceStatus.ErrorInfo.FromHttpError(401); + testData.UpdateStatus(DataSourceState.Shutdown, errorInfo); + + var newStatus = statuses.ExpectValue(); + Assert.Equal(DataSourceState.Shutdown, newStatus.State); + Assert.True(newStatus.StateSince >= errorInfo.Time); + Assert.Equal(errorInfo, newStatus.LastError); + } + } + + [Fact] + public void DataSourceStatusStartsAsInitializing() + { + var config = BasicConfig() + .DataSource(new MockDataSourceThatNeverInitializes().AsSingletonFactory()) + .Build(); + + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + var initialStatus = client.DataSourceStatusProvider.Status; + Assert.Equal(DataSourceState.Initializing, initialStatus.State); + Assert.Null(initialStatus.LastError); + } + } + + [Fact] + public void DataSourceStatusRemainsInitializingAfterErrorIfNeverInitialized() + { + var dataSourceFactory = new CapturingComponentConfigurer( + new MockDataSource().AsSingletonFactory()); + + var config = BasicConfig() + .DataSource(dataSourceFactory) + .Build(); + + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + var statuses = new EventSink(); + client.DataSourceStatusProvider.StatusChanged += statuses.Add; + + var errorInfo = DataSourceStatus.ErrorInfo.FromHttpError(503); + dataSourceFactory.ReceivedClientContext.DataSourceUpdateSink.UpdateStatus( + DataSourceState.Interrupted, errorInfo); + + var newStatus1 = statuses.ExpectValue(); + Assert.Equal(DataSourceState.Initializing, newStatus1.State); + Assert.Equal(errorInfo, newStatus1.LastError); + } + } + + [Fact] + public void DataSourceStatusIsInterruptedAfterErrorIfAlreadyInitialized() + { + var dataSourceFactory = new CapturingComponentConfigurer( + new MockDataSource().AsSingletonFactory()); + + var config = BasicConfig() + .DataSource(dataSourceFactory) + .Build(); + + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + var statuses = new EventSink(); + client.DataSourceStatusProvider.StatusChanged += statuses.Add; + + dataSourceFactory.ReceivedClientContext.DataSourceUpdateSink.UpdateStatus( + DataSourceState.Valid, null); + + var newStatus1 = statuses.ExpectValue(); + Assert.Equal(DataSourceState.Valid, newStatus1.State); + + var errorInfo = DataSourceStatus.ErrorInfo.FromHttpError(503); + dataSourceFactory.ReceivedClientContext.DataSourceUpdateSink.UpdateStatus( + DataSourceState.Interrupted, errorInfo); + + var newStatus2 = statuses.ExpectValue(); + Assert.Equal(DataSourceState.Interrupted, newStatus2.State); + Assert.Equal(errorInfo, newStatus2.LastError); + } + } + + [Fact] + public void DataSourceStatusStartsAsSetOfflineIfConfiguredOffline() + { + var config = BasicConfig().Offline(true).Build(); + + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + var initialStatus = client.DataSourceStatusProvider.Status; + Assert.Equal(DataSourceState.SetOffline, initialStatus.State); + Assert.Null(initialStatus.LastError); + } + } + + [Fact] + public void DataSourceStatusIsRestoredWhenNoLongerSetOffline() + { + var testData = TestData.DataSource(); + var config = BasicConfig().DataSource(testData).Offline(true).Build(); + + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + Assert.True(client.DataSourceStatusProvider.WaitFor(DataSourceState.SetOffline, TimeSpan.FromSeconds(5))); + + var statuses = new EventSink(); + client.DataSourceStatusProvider.StatusChanged += statuses.Add; + + client.SetOffline(false, TimeSpan.FromSeconds(1)); + + var newStatus1 = statuses.ExpectValue(); + Assert.Equal(DataSourceState.Initializing, newStatus1.State); + + var newStatus2 = statuses.ExpectValue(); + Assert.Equal(DataSourceState.Valid, newStatus2.State); + } + } + + [Fact] + public void DataSourceStatusStartsAsNetworkUnavailableIfNetworkIsUnavailable() + { + var connectivity = new MockConnectivityStateManager(false); + var config = BasicConfig().ConnectivityStateManager(connectivity).Build(); + + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + var initialStatus = client.DataSourceStatusProvider.Status; + Assert.Equal(DataSourceState.NetworkUnavailable, initialStatus.State); + Assert.Null(initialStatus.LastError); + } + } + + [Fact] + public void DataSourceStatusIsRestoredWhenNetworkIsAvailableAgain() + { + var testData = TestData.DataSource(); + var connectivity = new MockConnectivityStateManager(false); + var config = BasicConfig() + .DataSource(testData) + .ConnectivityStateManager(connectivity) + .Build(); + + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + Assert.True(client.DataSourceStatusProvider.WaitFor(DataSourceState.NetworkUnavailable, TimeSpan.FromSeconds(5))); + + var statuses = new EventSink(); + client.DataSourceStatusProvider.StatusChanged += statuses.Add; + + connectivity.Connect(true); + + var newStatus1 = statuses.ExpectValue(); + Assert.Equal(DataSourceState.Initializing, newStatus1.State); + + var newStatus2 = statuses.ExpectValue(); + Assert.Equal(DataSourceState.Valid, newStatus2.State); + } + } + + [Fact] + public void SetOfflineStatusOverridesNetworkUnavailableStatus() + { + var testData = TestData.DataSource(); + var connectivity = new MockConnectivityStateManager(false); + var config = BasicConfig() + .DataSource(testData) + .ConnectivityStateManager(connectivity) + .Offline(true) + .Build(); + + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + Assert.True(client.DataSourceStatusProvider.WaitFor(DataSourceState.SetOffline, TimeSpan.FromSeconds(5))); + + var statuses = new EventSink(); + client.DataSourceStatusProvider.StatusChanged += statuses.Add; + + client.SetOffline(false, TimeSpan.FromSeconds(1)); + + var newStatus1 = statuses.ExpectValue(); + Assert.Equal(DataSourceState.NetworkUnavailable, newStatus1.State); + + connectivity.Connect(true); + + var newStatus2 = statuses.ExpectValue(); + Assert.Equal(DataSourceState.Initializing, newStatus2.State); + + var newStatus3 = statuses.ExpectValue(); + Assert.Equal(DataSourceState.Valid, newStatus3.State); + } + } + + [Fact] + public void BackgroundDisabledState() + { + var testData = TestData.DataSource(); + var backgrounder = new MockBackgroundModeManager(); + var config = BasicConfig() + .BackgroundModeManager(backgrounder) + .DataSource(testData) + .EnableBackgroundUpdating(false) + .Build(); + + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + Assert.True(client.DataSourceStatusProvider.WaitFor(DataSourceState.Valid, TimeSpan.FromSeconds(5))); + + var statuses = new EventSink(); + client.DataSourceStatusProvider.StatusChanged += statuses.Add; + + backgrounder.UpdateBackgroundMode(true); + + var newStatus1 = statuses.ExpectValue(); + Assert.Equal(DataSourceState.BackgroundDisabled, newStatus1.State); + + backgrounder.UpdateBackgroundMode(false); + + var newStatus2 = statuses.ExpectValue(); + Assert.Equal(DataSourceState.Initializing, newStatus2.State); + + var newStatus3 = statuses.ExpectValue(); + Assert.Equal(DataSourceState.Valid, newStatus3.State); + } + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientDiagnosticEventTest.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientDiagnosticEventTest.cs new file mode 100644 index 00000000..780ae8d0 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientDiagnosticEventTest.cs @@ -0,0 +1,400 @@ +using System; +using System.Net; +using LaunchDarkly.Sdk.Client.Integrations; +using LaunchDarkly.Sdk.Internal; +using LaunchDarkly.Sdk.Internal.Events; +using LaunchDarkly.TestHelpers; +using LaunchDarkly.TestHelpers.HttpTest; +using Xunit; +using Xunit.Abstractions; + +using static LaunchDarkly.Sdk.Client.MockResponses; +using static LaunchDarkly.Sdk.Client.TestHttpUtils; +using static LaunchDarkly.TestHelpers.JsonAssertions; +using static LaunchDarkly.TestHelpers.JsonTestValue; + +namespace LaunchDarkly.Sdk.Client +{ + public class LdClientDiagnosticEventTest : BaseTest + { + // These tests cover the basic functionality of sending diagnostic events, and + // also verify that properties set with Configuration.Builder show up correctly + // in the configuration part of the diagnostic data. The lower-level details of + // how diagnostic events are accumulated in memory and delivered are tested in + // LaunchDarkly.InternalSdk, and the details of how stream connection data is + // logged in diagnostic events is tested in StreamProcessorTest. + + internal static readonly TimeSpan testStartWaitTime = TimeSpan.FromMilliseconds(1); + + private MockEventSender _testEventSender = new MockEventSender { FilterKind = EventDataKind.DiagnosticEvent }; + + public LdClientDiagnosticEventTest(ITestOutputHelper testOutput) : base(testOutput) { } + + [Fact] + public void NoDiagnosticInitEventIsSentIfOptedOut() + { + var config = BasicConfig() + .DiagnosticOptOut(true) + .Events(Components.SendEvents().EventSender(_testEventSender)) + .Build(); + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + _testEventSender.RequireNoPayloadSent(TimeSpan.FromMilliseconds(100)); + } + } + + [Fact] + public void DiagnosticInitEventIsSent() + { + var testWrapperName = "wrapper-name"; + var testWrapperVersion = "1.2.3"; + var expectedSdk = JsonOf(LdValue.BuildObject() + .Add("name", "dotnet-client-sdk") + .Add("version", AssemblyVersions.GetAssemblyVersionStringForType(typeof(LdClient))) + .Add("wrapperName", testWrapperName) + .Add("wrapperVersion", testWrapperVersion) + .Build().ToJsonString()); + + var config = BasicConfig() + .Events(Components.SendEvents().EventSender(_testEventSender)) + .Http(Components.HttpConfiguration().Wrapper(testWrapperName, testWrapperVersion)) + .Build(); + + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + var payload = _testEventSender.RequirePayload(); + + Assert.Equal(EventDataKind.DiagnosticEvent, payload.Kind); + Assert.Equal(1, payload.EventCount); + + var data = JsonOf(payload.Data); + AssertJsonEqual(JsonFromValue("diagnostic-init"), data.Property("kind")); + AssertJsonEqual(expectedSdk, data.Property("sdk")); + AssertJsonEqual(JsonFromValue(BasicMobileKey.Substring(BasicMobileKey.Length - 6)), + data.Property("id").Property("sdkKeySuffix")); + + data.RequiredProperty("creationDate"); + } + } + + [Fact] + public void DiagnosticPeriodicEventsAreSent() + { + var config = BasicConfig() + .Events(Components.SendEvents() + .EventSender(_testEventSender) + .DiagnosticRecordingIntervalNoMinimum(TimeSpan.FromMilliseconds(50))) + .Build(); + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + var payload1 = _testEventSender.RequirePayload(); + + Assert.Equal(EventDataKind.DiagnosticEvent, payload1.Kind); + Assert.Equal(1, payload1.EventCount); + var data1 = LdValue.Parse(payload1.Data); + Assert.Equal("diagnostic-init", data1.Get("kind").AsString); + var timestamp1 = data1.Get("creationDate").AsLong; + Assert.NotEqual(0, timestamp1); + + var payload2 = _testEventSender.RequirePayload(); + + Assert.Equal(EventDataKind.DiagnosticEvent, payload2.Kind); + Assert.Equal(1, payload2.EventCount); + var data2 = LdValue.Parse(payload2.Data); + Assert.Equal("diagnostic", data2.Get("kind").AsString); + var timestamp2 = data2.Get("creationDate").AsLong; + Assert.InRange(timestamp2, timestamp1, timestamp1 + 1000); + + var payload3 = _testEventSender.RequirePayload(); + + Assert.Equal(EventDataKind.DiagnosticEvent, payload3.Kind); + Assert.Equal(1, payload3.EventCount); + var data3 = LdValue.Parse(payload3.Data); + Assert.Equal("diagnostic", data3.Get("kind").AsString); + var timestamp3 = data2.Get("creationDate").AsLong; + Assert.InRange(timestamp3, timestamp2, timestamp1 + 1000); + } + } + + [Fact] + public void DiagnosticEventsAreNotSentWhenConfiguredOffline() + { + var config = BasicConfig() + .Offline(true) + .Events(Components.SendEvents() + .EventSender(_testEventSender) + .DiagnosticRecordingIntervalNoMinimum(TimeSpan.FromMilliseconds(50))) + .Build(); + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + _testEventSender.RequireNoPayloadSent(TimeSpan.FromMilliseconds(100)); + + client.SetOffline(false, TimeSpan.FromMilliseconds(100)); + + _testEventSender.RequirePayload(); + } + } + + [Fact] + public void DiagnosticEventsAreNotSentWhenNetworkIsUnavailable() + { + var connectivityStateManager = new MockConnectivityStateManager(false); + var config = BasicConfig() + .ConnectivityStateManager(connectivityStateManager) + .Events(Components.SendEvents() + .EventSender(_testEventSender) + .DiagnosticRecordingIntervalNoMinimum(TimeSpan.FromMilliseconds(50))) + .Build(); + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + _testEventSender.RequireNoPayloadSent(TimeSpan.FromMilliseconds(100)); + + connectivityStateManager.Connect(true); + + _testEventSender.RequirePayload(); + } + } + + [Fact] + public void DiagnosticPeriodicEventsAreNotSentWhenInBackground() + { + var mockBackgroundModeManager = new MockBackgroundModeManager(); + var config = BasicConfig() + .BackgroundModeManager(mockBackgroundModeManager) + .Events(Components.SendEvents() + .EventSender(_testEventSender) + .DiagnosticRecordingIntervalNoMinimum(TimeSpan.FromMilliseconds(50))) + .Build(); + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + mockBackgroundModeManager.UpdateBackgroundMode(true); + + // We will probably still get some periodic events before this mode change is picked + // up asynchronously, but we should stop getting them soon. + Assertions.AssertEventually(TimeSpan.FromMilliseconds(400), TimeSpan.FromMilliseconds(10), + () => !_testEventSender.Calls.TryTake(out _, TimeSpan.FromMilliseconds(100))); + + mockBackgroundModeManager.UpdateBackgroundMode(false); + + _testEventSender.RequirePayload(); + } + } + + [Fact] + public void ConfigDefaults() + { + // Note that in all of the test configurations where the streaming or polling data source + // is enabled, we're setting a fake HTTP message handler so it doesn't try to do any real + // HTTP requests that would fail and (depending on timing) disrupt the test. + TestDiagnosticConfig( + c => c.Http(Components.HttpConfiguration().MessageHandler(StreamWithInitialData().AsMessageHandler())), + null, + ExpectedConfigProps.Base() + ); + } + + [Fact] + public void CustomConfigForStreaming() + { + TestDiagnosticConfig( + c => c.DataSource( + Components.StreamingDataSource() + .BackgroundPollInterval(TimeSpan.FromDays(1)) + .InitialReconnectDelay(TimeSpan.FromSeconds(2)) + ) + .Http(Components.HttpConfiguration().MessageHandler(StreamWithInitialData().AsMessageHandler())), + null, + ExpectedConfigProps.Base() + .Set("backgroundPollingIntervalMillis", TimeSpan.FromDays(1).TotalMilliseconds) + .Set("reconnectTimeMillis", 2000) + ); + } + + [Fact] + public void CustomConfigForPolling() + { + TestDiagnosticConfig( + c => c.DataSource(Components.PollingDataSource()) + .Http(Components.HttpConfiguration().MessageHandler(PollingResponse().AsMessageHandler())), + null, + ExpectedConfigProps.Base().WithPollingDefaults() + ); + + TestDiagnosticConfig( + c => c.DataSource( + Components.PollingDataSource() + .PollInterval(TimeSpan.FromDays(1)) + ) + .Http(Components.HttpConfiguration().MessageHandler(PollingResponse().AsMessageHandler())), + null, + ExpectedConfigProps.Base().WithPollingDefaults() + .Set("pollingIntervalMillis", TimeSpan.FromDays(1).TotalMilliseconds) + ); + } + + [Fact] + public void CustomConfigForEvents() + { + TestDiagnosticConfig( + c => c.Http(Components.HttpConfiguration().MessageHandler(StreamWithInitialData().AsMessageHandler())), + e => e.AllAttributesPrivate(true) + .Capacity(333) + .DiagnosticRecordingInterval(TimeSpan.FromMinutes(32)) + .FlushInterval(TimeSpan.FromMilliseconds(555)), + ExpectedConfigProps.Base() + .Set("allAttributesPrivate", true) + .Set("diagnosticRecordingIntervalMillis", TimeSpan.FromMinutes(32).TotalMilliseconds) + .Set("eventsCapacity", 333) + .Set("eventsFlushIntervalMillis", 555) + ); + } + + [Fact] + public void CustomConfigForHTTP() + { + TestDiagnosticConfig( + c => c.Http( + Components.HttpConfiguration() + .ConnectTimeout(TimeSpan.FromMilliseconds(8888)) + .ResponseStartTimeout(TimeSpan.FromMilliseconds(9999)) + .MessageHandler(StreamWithInitialData().AsMessageHandler()) + .UseReport(true) + ), + null, + ExpectedConfigProps.Base() + .Set("connectTimeoutMillis", 8888) + .Set("socketTimeoutMillis", 9999) + .Set("useReport", true) + ); + + var proxyUri = new Uri("http://fake"); + var proxy = new WebProxy(proxyUri); + TestDiagnosticConfig( + c => c.Http( + Components.HttpConfiguration() + .Proxy(proxy) + .MessageHandler(StreamWithInitialData().AsMessageHandler()) + ), + null, + ExpectedConfigProps.Base() + .Set("usingProxy", true) + ); + + var credentials = new CredentialCache(); + credentials.Add(proxyUri, "Basic", new NetworkCredential("user", "pass")); + var proxyWithAuth = new WebProxy(proxyUri); + proxyWithAuth.Credentials = credentials; + TestDiagnosticConfig( + c => c.Http( + Components.HttpConfiguration() + .Proxy(proxyWithAuth) + .MessageHandler(StreamWithInitialData().AsMessageHandler()) + ), + null, + ExpectedConfigProps.Base() + .Set("usingProxy", true) + .Set("usingProxyAuthenticator", true) + ); + } + + [Fact] + public void TestConfigForServiceEndpoints() + { + TestDiagnosticConfig( + c => c.ServiceEndpoints(Components.ServiceEndpoints().RelayProxy("http://custom")) + .Http(Components.HttpConfiguration().MessageHandler(StreamWithInitialData().AsMessageHandler())), + null, + ExpectedConfigProps.Base() + .Set("customBaseURI", true) + .Set("customStreamURI", true) + .Set("customEventsURI", true) + ); + + TestDiagnosticConfig( + c => c.ServiceEndpoints(Components.ServiceEndpoints() + .Streaming("http://custom-streaming") + .Polling("http://custom-polling") + .Events("http://custom-events")) + .Http(Components.HttpConfiguration().MessageHandler(StreamWithInitialData().AsMessageHandler())), + null, + ExpectedConfigProps.Base() + .Set("customBaseURI", true) + .Set("customStreamURI", true) + .Set("customEventsURI", true) + ); + + TestDiagnosticConfig( + c => c.ServiceEndpoints(Components.ServiceEndpoints().RelayProxy("http://custom")) + .DataSource(Components.PollingDataSource()) + .Http(Components.HttpConfiguration().MessageHandler(StreamWithInitialData().AsMessageHandler())), + null, + ExpectedConfigProps.Base() + .WithPollingDefaults() + .Set("customBaseURI", true) + .Set("customEventsURI", true) + ); + } + + private void TestDiagnosticConfig( + Func modConfig, + Func modEvents, + LdValue.ObjectBuilder expected + ) + { + var eventsBuilder = Components.SendEvents() + .EventSender(_testEventSender); + modEvents?.Invoke(eventsBuilder); + var configBuilder = BasicConfig() + .DataSource(Components.StreamingDataSource()) + .Events(eventsBuilder) + .Http(Components.HttpConfiguration().MessageHandler(Error401Response.AsMessageHandler())); + modConfig?.Invoke(configBuilder); + using (var client = TestUtil.CreateClient(configBuilder.Build(), BasicUser, testStartWaitTime)) + { + var data = ExpectDiagnosticEvent(); + + AssertJsonEqual(JsonFromValue("diagnostic-init"), data.Property("kind")); + + AssertJsonEqual(JsonOf(expected.Build().ToJsonString()), data.Property("configuration")); + } + } + + private JsonTestValue ExpectDiagnosticEvent() + { + var payload = _testEventSender.RequirePayload(); + Assert.Equal(EventDataKind.DiagnosticEvent, payload.Kind); + Assert.Equal(1, payload.EventCount); + return JsonOf(payload.Data); + } + } + + static class ExpectedConfigProps + { + public static LdValue.ObjectBuilder Base() => + LdValue.BuildObject() + .Add("allAttributesPrivate", false) + .Add("backgroundPollingDisabled", false) + .Add("backgroundPollingIntervalMillis", Configuration.DefaultBackgroundPollInterval.TotalMilliseconds) + .Add("customBaseURI", false) + .Add("connectTimeoutMillis", HttpConfigurationBuilder.DefaultConnectTimeout.TotalMilliseconds) + .Add("customEventsURI", false) + .Add("customStreamURI", false) + .Add("diagnosticRecordingIntervalMillis", EventProcessorBuilder.DefaultDiagnosticRecordingInterval.TotalMilliseconds) + .Add("evaluationReasonsRequested", false) + .Add("eventsCapacity", EventProcessorBuilder.DefaultCapacity) + .Add("eventsFlushIntervalMillis", EventProcessorBuilder.DefaultFlushInterval.TotalMilliseconds) + .Add("reconnectTimeMillis", StreamingDataSourceBuilder.DefaultInitialReconnectDelay.TotalMilliseconds) + .Add("socketTimeoutMillis", HttpConfigurationBuilder.DefaultResponseStartTimeout.TotalMilliseconds) + .Add("startWaitMillis", LdClientDiagnosticEventTest.testStartWaitTime.TotalMilliseconds) + .Add("streamingDisabled", false) + .Add("useReport", false) + .Add("usingProxy", false) + .Add("usingProxyAuthenticator", false); + + public static LdValue.ObjectBuilder WithPollingDefaults(this LdValue.ObjectBuilder builder) => + builder.Set("pollingIntervalMillis", PollingDataSourceBuilder.DefaultPollInterval.TotalMilliseconds) + .Set("streamingDisabled", true) + .Remove("customStreamURI") + .Remove("reconnectTimeMillis"); + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientEvaluationTests.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientEvaluationTests.cs new file mode 100644 index 00000000..95206b2e --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientEvaluationTests.cs @@ -0,0 +1,294 @@ +using System; +using LaunchDarkly.Sdk.Client.Integrations; +using Xunit; +using Xunit.Abstractions; + +namespace LaunchDarkly.Sdk.Client +{ + public class LdClientEvaluationTests : BaseTest + { + const string flagKey = "flag-key"; + const string nonexistentFlagKey = "some flag key"; + static readonly Context user = Context.New("userkey"); + + private readonly TestData _testData = TestData.DataSource(); + + public LdClientEvaluationTests(ITestOutputHelper testOutput) : base(testOutput) { } + + private LdClient MakeClient() => + TestUtil.CreateClient(BasicConfig().DataSource(_testData).Build(), user, TimeSpan.FromSeconds(1)); + + [Fact] + public void BoolVariationReturnsValue() + { + _testData.Update(_testData.Flag(flagKey).Variation(true)); + using (var client = MakeClient()) + { + Assert.True(client.BoolVariation(flagKey, false)); + } + } + + [Fact] + public void BoolVariationReturnsDefaultForUnknownFlag() + { + using (var client = MakeClient()) + { + Assert.False(client.BoolVariation(nonexistentFlagKey)); + } + } + + [Fact] + public void BoolVariationDetailReturnsValue() + { + var reason = EvaluationReason.OffReason; + var flag = new FeatureFlagBuilder().Value(true).Variation(1).Reason(reason).Build(); + _testData.Update(_testData.Flag(flagKey).PreconfiguredFlag(flag)); + using (var client = MakeClient()) + { + var expected = new EvaluationDetail(true, 1, reason); + Assert.Equal(expected, client.BoolVariationDetail(flagKey, false)); + } + } + + [Fact] + public void IntVariationReturnsValue() + { + _testData.Update(_testData.Flag(flagKey).Variation(LdValue.Of(3))); + using (var client = MakeClient()) + { + Assert.Equal(3, client.IntVariation(flagKey, 0)); + } + } + + [Fact] + public void IntVariationCoercesFloatValue() + { + _testData.Update(_testData.Flag(flagKey).Variation(LdValue.Of(3.25f))); + using (var client = MakeClient()) + { + Assert.Equal(3, client.IntVariation(flagKey, 0)); + } + } + + [Fact] + public void IntVariationReturnsDefaultForUnknownFlag() + { + using (var client = MakeClient()) + { + Assert.Equal(1, client.IntVariation(nonexistentFlagKey, 1)); + } + } + + [Fact] + public void IntVariationDetailReturnsValue() + { + var reason = EvaluationReason.OffReason; + var flag = new FeatureFlagBuilder().Value(LdValue.Of(3)).Variation(1).Reason(reason).Build(); + _testData.Update(_testData.Flag(flagKey).PreconfiguredFlag(flag)); + using (var client = MakeClient()) + { + var expected = new EvaluationDetail(3, 1, reason); + Assert.Equal(expected, client.IntVariationDetail(flagKey, 0)); + } + } + + [Fact] + public void FloatVariationReturnsValue() + { + _testData.Update(_testData.Flag(flagKey).Variation(LdValue.Of(2.5f))); + using (var client = MakeClient()) + { + Assert.Equal(2.5f, client.FloatVariation(flagKey, 0)); + } + } + + [Fact] + public void FloatVariationCoercesIntValue() + { + _testData.Update(_testData.Flag(flagKey).Variation(LdValue.Of(2))); + using (var client = MakeClient()) + { + Assert.Equal(2.0f, client.FloatVariation(flagKey, 0)); + } + } + + [Fact] + public void FloatVariationReturnsDefaultForUnknownFlag() + { + using (var client = MakeClient()) + { + Assert.Equal(0.5f, client.FloatVariation(nonexistentFlagKey, 0.5f)); + } + } + + [Fact] + public void FloatVariationDetailReturnsValue() + { + var reason = EvaluationReason.OffReason; + var flag = new FeatureFlagBuilder().Value(LdValue.Of(2.5f)).Variation(1).Reason(reason).Build(); + _testData.Update(_testData.Flag(flagKey).PreconfiguredFlag(flag)); + using (var client = MakeClient()) + { + var expected = new EvaluationDetail(2.5f, 1, reason); + Assert.Equal(expected, client.FloatVariationDetail(flagKey, 0.5f)); + } + } + + [Fact] + public void DoubleVariationReturnsValue() + { + _testData.Update(_testData.Flag(flagKey).Variation(LdValue.Of(2.5d))); + using (var client = MakeClient()) + { + Assert.Equal(2.5d, client.DoubleVariation(flagKey, 0)); + } + } + + [Fact] + public void DoubleVariationCoercesIntValue() + { + _testData.Update(_testData.Flag(flagKey).Variation(LdValue.Of(2))); + using (var client = MakeClient()) + { + Assert.Equal(2.0d, client.DoubleVariation(flagKey, 0)); + } + } + + [Fact] + public void DoubleVariationReturnsDefaultForUnknownFlag() + { + using (var client = MakeClient()) + { + Assert.Equal(0.5d, client.DoubleVariation(nonexistentFlagKey, 0.5d)); + } + } + + [Fact] + public void DoubleVariationDetailReturnsValue() + { + var reason = EvaluationReason.OffReason; + var flag = new FeatureFlagBuilder().Value(LdValue.Of(2.5d)).Variation(1).Reason(reason).Build(); + _testData.Update(_testData.Flag(flagKey).PreconfiguredFlag(flag)); + using (var client = MakeClient()) + { + var expected = new EvaluationDetail(2.5d, 1, reason); + Assert.Equal(expected, client.DoubleVariationDetail(flagKey, 0.5d)); + } + } + + [Fact] + public void StringVariationReturnsValue() + { + _testData.Update(_testData.Flag(flagKey).Variation(LdValue.Of("string value"))); + using (var client = MakeClient()) + { + Assert.Equal("string value", client.StringVariation(flagKey, "")); + } + } + + [Fact] + public void StringVariationReturnsDefaultForUnknownFlag() + { + using (var client = MakeClient()) + { + Assert.Equal("d", client.StringVariation(nonexistentFlagKey, "d")); + } + } + + [Fact] + public void StringVariationDetailReturnsValue() + { + var reason = EvaluationReason.OffReason; + var flag = new FeatureFlagBuilder().Value(LdValue.Of("string value")).Variation(1).Reason(reason).Build(); + _testData.Update(_testData.Flag(flagKey).PreconfiguredFlag(flag)); + using (var client = MakeClient()) + { + var expected = new EvaluationDetail("string value", 1, reason); + Assert.Equal(expected, client.StringVariationDetail(flagKey, "")); + } + } + + [Fact] + public void JsonVariationReturnsValue() + { + var jsonValue = LdValue.BuildObject().Add("thing", "stuff").Build(); + _testData.Update(_testData.Flag(flagKey).Variation(jsonValue)); + using (var client = MakeClient()) + { + Assert.Equal(jsonValue, client.JsonVariation(flagKey, LdValue.Of(3))); + } + } + + [Fact] + public void JsonVariationReturnsDefaultForUnknownFlag() + { + using (var client = MakeClient()) + { + var defaultVal = LdValue.Of(3); + Assert.Equal(defaultVal, client.JsonVariation(nonexistentFlagKey, defaultVal)); + } + } + + [Fact] + public void JsonVariationDetailReturnsValue() + { + var jsonValue = LdValue.BuildObject().Add("thing", "stuff").Build(); + var reason = EvaluationReason.OffReason; + var flag = new FeatureFlagBuilder().Value(jsonValue).Variation(1).Reason(reason).Build(); + _testData.Update(_testData.Flag(flagKey).PreconfiguredFlag(flag)); + using (var client = MakeClient()) + { + var expected = new EvaluationDetail(jsonValue, 1, reason); + var result = client.JsonVariationDetail(flagKey, LdValue.Of(3)); + Assert.Equal(expected.Value, result.Value); + Assert.Equal(expected.VariationIndex, result.VariationIndex); + Assert.Equal(expected.Reason, result.Reason); + } + } + + [Fact] + public void AllFlagsReturnsAllFlagValues() + { + _testData.Update(_testData.Flag("flag1").Variation(LdValue.Of("a"))); + _testData.Update(_testData.Flag("flag2").Variation(LdValue.Of("b"))); + using (var client = MakeClient()) + { + var result = client.AllFlags(); + Assert.Equal(2, result.Count); + Assert.Equal("a", result["flag1"].AsString); + Assert.Equal("b", result["flag2"].AsString); + } + } + + [Fact] + public void DefaultValueReturnedIfValueTypeIsDifferent() + { + _testData.Update(_testData.Flag(flagKey).Variation(LdValue.Of("string value"))); + using (var client = MakeClient()) + { + Assert.Equal(3, client.IntVariation(flagKey, 3)); + } + } + + [Fact] + public void DefaultValueAndReasonIsReturnedIfValueTypeIsDifferent() + { + _testData.Update(_testData.Flag(flagKey).Variation(LdValue.Of("string value"))); + using (var client = MakeClient()) + { + var expected = new EvaluationDetail(3, null, EvaluationReason.ErrorReason(EvaluationErrorKind.WrongType)); + Assert.Equal(expected, client.IntVariationDetail(flagKey, 3)); + } + } + + [Fact] + public void DefaultValueReturnedIfFlagValueIsNull() + { + _testData.Update(_testData.Flag(flagKey).Variation(LdValue.Null)); + using (var client = MakeClient()) + { + Assert.Equal(3, client.IntVariation(flagKey, 3)); + } + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientEventTests.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientEventTests.cs new file mode 100644 index 00000000..cecb0fdf --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientEventTests.cs @@ -0,0 +1,343 @@ +using System; +using LaunchDarkly.Sdk.Client.Integrations; +using LaunchDarkly.Sdk.Client.Subsystems; +using Xunit; +using Xunit.Abstractions; + +using static LaunchDarkly.Sdk.Client.Subsystems.EventProcessorTypes; + +namespace LaunchDarkly.Sdk.Client +{ + public class LdClientEventTests : BaseTest + { + private static readonly Context user = Context.New("userkey"); + private readonly TestData _testData = TestData.DataSource(); + private MockEventProcessor eventProcessor = new MockEventProcessor(); + private IComponentConfigurer _factory; + + public LdClientEventTests(ITestOutputHelper testOutput) : base(testOutput) + { + _factory = eventProcessor.AsSingletonFactory(); + } + + private LdClient MakeClient(Context c) => + LdClient.Init(BasicConfig().DataSource(_testData).Events(_factory).Build(), + c, TimeSpan.FromSeconds(1)); + + [Fact] + public void IdentifySendsIdentifyEvent() + { + using (LdClient client = MakeClient(user)) + { + Context user1 = Context.New("userkey1"); + client.Identify(user1, TimeSpan.FromSeconds(1)); + Assert.Collection(eventProcessor.Events, + e => CheckIdentifyEvent(e, user), // there's always an initial identify event + e => CheckIdentifyEvent(e, user1)); + } + } + + [Fact] + public void TrackSendsCustomEvent() + { + using (LdClient client = MakeClient(user)) + { + client.Track("eventkey"); + Assert.Collection(eventProcessor.Events, + e => CheckIdentifyEvent(e, user), + e => { + CustomEvent ce = Assert.IsType(e); + Assert.Equal("eventkey", ce.EventKey); + Assert.Equal(user.FullyQualifiedKey, ce.Context.FullyQualifiedKey); + Assert.Equal(LdValue.Null, ce.Data); + Assert.Null(ce.MetricValue); + Assert.NotEqual(0, ce.Timestamp.Value); + }); + } + } + + [Fact] + public void TrackWithDataSendsCustomEvent() + { + using (LdClient client = MakeClient(user)) + { + LdValue data = LdValue.Of("hi"); + client.Track("eventkey", data); + Assert.Collection(eventProcessor.Events, + e => CheckIdentifyEvent(e, user), + e => { + CustomEvent ce = Assert.IsType(e); + Assert.Equal("eventkey", ce.EventKey); + Assert.Equal(user.FullyQualifiedKey, ce.Context.FullyQualifiedKey); + Assert.Equal(data, ce.Data); + Assert.Null(ce.MetricValue); + Assert.NotEqual(0, ce.Timestamp.Value); + }); + } + } + + [Fact] + public void TrackWithMetricValueSendsCustomEvent() + { + using (LdClient client = MakeClient(user)) + { + LdValue data = LdValue.Of("hi"); + double metricValue = 1.5; + client.Track("eventkey", data, metricValue); + Assert.Collection(eventProcessor.Events, + e => CheckIdentifyEvent(e, user), + e => { + CustomEvent ce = Assert.IsType(e); + Assert.Equal("eventkey", ce.EventKey); + Assert.Equal(user.FullyQualifiedKey, ce.Context.FullyQualifiedKey); + Assert.Equal(data, ce.Data); + Assert.Equal(metricValue, ce.MetricValue); + Assert.NotEqual(0, ce.Timestamp.Value); + }); + } + } + + [Fact] + public void VariationSendsFeatureEventForValidFlag() + { + var flag = new FeatureFlagBuilder().Value(LdValue.Of("a")).Variation(1).Version(1000) + .TrackEvents(true).DebugEventsUntilDate(UnixMillisecondTime.OfMillis(2000)).Build(); + _testData.Update(_testData.Flag("flag").PreconfiguredFlag(flag)); + using (LdClient client = MakeClient(user)) + { + string result = client.StringVariation("flag", "b"); + Assert.Equal("a", result); + Assert.Collection(eventProcessor.Events, + e => CheckIdentifyEvent(e, user), + e => { + EvaluationEvent fe = Assert.IsType(e); + Assert.Equal("flag", fe.FlagKey); + Assert.Equal("a", fe.Value.AsString); + Assert.Equal(1, fe.Variation); + Assert.Equal(1000, fe.FlagVersion); + Assert.Equal("b", fe.Default.AsString); + Assert.True(fe.TrackEvents); + Assert.Equal(UnixMillisecondTime.OfMillis(2000), fe.DebugEventsUntilDate); + Assert.Null(fe.Reason); + Assert.NotEqual(0, fe.Timestamp.Value); + }); + } + } + + [Fact] + public void FeatureEventUsesFlagVersionIfProvided() + { + var flag = new FeatureFlagBuilder().Value(LdValue.Of("a")).Variation(1).Version(1000) + .FlagVersion(1500).Build(); + _testData.Update(_testData.Flag("flag").PreconfiguredFlag(flag)); + using (LdClient client = MakeClient(user)) + { + string result = client.StringVariation("flag", "b"); + Assert.Equal("a", result); + Assert.Collection(eventProcessor.Events, + e => CheckIdentifyEvent(e, user), + e => { + EvaluationEvent fe = Assert.IsType(e); + Assert.Equal("flag", fe.FlagKey); + Assert.Equal("a", fe.Value.AsString); + Assert.Equal(1, fe.Variation); + Assert.Equal(1500, fe.FlagVersion); + Assert.Equal("b", fe.Default.AsString); + Assert.NotEqual(0, fe.Timestamp.Value); + }); + } + } + + [Fact] + public void VariationSendsFeatureEventForDefaultValue() + { + var flag = new FeatureFlagBuilder().Version(1000).Build(); + _testData.Update(_testData.Flag("flag").PreconfiguredFlag(flag)); + using (LdClient client = MakeClient(user)) + { + string result = client.StringVariation("flag", "b"); + Assert.Equal("b", result); + Assert.Collection(eventProcessor.Events, + e => CheckIdentifyEvent(e, user), + e => { + EvaluationEvent fe = Assert.IsType(e); + Assert.Equal("flag", fe.FlagKey); + Assert.Equal("b", fe.Value.AsString); + Assert.Null(fe.Variation); + Assert.Equal(1000, fe.FlagVersion); + Assert.Equal("b", fe.Default.AsString); + Assert.Null(fe.Reason); + Assert.NotEqual(0, fe.Timestamp.Value); + }); + } + } + + [Fact] + public void VariationSendsFeatureEventForUnknownFlag() + { + using (LdClient client = MakeClient(user)) + { + string result = client.StringVariation("flag", "b"); + Assert.Equal("b", result); + Assert.Collection(eventProcessor.Events, + e => CheckIdentifyEvent(e, user), + e => { + EvaluationEvent fe = Assert.IsType(e); + Assert.Equal("flag", fe.FlagKey); + Assert.Equal("b", fe.Value.AsString); + Assert.Null(fe.Variation); + Assert.Null(fe.FlagVersion); + Assert.Equal("b", fe.Default.AsString); + Assert.Null(fe.Reason); + Assert.NotEqual(0, fe.Timestamp.Value); + }); + } + } + + [Fact] + public void VariationSendsFeatureEventForUnknownFlagWhenClientIsNotInitialized() + { + var config = BasicConfig() + .DataSource(new MockDataSourceThatNeverInitializes().AsSingletonFactory()) + .Events(_factory); + + using (LdClient client = TestUtil.CreateClient(config.Build(), user)) + { + string result = client.StringVariation("flag", "b"); + Assert.Equal("b", result); + Assert.Collection(eventProcessor.Events, + e => CheckIdentifyEvent(e, user), + e => + { + EvaluationEvent fe = Assert.IsType(e); + Assert.Equal("flag", fe.FlagKey); + Assert.Equal("b", fe.Value.AsString); + Assert.Null(fe.Variation); + Assert.Null(fe.FlagVersion); + Assert.Equal("b", fe.Default.AsString); + Assert.Null(fe.Reason); + Assert.NotEqual(0, fe.Timestamp.Value); + }); + } + } + + [Fact] + public void VariationSendsFeatureEventWithTrackingAndReasonIfTrackReasonIsTrue() + { + var flag = new FeatureFlagBuilder().Value(LdValue.Of("a")).Variation(1).Version(1000) + .TrackReason(true).Reason(EvaluationReason.OffReason).Build(); + _testData.Update(_testData.Flag("flag").PreconfiguredFlag(flag)); + using (LdClient client = MakeClient(user)) + { + string result = client.StringVariation("flag", "b"); + Assert.Equal("a", result); + Assert.Collection(eventProcessor.Events, + e => CheckIdentifyEvent(e, user), + e => { + EvaluationEvent fe = Assert.IsType(e); + Assert.Equal("flag", fe.FlagKey); + Assert.Equal("a", fe.Value.AsString); + Assert.Equal(1, fe.Variation); + Assert.Equal(1000, fe.FlagVersion); + Assert.Equal("b", fe.Default.AsString); + Assert.True(fe.TrackEvents); + Assert.Null(fe.DebugEventsUntilDate); + Assert.Equal(EvaluationReason.OffReason, fe.Reason); + Assert.NotEqual(0, fe.Timestamp.Value); + }); + } + } + + [Fact] + public void VariationDetailSendsFeatureEventWithReasonForValidFlag() + { + var flag = new FeatureFlagBuilder().Value(LdValue.Of("a")).Variation(1).Version(1000) + .TrackEvents(true).DebugEventsUntilDate(UnixMillisecondTime.OfMillis(2000)) + .Reason(EvaluationReason.OffReason).Build(); + _testData.Update(_testData.Flag("flag").PreconfiguredFlag(flag)); + using (LdClient client = MakeClient(user)) + { + EvaluationDetail result = client.StringVariationDetail("flag", "b"); + Assert.Equal("a", result.Value); + Assert.Equal(EvaluationReason.OffReason, result.Reason); + Assert.Collection(eventProcessor.Events, + e => CheckIdentifyEvent(e, user), + e => { + EvaluationEvent fe = Assert.IsType(e); + Assert.Equal("flag", fe.FlagKey); + Assert.Equal("a", fe.Value.AsString); + Assert.Equal(1, fe.Variation); + Assert.Equal(1000, fe.FlagVersion); + Assert.Equal("b", fe.Default.AsString); + Assert.True(fe.TrackEvents); + Assert.Equal(UnixMillisecondTime.OfMillis(2000), fe.DebugEventsUntilDate); + Assert.Equal(EvaluationReason.OffReason, fe.Reason); + Assert.NotEqual(0, fe.Timestamp.Value); + }); + } + } + + [Fact] + public void VariationDetailSendsFeatureEventWithReasonForUnknownFlag() + { + using (LdClient client = MakeClient(user)) + { + EvaluationDetail result = client.StringVariationDetail("flag", "b"); + var expectedReason = EvaluationReason.ErrorReason(EvaluationErrorKind.FlagNotFound); + Assert.Equal("b", result.Value); + Assert.Equal(expectedReason, result.Reason); + Assert.Collection(eventProcessor.Events, + e => CheckIdentifyEvent(e, user), + e => { + EvaluationEvent fe = Assert.IsType(e); + Assert.Equal("flag", fe.FlagKey); + Assert.Equal("b", fe.Value.AsString); + Assert.Null(fe.Variation); + Assert.Null(fe.FlagVersion); + Assert.Equal("b", fe.Default.AsString); + Assert.False(fe.TrackEvents); + Assert.Null(fe.DebugEventsUntilDate); + Assert.Equal(expectedReason, fe.Reason); + Assert.NotEqual(0, fe.Timestamp.Value); + }); + } + } + + [Fact] + public void VariationSendsFeatureEventWithReasonForUnknownFlagWhenClientIsNotInitialized() + { + var config = BasicConfig() + .DataSource(new MockDataSourceThatNeverInitializes().AsSingletonFactory()) + .Events(_factory); + + using (LdClient client = TestUtil.CreateClient(config.Build(), user)) + { + EvaluationDetail result = client.StringVariationDetail("flag", "b"); + var expectedReason = EvaluationReason.ErrorReason(EvaluationErrorKind.ClientNotReady); + Assert.Equal("b", result.Value); + Assert.Equal(expectedReason, result.Reason); + Assert.Collection(eventProcessor.Events, + e => CheckIdentifyEvent(e, user), + e => { + EvaluationEvent fe = Assert.IsType(e); + Assert.Equal("flag", fe.FlagKey); + Assert.Equal("b", fe.Value.AsString); + Assert.Null(fe.Variation); + Assert.Null(fe.FlagVersion); + Assert.Equal("b", fe.Default.AsString); + Assert.False(fe.TrackEvents); + Assert.Null(fe.DebugEventsUntilDate); + Assert.Equal(expectedReason, fe.Reason); + Assert.NotEqual(0, fe.Timestamp.Value); + }); + } + } + + private void CheckIdentifyEvent(object e, Context c) + { + IdentifyEvent ie = Assert.IsType(e); + Assert.Equal(c.FullyQualifiedKey, ie.Context.FullyQualifiedKey); + Assert.NotEqual(0, ie.Timestamp.Value); + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientListenersTest.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientListenersTest.cs new file mode 100644 index 00000000..54b42ca6 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientListenersTest.cs @@ -0,0 +1,75 @@ +using System; +using LaunchDarkly.Sdk.Client.Integrations; +using LaunchDarkly.Sdk.Client.Interfaces; +using LaunchDarkly.TestHelpers; +using Xunit; +using Xunit.Abstractions; + +namespace LaunchDarkly.Sdk.Client +{ + public class LdClientListenersTest : BaseTest + { + // Tests for data source status listeners are in LdClientDataSourceStatusTests. + + public LdClientListenersTest(ITestOutputHelper testOutput) : base(testOutput) { } + + [Fact] + public void ClientSendsFlagValueChangeEvents() + { + var testData = TestData.DataSource(); + var config = BasicConfig().DataSource(testData).Build(); + + var flagKey = "flagkey"; + testData.Update(testData.Flag(flagKey).Variation(true)); + + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + var eventSink1 = new EventSink(); + var eventSink2 = new EventSink(); + EventHandler listener1 = eventSink1.Add; + EventHandler listener2 = eventSink2.Add; + client.FlagTracker.FlagValueChanged += listener1; + client.FlagTracker.FlagValueChanged += listener2; + + eventSink1.ExpectNoValue(); + eventSink2.ExpectNoValue(); + + testData.Update(testData.Flag(flagKey).Variation(false)); + + var event1 = eventSink1.ExpectValue(); + var event2 = eventSink2.ExpectValue(); + Assert.Equal(flagKey, event1.Key); + Assert.Equal(LdValue.Of(true), event1.OldValue); + Assert.Equal(LdValue.Of(false), event1.NewValue); + Assert.Equal(event1, event2); + + eventSink1.ExpectNoValue(); + eventSink2.ExpectNoValue(); + } + } + + [Fact] + public void EventSenderIsClientInstance() + { + // We're only checking one kind of events here (FlagValueChanged), but since the SDK uses the + // same TaskExecutor instance for all event dispatches and the sender is configured in + // that object, the sender should be the same for all events. + + var flagKey = "flagKey"; + var testData = TestData.DataSource(); + testData.Update(testData.Flag(flagKey).Variation(true)); + var config = BasicConfig().DataSource(testData).Build(); + + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + var receivedSender = new EventSink(); + client.FlagTracker.FlagValueChanged += (s, e) => receivedSender.Enqueue(s); + + testData.Update(testData.Flag(flagKey).Variation(false)); + + var sender = receivedSender.ExpectValue(); + Assert.Same(client, sender); + } + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientServiceEndpointsTests.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientServiceEndpointsTests.cs new file mode 100644 index 00000000..a93ac6fd --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientServiceEndpointsTests.cs @@ -0,0 +1,195 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Client.Internal; +using LaunchDarkly.TestHelpers; +using Xunit; +using Xunit.Abstractions; + +namespace LaunchDarkly.Sdk.Client +{ + public class LdClientServiceEndpointsTests : BaseTest + { + // These tests verify that the SDK is using the expected base URIs in various configurations. + // Since we need to be able to intercept requests that would normally go to the production service + // endpoints, and we don't care about simulating realistic responses, we'll just use a simple + // HttpMessageHandler stub. + + private static readonly Uri CustomUri = new Uri("http://custom"); + + private SimpleRecordingHttpMessageHandler _stubHandler = new SimpleRecordingHttpMessageHandler(401); + + public LdClientServiceEndpointsTests(ITestOutputHelper testOutput) : base(testOutput) { } + + [Fact] + public void DefaultStreamingDataSourceBaseUri() + { + using (var client = TestUtil.CreateClient( + BasicConfig() + .DataSource(Components.StreamingDataSource()) + .Http(Components.HttpConfiguration().MessageHandler(_stubHandler)) + .Build(), + BasicUser)) + { + var req = _stubHandler.Requests.ExpectValue(); + Assert.Equal(StandardEndpoints.BaseUris.StreamingBaseUri, BaseUriOf(req.RequestUri)); + } + } + + [Fact] + public void DefaultPollingDataSourceBaseUri() + { + using (var client = TestUtil.CreateClient( + BasicConfig() + .DataSource(Components.PollingDataSource()) + .Http(Components.HttpConfiguration().MessageHandler(_stubHandler)) + .Build(), + BasicUser)) + { + var req = _stubHandler.Requests.ExpectValue(); + Assert.Equal(StandardEndpoints.BaseUris.PollingBaseUri, BaseUriOf(req.RequestUri)); + } + } + + [Fact] + public void DefaultEventsBaseUri() + { + using (var client = TestUtil.CreateClient( + BasicConfig() + .Events(Components.SendEvents().FlushIntervalNoMinimum(TimeSpan.FromMilliseconds(10))) + .Http(Components.HttpConfiguration().MessageHandler(_stubHandler)) + .Build(), + BasicUser)) + { + var req = _stubHandler.Requests.ExpectValue(); + Assert.Equal(StandardEndpoints.BaseUris.EventsBaseUri, BaseUriOf(req.RequestUri)); + } + } + + [Fact] + public void CustomStreamingDataSourceBaseUri() + { + using (var client = TestUtil.CreateClient( + BasicConfig() + .DataSource(Components.StreamingDataSource()) + .Http(Components.HttpConfiguration().MessageHandler(_stubHandler)) + .ServiceEndpoints(Components.ServiceEndpoints().Streaming(CustomUri).Polling(CustomUri)) + .Build(), + BasicUser)) + { + var req = _stubHandler.Requests.ExpectValue(); + Assert.Equal(CustomUri, BaseUriOf(req.RequestUri)); + + Assert.False(logCapture.HasMessageWithRegex(LogLevel.Error, + "You have set custom ServiceEndpoints without specifying")); + } + } + + [Fact] + public void CustomPollingDataSourceBaseUri() + { + using (var client = TestUtil.CreateClient( + BasicConfig() + .DataSource(Components.PollingDataSource()) + .Http(Components.HttpConfiguration().MessageHandler(_stubHandler)) + .ServiceEndpoints(Components.ServiceEndpoints().Polling(CustomUri)) + .Build(), + BasicUser)) + { + var req = _stubHandler.Requests.ExpectValue(); + Assert.Equal(CustomUri, BaseUriOf(req.RequestUri)); + + Assert.False(logCapture.HasMessageWithRegex(LogLevel.Error, + "You have set custom ServiceEndpoints without specifying")); + } + } + + [Fact] + public void CustomEventsBaseUri() + { + using (var client = TestUtil.CreateClient( + BasicConfig() + .Events(Components.SendEvents().FlushIntervalNoMinimum(TimeSpan.FromMilliseconds(10))) + .Http(Components.HttpConfiguration().MessageHandler(_stubHandler)) + .ServiceEndpoints(Components.ServiceEndpoints().Events(CustomUri)) + .Build(), + BasicUser)) + { + var req = _stubHandler.Requests.ExpectValue(); + Assert.Equal(CustomUri, BaseUriOf(req.RequestUri)); + + Assert.False(logCapture.HasMessageWithRegex(LogLevel.Error, + "You have set custom ServiceEndpoints without specifying")); + } + } + + [Fact] + public void ErrorIsLoggedIfANecessaryUriIsNotSetWhenOtherCustomUrisAreSet() + { + var logCapture1 = Logs.Capture(); + using (var client = TestUtil.CreateClient( + BasicConfig() + .DataSource(Components.StreamingDataSource()) + .Http(Components.HttpConfiguration().MessageHandler(_stubHandler)) + .Logging(logCapture1) + .ServiceEndpoints(Components.ServiceEndpoints().Polling(CustomUri)) + .Build(), + BasicUser)) + { + Assert.True(logCapture1.HasMessageWithRegex(LogLevel.Error, + "You have set custom ServiceEndpoints without specifying the Streaming base URI")); + } + + var logCapture2 = Logs.Capture(); + using (var client = TestUtil.CreateClient( + BasicConfig() + .DataSource(Components.PollingDataSource()) + .Http(Components.HttpConfiguration().MessageHandler(_stubHandler)) + .Logging(logCapture2) + .ServiceEndpoints(Components.ServiceEndpoints().Events(CustomUri)) + .Build(), + BasicUser)) + { + Assert.True(logCapture2.HasMessageWithRegex(LogLevel.Error, + "You have set custom ServiceEndpoints without specifying the Polling base URI")); + } + + var logCapture3 = Logs.Capture(); + using (var client = TestUtil.CreateClient( + BasicConfig() + .Events(Components.SendEvents()) + .Http(Components.HttpConfiguration().MessageHandler(_stubHandler)) + .Logging(logCapture3) + .ServiceEndpoints(Components.ServiceEndpoints().Streaming(CustomUri)) + .Build(), + BasicUser)) + { + Assert.True(logCapture3.HasMessageWithRegex(LogLevel.Error, + "You have set custom ServiceEndpoints without specifying the Events base URI")); + } + } + + private static Uri BaseUriOf(Uri uri) => + new Uri(uri.GetComponents(UriComponents.Scheme | UriComponents.HostAndPort | UriComponents.KeepDelimiter, UriFormat.Unescaped)); + + private class SimpleRecordingHttpMessageHandler : HttpMessageHandler + { + internal readonly EventSink Requests = new EventSink(); + private int _statusCode; + + public SimpleRecordingHttpMessageHandler(int statusCode) + { + _statusCode = statusCode; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Enqueue(request); + return Task.FromResult(new HttpResponseMessage((HttpStatusCode)_statusCode)); + } + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientTests.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientTests.cs new file mode 100644 index 00000000..ce75a701 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/LdClientTests.cs @@ -0,0 +1,605 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using LaunchDarkly.Sdk.Client.Subsystems; +using Xunit; +using Xunit.Abstractions; + +namespace LaunchDarkly.Sdk.Client +{ + public class LdClientTests : BaseTest + { + private static readonly Context AnonUser = Context.Builder("anon-placeholder-key") + .Anonymous(true) + .Set("email", "example") + .Set("other", 3) + .Build(); + + public LdClientTests(ITestOutputHelper testOutput) : base(testOutput) { } + + [Fact] + public void CannotCreateClientWithNullConfig() + { + Assert.Throws(() => LdClient.Init((Configuration)null, BasicUser, TimeSpan.Zero)); + } + + [Fact] + public void CannotCreateClientWithNegativeWaitTime() + { + var config = BasicConfig().Build(); + Assert.Throws(() => LdClient.Init(config, BasicUser, TimeSpan.FromMilliseconds(-2))); + } + + [Fact] + public void CanCreateClientWithInfiniteWaitTime() + { + var config = BasicConfig().Build(); + using (var client = LdClient.Init(config, BasicUser, System.Threading.Timeout.InfiniteTimeSpan)) { } + TestUtil.ClearClient(); + } + + [Fact] + public async void InitPassesUserToDataSource() + { + MockPollingProcessor stub = new MockPollingProcessor(DataSetBuilder.Empty); + + var dataSourceConfig = new CapturingComponentConfigurer(stub.AsSingletonFactory()); + var config = BasicConfig() + .DataSource(dataSourceConfig) + .Build(); + + using (var client = await LdClient.InitAsync(config, BasicUser)) + { + var actualUser = client.Context; // may have been transformed e.g. to add device/OS properties + Assert.Equal(BasicUser.Key, actualUser.Key); + Assert.Equal(actualUser, dataSourceConfig.ReceivedClientContext.CurrentContext); + } + } + + [Fact] + public async Task InitWithAnonUserAddsRandomizedKey() + { + // Note, we don't care about polling mode vs. streaming mode for this functionality. + var config = BasicConfig().Persistence(Components.NoPersistence).GenerateAnonymousKeys(true).Build(); + + string key1; + + using (var client = await TestUtil.CreateClientAsync(config, AnonUser)) + { + key1 = client.Context.FullyQualifiedKey; + Assert.NotNull(key1); + Assert.NotEqual("", key1); + AssertHelpers.ContextsEqual( + Context.BuilderFromContext(AnonUser).Key(key1).Build(), + client.Context); + } + + // Starting again should generate a new key, since we've turned off persistence + using (var client = await TestUtil.CreateClientAsync(config, AnonUser)) + { + var key2 = client.Context.FullyQualifiedKey; + Assert.NotNull(key2); + Assert.NotEqual("", key2); + Assert.NotEqual(key1, key2); + AssertHelpers.ContextsEqual( + Context.BuilderFromContext(AnonUser).Key(key2).Build(), + client.Context); + } + } + + [Fact] + public async Task InitWithAnonUserDoesNotChangeKeyIfConfigOptionIsNotSet() + { + // Note, we don't care about polling mode vs. streaming mode for this functionality. + var config = BasicConfig().Persistence(Components.NoPersistence).Build(); + + using (var client = await TestUtil.CreateClientAsync(config, AnonUser)) + { + AssertHelpers.ContextsEqual(AnonUser, client.Context); + } + } + + [Fact] + public async Task InitWithAnonUserCanReusePreviousRandomizedKey() + { + // Note, we don't care about polling mode vs. streaming mode for this functionality. + var store = new MockPersistentDataStore(); + var config = BasicConfig().Persistence(Components.Persistence().Storage( + store.AsSingletonFactory())) + .GenerateAnonymousKeys(true).Build(); + + string key1; + + using (var client = await TestUtil.CreateClientAsync(config, AnonUser)) + { + key1 = client.Context.FullyQualifiedKey; + Assert.NotNull(key1); + Assert.NotEqual("", key1); + AssertHelpers.ContextsEqual( + Context.BuilderFromContext(AnonUser).Key(key1).Build(), + client.Context); + } + + // Starting again should reuse the persisted key + using (var client = await TestUtil.CreateClientAsync(config, AnonUser)) + { + Assert.Equal(key1, client.Context.FullyQualifiedKey); + AssertHelpers.ContextsEqual( + Context.BuilderFromContext(AnonUser).Key(key1).Build(), + client.Context); + } + } + + [Fact] + public async void InitWithAnonUserPassesGeneratedUserToDataSource() + { + MockPollingProcessor stub = new MockPollingProcessor(DataSetBuilder.Empty); + + var dataSourceConfig = new CapturingComponentConfigurer(stub.AsSingletonFactory()); + var config = BasicConfig() + .DataSource(dataSourceConfig) + .GenerateAnonymousKeys(true) + .Build(); + + using (var client = await LdClient.InitAsync(config, AnonUser)) + { + var receivedContext = dataSourceConfig.ReceivedClientContext.CurrentContext; + Assert.NotEqual(AnonUser, receivedContext); + Assert.Equal(client.Context, receivedContext); + AssertHelpers.ContextsEqual( + Context.BuilderFromContext(AnonUser).Key(receivedContext.FullyQualifiedKey).Build(), + receivedContext); + } + } + + [Fact] + public async void InitWithAutoEnvAttributesEnabledAddAppInfoContext() + { + MockPollingProcessor stub = new MockPollingProcessor(DataSetBuilder.Empty); + var dataSourceConfig = new CapturingComponentConfigurer(stub.AsSingletonFactory()); + var config = BasicConfig() + .AutoEnvironmentAttributes(ConfigurationBuilder.AutoEnvAttributes.Enabled) + .DataSource(dataSourceConfig) + .GenerateAnonymousKeys(true) + .Build(); + + using (var client = await LdClient.InitAsync(config, AnonUser)) + { + var receivedContext = dataSourceConfig.ReceivedClientContext.CurrentContext; + Assert.True(receivedContext.TryGetContextByKind(ContextKind.Of("ld_application"), out _)); + } + } + + [Fact] + public async void InitWithAutoEnvAttributesDisabledNoAddedContexts() + { + MockPollingProcessor stub = new MockPollingProcessor(DataSetBuilder.Empty); + var dataSourceConfig = new CapturingComponentConfigurer(stub.AsSingletonFactory()); + var config = BasicConfig() + .AutoEnvironmentAttributes(ConfigurationBuilder.AutoEnvAttributes.Disabled) + .DataSource(dataSourceConfig) + .GenerateAnonymousKeys(true) + .Build(); + + using (var client = await LdClient.InitAsync(config, AnonUser)) + { + var receivedContext = dataSourceConfig.ReceivedClientContext.CurrentContext; + Assert.NotEqual(AnonUser, receivedContext); + Assert.Equal(client.Context, receivedContext); + AssertHelpers.ContextsEqual( + Context.BuilderFromContext(AnonUser).Key(receivedContext.FullyQualifiedKey).Build(), + receivedContext); + } + } + + [Fact] + public void IdentifyUpdatesTheUser() + { + using (var client = TestUtil.CreateClient(BasicConfig().Build(), BasicUser)) + { + var updatedUser = Context.New("some new key"); + var success = client.Identify(updatedUser, TimeSpan.FromSeconds(1)); + Assert.True(success); + Assert.Equal(client.Context.FullyQualifiedKey, updatedUser.FullyQualifiedKey); // don't compare entire user, because SDK may have added device/os attributes + } + } + + [Fact] + public Task IdentifyAsyncCompletesOnlyWhenNewFlagsAreAvailable() + => IdentifyCompletesOnlyWhenNewFlagsAreAvailable((client, context) => client.IdentifyAsync(context)); + + [Fact] + public Task IdentifySyncCompletesOnlyWhenNewFlagsAreAvailable() + => IdentifyCompletesOnlyWhenNewFlagsAreAvailable((client, context) => Task.Run(() => client.Identify(context, TimeSpan.FromSeconds(4)))); + + private async Task IdentifyCompletesOnlyWhenNewFlagsAreAvailable(Func identifyTask) + { + var userA = Context.New("a"); + var userB = Context.New("b"); + + var flagKey = "flag"; + var userAFlags = new DataSetBuilder() + .Add(flagKey, 1, LdValue.Of("a-value"), 0).Build(); + var userBFlags = new DataSetBuilder() + .Add(flagKey, 2, LdValue.Of("b-value"), 1).Build(); + + var startedIdentifyUserB = new SemaphoreSlim(0, 1); + var canFinishIdentifyUserB = new SemaphoreSlim(0, 1); + var finishedIdentifyUserB = new SemaphoreSlim(0, 1); + + var dataSourceFactory = MockComponents.ComponentConfigurerFromLambda(ctx => + new MockDataSourceFromLambda(ctx.CurrentContext, async () => + { + switch (ctx.CurrentContext.FullyQualifiedKey) + { + case "a": + ctx.DataSourceUpdateSink.Init(ctx.CurrentContext, userAFlags); + break; + + case "b": + startedIdentifyUserB.Release(); + await canFinishIdentifyUserB.WaitAsync(); + ctx.DataSourceUpdateSink.Init(ctx.CurrentContext, userBFlags); + break; + } + })); + + var config = BasicConfig() + .DataSource(dataSourceFactory) + .Build(); + + using (var client = await LdClient.InitAsync(config, userA)) + { + Assert.True(client.Initialized); + Assert.Equal("a-value", client.StringVariation(flagKey, null)); + + var identifyUserBTask = Task.Run(async () => + { + await identifyTask(client, userB); + finishedIdentifyUserB.Release(); + }); + + await startedIdentifyUserB.WaitAsync(); + + Assert.False(client.Initialized); + Assert.Null(client.StringVariation(flagKey, null)); + + canFinishIdentifyUserB.Release(); + await finishedIdentifyUserB.WaitAsync(); + + Assert.True(client.Initialized); + Assert.Equal("b-value", client.StringVariation(flagKey, null)); + } + } + + [Fact] + public async void IdentifyPassesUserToDataSource() + { + MockPollingProcessor stub = new MockPollingProcessor(DataSetBuilder.Empty); + Context newUser = Context.New("new-user"); + + var dataSourceConfig = new CapturingComponentConfigurer(stub.AsSingletonFactory()); + var config = BasicConfig() + .DataSource(dataSourceConfig) + .Build(); + + using (var client = await LdClient.InitAsync(config, BasicUser)) + { + var receivedContext = dataSourceConfig.ReceivedClientContext.CurrentContext; + AssertHelpers.ContextsEqual(BasicUser, client.Context); + Assert.Equal(client.Context, receivedContext); + + await client.IdentifyAsync(newUser); + + receivedContext = dataSourceConfig.ReceivedClientContext.CurrentContext; + AssertHelpers.ContextsEqual(newUser, client.Context); + Assert.Equal(client.Context, receivedContext); + } + } + + [Fact] + public async Task IdentifyWithAnonUserAddsRandomizedKey() + { + var config = BasicConfig().Persistence(Components.NoPersistence).GenerateAnonymousKeys(true).Build(); + + string key1; + + using (var client = await TestUtil.CreateClientAsync(config, BasicUser)) + { + await client.IdentifyAsync(AnonUser); + + key1 = client.Context.FullyQualifiedKey; + Assert.NotNull(key1); + Assert.NotEqual("", key1); + AssertHelpers.ContextsEqual( + Context.BuilderFromContext(AnonUser).Key(key1).Build(), + client.Context); + + var anonUser2 = TestUtil.BuildAutoContext().Name("other").Build(); + await client.IdentifyAsync(anonUser2); + var key2 = client.Context.FullyQualifiedKey; + Assert.Equal(key1, key2); // Even though persistence is disabled, the key is stable during the lifetime of the SDK client. + AssertHelpers.ContextsEqual( + Context.BuilderFromContext(anonUser2).Key(key2).Build(), + client.Context); + } + + using (var client = await TestUtil.CreateClientAsync(config, BasicUser)) + { + await client.IdentifyAsync(AnonUser); + + var key3 = client.Context.FullyQualifiedKey; + Assert.NotNull(key3); + Assert.NotEqual("", key3); + Assert.NotEqual(key1, key3); // The previously generated key was discarded with the previous client. + AssertHelpers.ContextsEqual( + Context.BuilderFromContext(AnonUser).Key(key3).Build(), + client.Context); + } + } + + [Fact] + public async Task IdentifyWithAnonUserDoesNotChangeKeyIfConfigOptionIsNotSet() + { + var config = BasicConfig().Persistence(Components.NoPersistence).Build(); + + using (var client = await TestUtil.CreateClientAsync(config, BasicUser)) + { + await client.IdentifyAsync(AnonUser); + + AssertHelpers.ContextsEqual(AnonUser, client.Context); + } + } + + [Fact] + public async Task IdentifyWithAnonUserCanReusePersistedRandomizedKey() + { + var store = new MockPersistentDataStore(); + var config = BasicConfig().Persistence(Components.Persistence().Storage( + store.AsSingletonFactory())) + .GenerateAnonymousKeys(true).Build(); + + string key1; + + using (var client = await TestUtil.CreateClientAsync(config, BasicUser)) + { + await client.IdentifyAsync(AnonUser); + + key1 = client.Context.FullyQualifiedKey; + Assert.NotNull(key1); + Assert.NotEqual("", key1); + AssertHelpers.ContextsEqual( + Context.BuilderFromContext(AnonUser).Key(key1).Build(), + client.Context); + } + + using (var client = await TestUtil.CreateClientAsync(config, BasicUser)) + { + await client.IdentifyAsync(AnonUser); + + var key2 = client.Context.FullyQualifiedKey; + Assert.Equal(key1, key2); + AssertHelpers.ContextsEqual( + Context.BuilderFromContext(AnonUser).Key(key2).Build(), + client.Context); + } + } + + [Fact] + public async void IdentifyWithAnonUserPassesGeneratedUserToDataSource() + { + MockPollingProcessor stub = new MockPollingProcessor(DataSetBuilder.Empty); + + var dataSourceConfig = new CapturingComponentConfigurer(stub.AsSingletonFactory()); + var config = BasicConfig() + .DataSource(dataSourceConfig) + .GenerateAnonymousKeys(true) + .Build(); + + using (var client = await LdClient.InitAsync(config, BasicUser)) + { + await client.IdentifyAsync(AnonUser); + + var receivedContext = dataSourceConfig.ReceivedClientContext.CurrentContext; + Assert.NotEqual(AnonUser, receivedContext); + Assert.Equal(client.Context, receivedContext); + AssertHelpers.ContextsEqual( + Context.BuilderFromContext(AnonUser).Key(client.Context.FullyQualifiedKey).Build(), + receivedContext); + } + } + + [Fact] + public async void IdentifyWithAutoEnvAttributesEnabledAddsAppInfoContext() + { + var stub = new MockPollingProcessor(DataSetBuilder.Empty); + var dataSourceConfig = new CapturingComponentConfigurer(stub.AsSingletonFactory()); + var config = BasicConfig() + .AutoEnvironmentAttributes(ConfigurationBuilder.AutoEnvAttributes.Enabled) + .DataSource(dataSourceConfig) + .GenerateAnonymousKeys(true) + .Build(); + + using (var client = await LdClient.InitAsync(config, BasicUser)) + { + await client.IdentifyAsync(AnonUser); + + var receivedContext = dataSourceConfig.ReceivedClientContext.CurrentContext; + Assert.True(receivedContext.TryGetContextByKind(ContextKind.Of("ld_application"), out _)); + } + } + + [Fact] + public async void IdentifyWithAutoEnvAttributesDisabledNoAddedContexts() + { + var stub = new MockPollingProcessor(DataSetBuilder.Empty); + var dataSourceConfig = new CapturingComponentConfigurer(stub.AsSingletonFactory()); + var config = BasicConfig() + .AutoEnvironmentAttributes(ConfigurationBuilder.AutoEnvAttributes.Disabled) + .DataSource(dataSourceConfig) + .GenerateAnonymousKeys(true) + .Build(); + + using (var client = await LdClient.InitAsync(config, BasicUser)) + { + await client.IdentifyAsync(AnonUser); + + var receivedContext = dataSourceConfig.ReceivedClientContext.CurrentContext; + Assert.NotEqual(AnonUser, receivedContext); + Assert.Equal(client.Context, receivedContext); + AssertHelpers.ContextsEqual( + Context.BuilderFromContext(AnonUser).Key(receivedContext.FullyQualifiedKey).Build(), + receivedContext); + } + } + + [Fact] + public void SharedClientIsTheOnlyClientAvailable() + { + TestUtil.WithClientLock(() => + { + var config = BasicConfig().Build(); + using (var client = LdClient.Init(config, BasicUser, TimeSpan.Zero)) + { + Assert.Throws(() => LdClient.Init(config, BasicUser, TimeSpan.Zero)); + } + TestUtil.ClearClient(); + }); + } + + [Fact] + public void CanCreateNewClientAfterDisposingOfSharedInstance() + { + TestUtil.WithClientLock(() => + { + TestUtil.ClearClient(); + var config = BasicConfig().Build(); + using (var client0 = LdClient.Init(config, BasicUser, TimeSpan.Zero)) { } + Assert.Null(LdClient.Instance); + // Dispose() is called automatically at end of "using" block + using (var client1 = LdClient.Init(config, BasicUser, TimeSpan.Zero)) { } + }); + } + + [Fact] + public void ConnectionChangeShouldStopDataSource() + { + var mockUpdateProc = new MockPollingProcessor(null); + var mockConnectivityStateManager = new MockConnectivityStateManager(true); + var config = BasicConfig() + .DataSource(mockUpdateProc.AsSingletonFactory()) + .ConnectivityStateManager(mockConnectivityStateManager) + .Build(); + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + mockConnectivityStateManager.Connect(false); + Assert.False(mockUpdateProc.IsRunning); + } + } + + [Fact] + public void FlagsAreLoadedFromPersistentStorageByDefault() + { + var storage = new MockPersistentDataStore(); + var data = new DataSetBuilder().Add("flag", 1, LdValue.Of(100), 0).Build(); + var config = BasicConfig() + .Persistence(Components.Persistence().Storage(storage.AsSingletonFactory())) + .Offline(true) + .Build(); + storage.SetupUserData(config.MobileKey, BasicUser.Key, data); + + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + Assert.Equal(100, client.IntVariation("flag", 99)); + } + } + + [Fact] + public void FlagsAreSavedToPersistentStorageByDefault() + { + var storage = new MockPersistentDataStore(); + var initialFlags = new DataSetBuilder().Add("flag", 1, LdValue.Of(100), 0).Build(); + var config = BasicConfig() + .DataSource(MockPollingProcessor.Factory(initialFlags)) + .Persistence(Components.Persistence().Storage(storage.AsSingletonFactory())) + .Build(); + + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + var storedData = storage.InspectUserData(config.MobileKey, BasicUser.Key); + Assert.NotNull(storedData); + AssertHelpers.DataSetsEqual(initialFlags, storedData.Value); + } + } + + [Fact] + public void EventProcessorIsOnlineByDefault() + { + var eventProcessor = new MockEventProcessor(); + var config = BasicConfig() + .Events(eventProcessor.AsSingletonFactory()) + .Build(); + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + Assert.False(eventProcessor.Offline); + } + } + + [Fact] + public void EventProcessorIsOfflineWhenClientIsConfiguredOffline() + { + var connectivityStateManager = new MockConnectivityStateManager(true); + var eventProcessor = new MockEventProcessor(); + var config = BasicConfig() + .ConnectivityStateManager(connectivityStateManager) + .Events(eventProcessor.AsSingletonFactory()) + .Offline(true) + .Build(); + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + Assert.True(eventProcessor.Offline); + + client.SetOffline(false, TimeSpan.FromSeconds(1)); + Assert.False(eventProcessor.Offline); + + client.SetOffline(true, TimeSpan.FromSeconds(1)); + Assert.True(eventProcessor.Offline); + + // If the network is unavailable... + connectivityStateManager.Connect(false); + + // ...then even if Offline is set to false, events stay off + client.SetOffline(false, TimeSpan.FromSeconds(1)); + Assert.True(eventProcessor.Offline); + } + } + + [Fact] + public void EventProcessorIsOfflineWhenNetworkIsUnavailable() + { + var connectivityStateManager = new MockConnectivityStateManager(false); + var eventProcessor = new MockEventProcessor(); + var config = BasicConfig() + .ConnectivityStateManager(connectivityStateManager) + .Events(eventProcessor.AsSingletonFactory()) + .Build(); + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + Assert.True(eventProcessor.Offline); + + connectivityStateManager.Connect(true); + Assert.False(eventProcessor.Offline); + + connectivityStateManager.Connect(false); + Assert.True(eventProcessor.Offline); + + // If client is configured offline... + client.SetOffline(true, TimeSpan.FromSeconds(1)); + + // ...then even if the network comes back on, events stay off + connectivityStateManager.Connect(true); + Assert.True(eventProcessor.Offline); + } + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/MockComponents.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/MockComponents.cs new file mode 100644 index 00000000..38268ed8 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/MockComponents.cs @@ -0,0 +1,422 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Client.Interfaces; +using LaunchDarkly.Sdk.Client.Internal; +using LaunchDarkly.Sdk.Client.Internal.DataSources; +using LaunchDarkly.Sdk.Client.Internal.DataStores; +using LaunchDarkly.Sdk.Client.Internal.Interfaces; +using LaunchDarkly.Sdk.Client.PlatformSpecific; +using LaunchDarkly.Sdk.Client.Subsystems; +using LaunchDarkly.Sdk.Internal.Events; +using LaunchDarkly.TestHelpers; +using Xunit; + +using static LaunchDarkly.Sdk.Client.Subsystems.DataStoreTypes; + +namespace LaunchDarkly.Sdk.Client +{ + // Even more so than in the server-side SDK tests, we rely on our own concrete mock + // implementations of our component interfaces rather than using Moq to create mocks + // dynamically, because runtime differences cause Moq to fail on some mobile platforms. + + internal static class MockComponentExtensions + { + // Normally SDK configuration always specifies component factories rather than component instances, + // so that the SDK can handle the component lifecycle and dependency injection. However, in tests, + // we often want to set up a specific component instance; .AsSingletonFactory() wraps it in a + // factory that always returns that instance. + public static IComponentConfigurer AsSingletonFactory(this T instance) => + MockComponents.ComponentConfigurerFromLambda((clientContext) => instance); + + public static IComponentConfigurer AsSingletonFactoryWithDiagnosticDescription(this T instance, LdValue description) => + new SingleComponentFactoryWithDiagnosticDescription { Instance = instance, Description = description }; + + private class SingleComponentFactoryWithDiagnosticDescription : IComponentConfigurer, IDiagnosticDescription + { + public T Instance { get; set; } + public LdValue Description { get; set; } + public LdValue DescribeConfiguration(LdClientContext context) => Description; + public T Build(LdClientContext context) => Instance; + } + } + + internal static class MockComponents + { + public static IComponentConfigurer ComponentConfigurerFromLambda(Func factory) => + new ComponentConfigurerFromLambdaImpl() { Factory = factory }; + + private class ComponentConfigurerFromLambdaImpl : IComponentConfigurer + { + public Func Factory { get; set; } + public T Build(LdClientContext context) => Factory(context); + } + } + + internal class CapturingComponentConfigurer: IComponentConfigurer + { + private readonly IComponentConfigurer _factory; + public LdClientContext ReceivedClientContext { get; private set; } + + public CapturingComponentConfigurer(IComponentConfigurer factory) + { + _factory = factory; + } + + public T Build(LdClientContext clientContext) + { + ReceivedClientContext = clientContext; + return _factory.Build(clientContext); + } + } + + internal class MockBackgroundModeManager : IBackgroundModeManager + { + public event EventHandler BackgroundModeChanged; + + public void UpdateBackgroundMode(bool isInBackground) + { + BackgroundModeChanged?.Invoke(this, new BackgroundModeChangedEventArgs(isInBackground)); + } + } + + internal class MockConnectivityStateManager : IConnectivityStateManager + { + public Action ConnectionChanged { get; set; } + + public MockConnectivityStateManager(bool isOnline) + { + isConnected = isOnline; + } + + bool isConnected; + public bool IsConnected + { + get + { + return isConnected; + } + + set + { + isConnected = value; + } + } + + public void Connect(bool online) + { + IsConnected = online; + ConnectionChanged?.Invoke(IsConnected); + } + } + + internal class MockDataSourceUpdateSink : IDataSourceUpdateSink + { + internal class ReceivedInit + { + public FullDataSet Data { get; set; } + public Context Context{ get; set; } + } + + internal class ReceivedUpsert + { + public string Key { get; set; } + public ItemDescriptor Data { get; set; } + public Context Context { get; set; } + } + + internal class ReceivedStatusUpdate + { + public DataSourceState State { get; set; } + public DataSourceStatus.ErrorInfo? Error { get; set; } + } + + public readonly EventSink Actions = new EventSink(); + + public void Init(Context context, FullDataSet data) => + Actions.Enqueue(new ReceivedInit { Data = data, Context = context}); + + public void Upsert(Context context, string key, ItemDescriptor data) => + Actions.Enqueue(new ReceivedUpsert { Key = key, Data = data, Context = context }); + + public void UpdateStatus(DataSourceState newState, DataSourceStatus.ErrorInfo? newError) => + Actions.Enqueue(new ReceivedStatusUpdate { State = newState, Error = newError }); + + public FullDataSet ExpectInit(Context context) + { + var action = Assert.IsType(Actions.ExpectValue(TimeSpan.FromSeconds(5))); + AssertHelpers.ContextsEqual(context, action.Context); + return action.Data; + } + + public ItemDescriptor ExpectUpsert(Context context, string key) + { + var action = Assert.IsType(Actions.ExpectValue(TimeSpan.FromSeconds(5))); + AssertHelpers.ContextsEqual(context, action.Context); + Assert.Equal(key, action.Key); + return action.Data; + } + + public DataSourceStatus ExpectStatusUpdate() + { + var action = Assert.IsType(Actions.ExpectValue(TimeSpan.FromSeconds(5))); + return new DataSourceStatus { State = action.State, LastError = action.Error }; + } + + public void ExpectNoMoreActions() => Actions.ExpectNoValue(); + } + + internal class MockDiagnosticStore : IDiagnosticStore + { + internal struct StreamInit + { + internal DateTime Timestamp; + internal TimeSpan Duration; + internal bool Failed; + } + + internal readonly EventSink StreamInits = new EventSink(); + + public DateTime DataSince => DateTime.Now; + + public DiagnosticEvent? InitEvent => null; + + public DiagnosticEvent? PersistedUnsentEvent => null; + + public void AddStreamInit(DateTime timestamp, TimeSpan duration, bool failed) => + StreamInits.Enqueue(new StreamInit { Timestamp = timestamp, Duration = duration, Failed = failed }); + + public DiagnosticEvent CreateEventAndReset() => new DiagnosticEvent(); + + public void IncrementDeduplicatedUsers() { } + + public void IncrementDroppedEvents() { } + + public void RecordEventsInBatch(long eventsInBatch) { } + } + + internal class MockEventProcessor : IEventProcessor + { + public List Events = new List(); + public bool Offline = false; + + public void SetOffline(bool offline) + { + Offline = offline; + } + + public void Flush() { } + + public bool FlushAndWait(TimeSpan timeout) => true; + + public Task FlushAndWaitAsync(TimeSpan timeout) => Task.FromResult(true); + + public void Dispose() { } + + public void RecordEvaluationEvent(in EventProcessorTypes.EvaluationEvent e) => + Events.Add(e); + + public void RecordIdentifyEvent(in EventProcessorTypes.IdentifyEvent e) => + Events.Add(e); + + public void RecordCustomEvent(in EventProcessorTypes.CustomEvent e) => + Events.Add(e); + } + + public class MockEventSender : IEventSender + { + public BlockingCollection Calls = new BlockingCollection(); + public EventDataKind? FilterKind = null; + + public void Dispose() { } + + public struct Params + { + public EventDataKind Kind; + public string Data; + public int EventCount; + } + + public Task SendEventDataAsync(EventDataKind kind, byte[] data, int eventCount) + { + if (!FilterKind.HasValue || kind == FilterKind.Value) + { + Calls.Add(new Params { Kind = kind, Data = Encoding.UTF8.GetString(data), EventCount = eventCount }); + } + return Task.FromResult(new EventSenderResult(DeliveryStatus.Succeeded, null)); + } + + public Params RequirePayload() + { + Params result; + if (!Calls.TryTake(out result, TimeSpan.FromSeconds(5))) + { + throw new System.Exception("did not receive an event payload"); + } + return result; + } + + public void RequireNoPayloadSent(TimeSpan timeout) + { + Params result; + if (Calls.TryTake(out result, timeout)) + { + throw new System.Exception("received an unexpected event payload"); + } + } + } + + internal class MockFeatureFlagRequestor : IFeatureFlagRequestor + { + private readonly string _jsonFlags; + + public MockFeatureFlagRequestor(string jsonFlags) + { + _jsonFlags = jsonFlags; + } + + public void Dispose() + { + + } + + public Task FeatureFlagsAsync() + { + var response = new WebResponse(200, _jsonFlags, null); + return Task.FromResult(response); + } + } + + internal class MockPersistentDataStore : IPersistentDataStore + { + private Dictionary<(string, string), string> _map = new Dictionary<(string, string), string>(); + + public void Dispose() { } + + public string GetValue(string storageNamespace, string key) => + _map.TryGetValue((storageNamespace, key), out var value) ? value : null; + + public void SetValue(string storageNamespace, string key, string value) + { + if (value is null) + { + _map.Remove((storageNamespace, key)); + } + else + { + _map[(storageNamespace, key)] = value; + } + } + + public ImmutableList GetKeys(string storageNamespace) => + _map.Where(kv => kv.Key.Item1 == storageNamespace).Select(kv => kv.Value).ToImmutableList(); + + private PersistentDataStoreWrapper WithWrapper(string mobileKey) => + new PersistentDataStoreWrapper(this, mobileKey, Logs.None.Logger("")); + + internal void SetupUserData(string mobileKey, string contextKey, FullDataSet data) => + WithWrapper(mobileKey).SetContextData(Base64.UrlSafeSha256Hash(contextKey), data); + + internal FullDataSet? InspectUserData(string mobileKey, string contextKey) => + WithWrapper(mobileKey).GetContextData(Base64.UrlSafeSha256Hash(contextKey)); + + internal ContextIndex InspectContextIndex(string mobileKey) => + WithWrapper(mobileKey).GetIndex(); + } + + internal class MockPollingProcessor : IDataSource + { + private IDataSourceUpdateSink _updateSink; + private Context _context; + private FullDataSet? _data; + + public MockPollingProcessor(FullDataSet? data) : this(null, new Context(), data) { } + + private MockPollingProcessor(IDataSourceUpdateSink updateSink, Context context, FullDataSet? data) + { + _updateSink = updateSink; + _context = context; + _data = data; + } + + public static IComponentConfigurer Factory(FullDataSet? data) => + MockComponents.ComponentConfigurerFromLambda(clientContext => + new MockPollingProcessor(clientContext.DataSourceUpdateSink, clientContext.CurrentContext, data)); + + public bool IsRunning + { + get; + set; + } + + public void Dispose() + { + IsRunning = false; + } + + public bool Initialized => IsRunning; + + public Task Start() + { + IsRunning = true; + if (_updateSink != null && _data != null) + { + _updateSink.Init(_context, _data.Value); + } + return Task.FromResult(true); + } + } + + internal class MockDataSourceFromLambda : IDataSource + { + private readonly Context _context; + private readonly Func _startFn; + private bool _initialized; + + public MockDataSourceFromLambda(Context context, Func startFn) + { + _context = context; + _startFn = startFn; + } + + public Task Start() + { + return _startFn().ContinueWith(t => + { + _initialized = true; + return true; + }); + } + + public bool Initialized => _initialized; + + public void Dispose() { } + } + + internal class MockDataSource : IDataSource + { + public bool IsRunning => true; + + public void Dispose() { } + + public bool Initialized => true; + + public Task Start() => Task.FromResult(true); + } + + internal class MockDataSourceThatNeverInitializes : IDataSource + { + public bool IsRunning => false; + + public void Dispose() { } + + public bool Initialized => false; + + public Task Start() => new TaskCompletionSource().Task; // will never be completed + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/MockResponses.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/MockResponses.cs new file mode 100644 index 00000000..f6d30b48 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/MockResponses.cs @@ -0,0 +1,63 @@ +using LaunchDarkly.TestHelpers.HttpTest; + +using static LaunchDarkly.Sdk.Client.Subsystems.DataStoreTypes; + +namespace LaunchDarkly.Sdk.Client +{ + public static class MockResponses + { + public static Handler Error401Response => Handlers.Status(401); + + public static Handler Error503Response => Handlers.Status(503); + + public static Handler EventsAcceptedResponse => Handlers.Status(202); + + public static Handler PollingResponse(FullDataSet? data = null) => + Handlers.BodyJson((data ?? DataSetBuilder.Empty).ToJsonString()); + + public static Handler StreamWithEmptyData => StreamWithInitialData(null); + + public static Handler StreamWithInitialData(FullDataSet? data = null) => + Handlers.SSE.Start() + .Then(PutEvent(data)) + .Then(Handlers.SSE.LeaveOpen()); + + public static Handler StreamWithEmptyInitialDataAndThen(params Handler[] handlers) + { + var ret = Handlers.SSE.Start().Then(PutEvent()); + foreach (var h in handlers) + { + if (h != null) + { + ret = ret.Then(h); + } + } + return ret.Then(Handlers.SSE.LeaveOpen()); + } + + public static Handler StreamThatStaysOpenWithNoEvents => + Handlers.SSE.Start().Then(Handlers.SSE.LeaveOpen()); + + public static Handler AllowOnlyStreamRequests(Handler streamHandler) + { + var ret = Handlers.Router(out var router); + router.AddRegex("^/meval/.*", streamHandler); + router.AddRegex(".*", Handlers.Status(500)); + return ret; + } + + public static Handler PutEvent(FullDataSet? data = null) => + Handlers.SSE.Event( + "put", + (data ?? DataSetBuilder.Empty).ToJsonString() + ); + + public static Handler PatchEvent(string data) => + Handlers.SSE.Event("patch", data); + + public static Handler DeleteEvent(string key, int version) => + Handlers.SSE.Event("delete", @"{""key"":""" + key + @""",""version"":" + version + "}"); + + public static Handler PingEvent => Handlers.SSE.Event("ping", ""); + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/ModelBuilders.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/ModelBuilders.cs new file mode 100644 index 00000000..a32ffb92 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/ModelBuilders.cs @@ -0,0 +1,128 @@ +using System.Collections.Generic; +using LaunchDarkly.Sdk.Client.Internal; + +using static LaunchDarkly.Sdk.Client.DataModel; +using static LaunchDarkly.Sdk.Client.Subsystems.DataStoreTypes; + +namespace LaunchDarkly.Sdk.Client +{ + internal class FeatureFlagBuilder + { + private LdValue _value = LdValue.Null; + private int _version; + private int? _variation; + private int? _flagVersion; + private bool _trackEvents; + private bool _trackReason; + private UnixMillisecondTime? _debugEventsUntilDate; + private EvaluationReason? _reason; + + public FeatureFlagBuilder() + { + } + + public FeatureFlagBuilder(FeatureFlag from) + { + _value = from.Value; + _version = from.Version; + _variation = from.Variation; + _trackEvents = from.TrackEvents; + _trackReason = from.TrackReason; + _debugEventsUntilDate = from.DebugEventsUntilDate; + _reason = from.Reason; + } + + public FeatureFlag Build() + { + return new FeatureFlag(_value, _variation, _reason, _version, _flagVersion, _trackEvents, _trackReason, _debugEventsUntilDate); + } + + public FeatureFlagBuilder Value(LdValue value) + { + _value = value; + return this; + } + + public FeatureFlagBuilder Value(bool value) => Value(LdValue.Of(value)); + + public FeatureFlagBuilder Value(string value) => Value(LdValue.Of(value)); + + public FeatureFlagBuilder FlagVersion(int? flagVersion) + { + _flagVersion = flagVersion; + return this; + } + + public FeatureFlagBuilder Version(int version) + { + _version = version; + return this; + } + + public FeatureFlagBuilder Variation(int? variation) + { + _variation = variation; + return this; + } + + public FeatureFlagBuilder Reason(EvaluationReason? reason) + { + _reason = reason; + return this; + } + + public FeatureFlagBuilder TrackEvents(bool trackEvents) + { + _trackEvents = trackEvents; + return this; + } + + public FeatureFlagBuilder TrackReason(bool trackReason) + { + _trackReason = trackReason; + return this; + } + + public FeatureFlagBuilder DebugEventsUntilDate(UnixMillisecondTime? debugEventsUntilDate) + { + _debugEventsUntilDate = debugEventsUntilDate; + return this; + } + } + + internal class DataSetBuilder + { + private List> _items = new List>(); + + public static FullDataSet Empty => new DataSetBuilder().Build(); + + public DataSetBuilder Add(string key, FeatureFlag flag) + { + _items.Add(new KeyValuePair(key, flag.ToItemDescriptor())); + return this; + } + + public DataSetBuilder Add(string key, int version, LdValue value, int variation) => + Add(key, new FeatureFlagBuilder().Version(version).Value(value).Variation(variation).Build()); + + public DataSetBuilder AddDeleted(string key, int version) + { + _items.Add(new KeyValuePair(key, new ItemDescriptor(version, null))); + return this; + } + + public FullDataSet Build() => new FullDataSet(_items); + } + + public static class DataSetExtensions + { + public static string ToJsonString(this FullDataSet data) => + DataModelSerialization.SerializeAll(data); + + public static string ToJsonString(this FeatureFlag flag) => + DataModelSerialization.SerializeFlag(flag); + + public static string ToJsonString(this FeatureFlag flag, string key) => + LdValue.BuildObject().Copy(LdValue.Parse(flag.ToJsonString())).Set("key", key).Build().ToJsonString(); + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/TestHttpUtils.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/TestHttpUtils.cs new file mode 100644 index 00000000..60f40d5a --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/TestHttpUtils.cs @@ -0,0 +1,195 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Client.Integrations; +using LaunchDarkly.Sdk.Client.Interfaces; +using LaunchDarkly.Sdk.Internal.Http; +using LaunchDarkly.TestHelpers.HttpTest; +using Xunit; + +namespace LaunchDarkly.Sdk.Client +{ + internal static class TestHttpUtils + { + public static readonly Uri FakeUri = new Uri("http://not-real"); + + // Used for TestWithSpecialHttpConfigurations + internal class MessageHandlerThatAddsPathSuffix : HttpClientHandler + { + private readonly string _suffix; + + internal MessageHandlerThatAddsPathSuffix(string suffix) { _suffix = suffix; } + + protected override Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + request.RequestUri = new Uri(request.RequestUri.ToString() + _suffix); + return base.SendAsync(request, cancellationToken); + } + } + + // Used for TestWithSpecialHttpConfigurations + public delegate void HttpConfigurationTestAction(Uri targetUri, HttpConfigurationBuilder httpConfig, HttpServer server); + + /// + /// A test suite for all SDK components that support our standard HTTP configuration options. + /// + /// + /// + /// Although all of our supported HTTP behaviors are implemented in shared code, there is no + /// guarantee that all of our components are using that code, or using it correctly. So we + /// should run this test suite on each component that can be affected by HttpConfigurationBuilder + /// properties. + /// + /// + /// For each HTTP configuration variant that is expected work (e.g., using a proxy server), it + /// sets up a server that will produce whatever expected response was specified in + /// . Then it runs , + /// which should create its component with the given configuration and base URI and verify that + /// the component behaves correctly. + /// + /// + /// specifies how the target server should response + /// verifies the result of a test + /// the current TestLogger + public static void TestWithSpecialHttpConfigurations( + Handler responseHandler, + HttpConfigurationTestAction testActionShouldSucceed, + Logger log + ) + { + log.Info("*** TestHttpClientCanUseCustomMessageHandler"); + TestHttpClientCanUseCustomMessageHandler(responseHandler, testActionShouldSucceed); + + log.Info("*** TestHttpClientCanUseProxy"); + TestHttpClientCanUseProxy(responseHandler, testActionShouldSucceed); + } + + static void TestHttpClientCanUseCustomMessageHandler(Handler responseHandler, + HttpConfigurationTestAction testActionShouldSucceed) + { + // To verify that a custom HttpMessageHandler will really be used if provided, we + // create one that behaves normally except that it modifies the request path. + // Then we verify that the server received a request with a modified path. + + var recordAndDelegate = Handlers.Record(out var recorder).Then(responseHandler); + using (var server = HttpServer.Start(recordAndDelegate)) + { + var suffix = "/modified-by-test"; + var messageHandler = new MessageHandlerThatAddsPathSuffix(suffix); + var httpConfig = Components.HttpConfiguration().MessageHandler(messageHandler); + + testActionShouldSucceed(server.Uri, httpConfig, server); + + var request = recorder.RequireRequest(); + Assert.EndsWith(suffix, request.Path); + recorder.RequireNoRequests(TimeSpan.FromMilliseconds(100)); + } + } + + static void TestHttpClientCanUseProxy(Handler responseHandler, + HttpConfigurationTestAction testActionShouldSucceed) + { + // To verify that a web proxy will really be used if provided, we set up a proxy + // configuration pointing to our test server. It's not really a proxy server, + // but if it receives a request that was intended for some other URI (instead of + // the SDK trying to access that other URI directly), then that's a success. + + using (var server = HttpServer.Start(responseHandler)) + { + var proxy = new WebProxy(server.Uri); + var httpConfig = Components.HttpConfiguration().Proxy(proxy); + var fakeBaseUri = new Uri("http://not-a-real-host"); + + testActionShouldSucceed(fakeBaseUri, httpConfig, server); + } + } + + public struct ServerErrorCondition + { + public const int FakeIOException = -1; // constant to be used in the constructor + + public int StatusCode { get; set; } + public Exception IOException { get; set; } + + public static ServerErrorCondition FromStatus(int status) + { + return new ServerErrorCondition + { + StatusCode = status, + IOException = status == FakeIOException ? new IOException("deliberate error") : null + }; + } + + public bool Recoverable => IOException != null || HttpErrors.IsRecoverable(StatusCode); + + public Handler Handler => + IOException is null ? Handlers.Status(StatusCode) : Handlers.Error(IOException); + + public void VerifyDataSourceStatusError(DataSourceStatus status) + { + Assert.Equal(Recoverable ? DataSourceState.Interrupted : DataSourceState.Shutdown, status.State); + Assert.NotNull(status.LastError); + Assert.Equal( + IOException is null + ? DataSourceStatus.ErrorKind.ErrorResponse + : DataSourceStatus.ErrorKind.NetworkError, + status.LastError.Value.Kind); + Assert.Equal( + IOException is null ? StatusCode : 0, + status.LastError.Value.StatusCode + ); + if (IOException != null) + { + Assert.Contains(IOException.Message, status.LastError.Value.Message); + } + } + + public void VerifyLogMessage(LogCapture logCapture) + { + var level = Recoverable ? LogLevel.Warn : LogLevel.Error; + var message = (IOException is null) + ? "HTTP error " + StatusCode + ".*" + (Recoverable ? "will retry" : "giving up") + : IOException.Message; + AssertHelpers.LogMessageRegex(logCapture, true, level, message); + } + } + + /// + /// Sets up the HttpTest framework to simulate a server error of some kind. If + /// it is an HTTP error response, we'll use an embedded HttpServer. If it is an + /// I/O error, we have to use a custom message handler instead. + /// + /// + /// if not null, the second request will + /// receive this response instead of the error + /// + public static void WithServerErrorCondition(ServerErrorCondition errorCondition, + Handler successResponseAfterError, + Action action) + { + var responseHandler = successResponseAfterError is null ? errorCondition.Handler : + Handlers.Sequential(errorCondition.Handler, successResponseAfterError); + if (errorCondition.IOException is null) + { + using (var server = HttpServer.Start(responseHandler)) + { + action(server.Uri, Components.HttpConfiguration(), server.Recorder); + } + } + else + { + var handler = Handlers.Record(out var recorder).Then(responseHandler); + action( + FakeUri, + Components.HttpConfiguration().MessageHandler(handler.AsMessageHandler()), + recorder + ); + } + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/TestLogging.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/TestLogging.cs new file mode 100644 index 00000000..c89e021c --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/TestLogging.cs @@ -0,0 +1,40 @@ +using System; +using LaunchDarkly.Logging; +using Xunit.Abstractions; + +namespace LaunchDarkly.Sdk.Client +{ + /// + /// Allows logging from SDK components to appear in test output. + /// + /// + /// Xunit disables all console output from unit tests, because multiple tests can run in parallel so it + /// would be impossible to see which test produced the output. Instead, it provides an ITestOutputHelper + /// which is passed into your test class automatically if you declare a constructor parameter for it; any + /// output written to this object is buffered on a per-test-method basis, and dumped into the output all at + /// once if the test method fails. We were unable to take advantage of this when we were using Common.Logging, + /// because Common.Logging is configured globally with static methods; but now that we're using our own API, + /// we can direct output from a component to a specific logger instance, which can be redirected to Xunit. + /// See for the simplest way to use this in tests. + /// + public class TestLogging + { + /// + /// Creates an that sends logging to the Xunit output buffer. Use this in + /// contexts where an ILogAdapter is expected instead of an individual logger instance (such as + /// in the SDK client configuration). + /// + /// the that Xunit passed to the test + /// class constructor + /// a log adapter + public static ILogAdapter TestOutputAdapter(ITestOutputHelper testOutputHelper) => + Logs.ToMethod(line => + { + try + { + testOutputHelper.WriteLine("LOG OUTPUT >> " + line); + } + catch (Exception) { } // WriteLine may fail if a background task tries to log something after the test has ended + }); + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/TestUtil.cs b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/TestUtil.cs new file mode 100644 index 00000000..c4cb2c68 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/TestUtil.cs @@ -0,0 +1,165 @@ +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using LaunchDarkly.Sdk.Client.Subsystems; +using LaunchDarkly.Sdk.Internal; +using LaunchDarkly.Sdk.Json; +using Xunit; +using static LaunchDarkly.Sdk.Client.DataModel; +using static LaunchDarkly.Sdk.Client.Subsystems.DataStoreTypes; + +namespace LaunchDarkly.Sdk.Client +{ + public static class TestUtil + { + // Any tests that are going to access the static LdClient.Instance must hold this lock, + // to avoid interfering with tests that use CreateClient. + private static readonly SemaphoreSlim ClientInstanceLock = new SemaphoreSlim(1); + + private static ThreadLocal InClientLock = new ThreadLocal(); + + public static LdClientContext SimpleContext => new LdClientContext( + Configuration.Default("key", ConfigurationBuilder.AutoEnvAttributes.Enabled), Context.New("userkey")); + + public static Context Base64ContextFromUrlPath(string path, string pathPrefix) + { + Assert.StartsWith(pathPrefix, path); + var base64String = path.Substring(pathPrefix.Length); + var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(base64String)); + return LdJsonSerialization.DeserializeObject(decoded); + } + + public static ContextBuilder BuildAutoContext() => + Context.Builder("placeholder").Anonymous(true); + + public static T WithClientLock(Func f) + { + // This cumbersome combination of a ThreadLocal and a SemaphoreSlim is simply because 1. we have to use + // SemaphoreSlim (I think) since there's no way to wait on a regular lock in *async* code, and 2. SemaphoreSlim + // is not reentrant, so we need to make sure a thread can't block itself. + if (InClientLock.Value) + { + return f.Invoke(); + } + + ClientInstanceLock.Wait(); + try + { + InClientLock.Value = true; + return f.Invoke(); + } + finally + { + InClientLock.Value = false; + ClientInstanceLock.Release(); + } + } + + public static void WithClientLock(Action a) + { + if (InClientLock.Value) + { + a.Invoke(); + return; + } + + ClientInstanceLock.Wait(); + try + { + InClientLock.Value = true; + a.Invoke(); + } + finally + { + InClientLock.Value = false; + ClientInstanceLock.Release(); + } + } + + public static async Task WithClientLockAsync(Func> f) + { + if (InClientLock.Value) + { + return await f.Invoke(); + } + + await ClientInstanceLock.WaitAsync(); + try + { + InClientLock.Value = true; + return await f.Invoke(); + } + finally + { + InClientLock.Value = false; + ClientInstanceLock.Release(); + } + } + + // Calls LdClient.Init, but then sets LdClient.Instance to null so other tests can + // instantiate their own independent clients. Application code cannot do this because + // the LdClient.Instance setter has internal scope. + public static LdClient CreateClient(Configuration config, Context context, TimeSpan? timeout = null) + { + return WithClientLock(() => + { + ClearClient(); + LdClient client = LdClient.Init(config, context, timeout ?? TimeSpan.FromSeconds(1)); + client.DetachInstance(); + return client; + }); + } + + // Calls LdClient.Init, but then sets LdClient.Instance to null so other tests can + // instantiate their own independent clients. Application code cannot do this because + // the LdClient.Instance setter has internal scope. + public static async Task CreateClientAsync(Configuration config, Context context) + { + return await WithClientLockAsync(async () => + { + ClearClient(); + LdClient client = await LdClient.InitAsync(config, context); + client.DetachInstance(); + return client; + }); + } + + // Calls LdClient.Init, but then sets LdClient.Instance to null so other tests can + // instantiate their own independent clients. Application code cannot do this because + // the LdClient.Instance setter has internal scope. + public static async Task CreateClientAsync(Configuration config, Context context, TimeSpan waitTime) + { + return await WithClientLockAsync(async () => + { + ClearClient(); + LdClient client = await LdClient.InitAsync(config, context, waitTime); + client.DetachInstance(); + return client; + }); + } + + public static void ClearClient() + { + WithClientLock(() => { LdClient.Instance?.Dispose(); }); + } + + internal static string MakeJsonData(FullDataSet data) + { + return JsonUtils.WriteJsonAsString(w => + { + w.WriteStartObject(); + foreach (var item in data.Items) + { + if (item.Value.Item != null) + { + w.WritePropertyName(item.Key); + FeatureFlagJsonConverter.WriteJsonValue(item.Value.Item, w); + } + } + + w.WriteEndObject(); + }); + } + } +} diff --git a/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/xunit-to-junit.xslt b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/xunit-to-junit.xslt new file mode 100755 index 00000000..d5c2b553 --- /dev/null +++ b/pkgs/sdk/client/tests/LaunchDarkly.ClientSdk.Tests/xunit-to-junit.xslt @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + T + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <![CDATA[ + + ]]> + + + + diff --git a/pkgs/sdk/client/toc.yml b/pkgs/sdk/client/toc.yml new file mode 100644 index 00000000..1a5e7a22 --- /dev/null +++ b/pkgs/sdk/client/toc.yml @@ -0,0 +1,2 @@ +- name: API + href: api/