diff --git a/libs/client-sdk/src/client_impl.cpp b/libs/client-sdk/src/client_impl.cpp index eca423d66..81312f3cf 100644 --- a/libs/client-sdk/src/client_impl.cpp +++ b/libs/client-sdk/src/client_impl.cpp @@ -1,4 +1,6 @@ #include "client_impl.hpp" +#include "data_sources/fdv2/fdv2_data_source.hpp" +#include "data_sources/fdv2/mode_sources.hpp" #include "data_sources/null_data_source.hpp" #include "data_sources/polling_data_source.hpp" #include "data_sources/streaming_data_source.hpp" @@ -31,12 +33,58 @@ using launchdarkly::client_side::data_sources::DataSourceStatus; using launchdarkly::config::shared::built::DataSourceConfig; using launchdarkly::config::shared::built::HttpProperties; +using FDv2Config = config::shared::built::FDv2Config; + +static bool UsesFDv2(Config const& config) { + return std::holds_alternative(config.DataSourceConfig().method); +} + +static std::shared_ptr MakeFDv2DataSource( + HttpProperties const& http_properties, + Config const& config, + Context const& context, + boost::asio::any_io_executor const& executor, + flag_manager::FlagManager& flag_manager, + data_sources::DataSourceStatusManager& status_manager, + Logger& logger) { + auto const& fdv2 = std::get(config.DataSourceConfig().method); + auto const& endpoints = config.ServiceEndpoints(); + + if (config.DataSourceConfig().use_report) { + LD_LOG(logger, LogLevel::kWarn) + << "UseReport is not applicable to FDv2 and will be ignored"; + } + + data_sources::ModeSourceParams const params{ + executor, + logger, + fdv2.polling_base_url, + fdv2.streaming_base_url, + http_properties, + endpoints, + context, + config.DataSourceConfig().with_reasons, + &flag_manager.Cache()}; + + auto sources = + data_sources::BuildModeSources(fdv2, fdv2.initial_mode, params); + + return std::make_shared( + std::move(sources.initializers), std::move(sources.synchronizers), + std::make_unique( + executor, fdv2.fallback_timeout), + std::make_unique( + executor, fdv2.recovery_timeout), + executor, context, &flag_manager.Updater(), &flag_manager.Store(), + &status_manager, logger); +} + static std::shared_ptr MakeDataSource( HttpProperties const& http_properties, Config const& config, Context const& context, boost::asio::any_io_executor const& executor, - IDataSourceUpdateSink& flag_updater, + flag_manager::FlagManager& flag_manager, data_sources::DataSourceStatusManager& status_manager, Logger& logger) { if (config.Offline()) { @@ -48,18 +96,26 @@ static std::shared_ptr MakeDataSource( auto data_source_properties = builder.Build(); - if (config.DataSourceConfig().method.index() == 0) { + if (UsesFDv2(config)) { + return MakeFDv2DataSource(data_source_properties, config, context, + executor, flag_manager, status_manager, + logger); + } + + if (std::holds_alternative< + config::shared::built::StreamingConfig>( + config.DataSourceConfig().method)) { return std::make_shared< launchdarkly::client_side::data_sources::StreamingDataSource>( config.ServiceEndpoints(), config.DataSourceConfig(), - data_source_properties, executor, context, flag_updater, + data_source_properties, executor, context, flag_manager.Updater(), status_manager, logger); } return std::make_shared< launchdarkly::client_side::data_sources::PollingDataSource>( config.ServiceEndpoints(), config.DataSourceConfig(), - data_source_properties, executor, context, flag_updater, status_manager, - logger); + data_source_properties, executor, context, flag_manager.Updater(), + status_manager, logger); } static Logger MakeLogger(config::shared::built::Logging const& config) { @@ -101,13 +157,17 @@ ClientImpl::ClientImpl(Config in_cfg, MakePersistence(config_)), data_source_factory_([this]() { return MakeDataSource(http_properties_, config_, context_, - ioc_.get_executor(), flag_manager_.Updater(), + ioc_.get_executor(), flag_manager_, status_manager_, logger_); }), data_source_(nullptr), event_processor_(nullptr), eval_reasons_available_(config_.DataSourceConfig().with_reasons) { - flag_manager_.LoadCache(context_); + // Under FDv2 the cache is loaded by an initializer, so that it enters the + // store through the same path as every other source. + if (!UsesFDv2(config_)) { + flag_manager_.LoadCache(context_); + } if (auto custom_ca = http_properties_.Tls().CustomCAFile()) { LD_LOG(logger_, LogLevel::kInfo) @@ -161,7 +221,9 @@ static bool IsInitializedSuccessfully(DataSourceStatus::DataSourceState state) { std::future ClientImpl::IdentifyAsync(Context context) { UpdateContextSynchronized(context); flag_manager_.ClearSelector(); - flag_manager_.LoadCache(context); + if (!UsesFDv2(config_)) { + flag_manager_.LoadCache(context); + } event_processor_->SendAsync(events::IdentifyEventParams{ std::chrono::system_clock::now(), std::move(context)}); diff --git a/libs/client-sdk/tests/fdv2_client_test.cpp b/libs/client-sdk/tests/fdv2_client_test.cpp new file mode 100644 index 000000000..e14b48336 --- /dev/null +++ b/libs/client-sdk/tests/fdv2_client_test.cpp @@ -0,0 +1,112 @@ +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +using namespace launchdarkly; +using namespace launchdarkly::client_side; +using namespace std::chrono_literals; + +namespace { + +class TestPersistence : public IPersistence { + public: + using StoreType = + std::map>>; + + explicit TestPersistence(StoreType store) : store_(std::move(store)) {} + + void Set(std::string storageNamespace, + std::string key, + std::string data) noexcept override { + std::lock_guard lock{mutex_}; + store_[storageNamespace][key] = data; + } + + void Remove(std::string storageNamespace, + std::string key) noexcept override { + std::lock_guard lock{mutex_}; + store_[storageNamespace].erase(key); + } + + std::optional Read(std::string storageNamespace, + std::string key) noexcept override { + std::lock_guard lock{mutex_}; + auto const ns = store_.find(storageNamespace); + if (ns == store_.end()) { + return std::nullopt; + } + auto const entry = ns->second.find(key); + if (entry == ns->second.end()) { + return std::nullopt; + } + return entry->second; + } + + private: + // The SDK reads from its own thread while the test writes from the main + // one. + std::mutex mutex_; + StoreType store_; +}; + +// Offline mode makes no requests, so the whole FDv2 path can be exercised +// without a service to talk to. +Config OfflineFDv2Config(std::shared_ptr persistence) { + auto builder = ConfigBuilder("the-key"); + builder.DataSource().Method( + DataSourceBuilder::FDv2().InitialMode(ConnectionMode::kOffline)); + builder.Events().Disable(); + if (persistence) { + builder.Persistence().Custom(std::move(persistence)); + } else { + builder.Persistence().None(); + } + return builder.Build().value(); +} + +// The namespace and context id the client derives for SDK key "the-key" and +// context user:user-key. +char const* const kEnvironment = + "LaunchDarkly_rUTcjlHPv6Vegd27YmtGYkEGkEUGaEbn5M0JYTFQUpA="; +char const* const kContextId = "CEXjZY7cHJG_ydFy7q4-YEFwVrG3_pkJwA4FAjrbfx0="; + +} // namespace + +// The cache is the only source offline mode has, so a miss still starts the +// SDK with no flags, evaluating to defaults. +TEST(FDv2ClientTest, OfflineModeStartsWithNoCachedData) { + Client client(OfflineFDv2Config(nullptr), + ContextBuilder().Kind("user", "user-key").Build()); + + auto started = client.StartAsync(); + + ASSERT_EQ(std::future_status::ready, started.wait_for(5s)); + EXPECT_TRUE(started.get()); + EXPECT_TRUE(client.Initialized()); + EXPECT_TRUE(client.AllFlags().empty()); +} + +TEST(FDv2ClientTest, OfflineModeEvaluatesAgainstTheCache) { + auto persistence = + std::make_shared(TestPersistence::StoreType{ + {kEnvironment, + {{kContextId, R"({"treat":{"version":1,"value":"fish"}})"}}}}); + + Client client(OfflineFDv2Config(persistence), + ContextBuilder().Kind("user", "user-key").Build()); + + auto started = client.StartAsync(); + + ASSERT_EQ(std::future_status::ready, started.wait_for(5s)); + EXPECT_TRUE(started.get()); + EXPECT_EQ("fish", client.StringVariation("treat", "chicken")); +} diff --git a/libs/client-sdk/tests/fdv2_config_builder_test.cpp b/libs/client-sdk/tests/fdv2_config_builder_test.cpp new file mode 100644 index 000000000..dcf8e49ad --- /dev/null +++ b/libs/client-sdk/tests/fdv2_config_builder_test.cpp @@ -0,0 +1,211 @@ +#include + +#include + +#include +#include + +using launchdarkly::client_side::ConfigBuilder; +using launchdarkly::client_side::ConnectionMode; +using launchdarkly::client_side::DataSourceBuilder; +using launchdarkly::client_side::Defaults; +using launchdarkly::client_side::FDv2Builder; +using launchdarkly::config::shared::ClientSDK; + +using FDv2Config = launchdarkly::config::shared::built::FDv2Config; +using PollingConfig = + launchdarkly::config::shared::built::PollingConfig; +using StreamingConfig = + launchdarkly::config::shared::built::StreamingConfig; + +using namespace std::chrono_literals; + +namespace { + +FDv2Config BuildFDv2(FDv2Builder fdv2) { + auto config = + ConfigBuilder("sdk-key").DataSource().Method(std::move(fdv2)).Build(); + return std::get(config.method); +} + +} // namespace + +// The streaming and polling methods speak FDv1. FDv2 replaces them. +TEST(FDv2ConfigBuilderTest, TheDefaultDataSourceIsStillFDv1Streaming) { + auto config = ConfigBuilder("sdk-key").Build(); + + ASSERT_TRUE(config.has_value()); + EXPECT_TRUE(std::holds_alternative( + config->DataSourceConfig().method)); +} + +TEST(FDv2ConfigBuilderTest, SelectingFDv2ReplacesTheFDv1Method) { + auto builder = ConfigBuilder("sdk-key"); + builder.DataSource().Method(DataSourceBuilder::FDv2()); + + auto config = builder.Build(); + + ASSERT_TRUE(config.has_value()); + EXPECT_TRUE( + std::holds_alternative(config->DataSourceConfig().method)); +} + +// What those defaults are is pinned down where they are defined. This is +// about the builder starting from them. +TEST(FDv2ConfigBuilderTest, AnUncustomizedBuilderYieldsTheSDKDefaults) { + auto const config = BuildFDv2(FDv2Builder()); + auto const defaults = Defaults::FDv2Config(); + + EXPECT_EQ(defaults.initial_mode, config.initial_mode); + EXPECT_EQ(defaults.modes.size(), config.modes.size()); + EXPECT_EQ(defaults.fallback_timeout, config.fallback_timeout); + EXPECT_EQ(defaults.recovery_timeout, config.recovery_timeout); +} + +TEST(FDv2ConfigBuilderTest, DefaultsSendTheContextInTheRequestPath) { + auto const config = BuildFDv2(FDv2Builder()); + + EXPECT_FALSE(config.use_post); +} + +TEST(FDv2ConfigBuilderTest, InitialModeIsSelectable) { + auto const config = + BuildFDv2(FDv2Builder().InitialMode(ConnectionMode::kPolling)); + + EXPECT_EQ(ConnectionMode::kPolling, config.initial_mode); +} + +TEST(FDv2ConfigBuilderTest, UsePostIsSettable) { + auto const config = BuildFDv2(FDv2Builder().UsePost(true)); + + EXPECT_TRUE(config.use_post); +} + +TEST(FDv2ConfigBuilderTest, CustomizingAModeReplacesItsPipeline) { + FDv2Builder builder; + builder.CustomizeMode( + ConnectionMode::kStreaming, + FDv2Builder::Mode() + .Initializer(FDv2Builder::Cache()) + .Synchronizer(FDv2Builder::Streaming().InitialReconnectDelay(2s)) + .DisableFDv1Fallback()); + + auto const config = BuildFDv2(std::move(builder)); + auto const& mode = config.modes.at(ConnectionMode::kStreaming); + + ASSERT_EQ(1u, mode.initializers.size()); + EXPECT_TRUE( + std::holds_alternative(mode.initializers[0])); + ASSERT_EQ(1u, mode.synchronizers.size()); + auto const* streaming = + std::get_if(&mode.synchronizers[0]); + ASSERT_NE(nullptr, streaming); + EXPECT_EQ(2s, streaming->initial_reconnect_delay); + EXPECT_FALSE(mode.fdv1_fallback.has_value()); +} + +TEST(FDv2ConfigBuilderTest, CustomizingOneModeLeavesTheOthersAlone) { + FDv2Builder builder; + builder.CustomizeMode(ConnectionMode::kPolling, + FDv2Builder::Mode().Synchronizer( + FDv2Builder::Polling().PollInterval(60s))); + + auto const config = BuildFDv2(std::move(builder)); + + EXPECT_EQ(3u, config.modes.size()); + EXPECT_EQ(2u, + config.modes.at(ConnectionMode::kStreaming).initializers.size()); +} + +TEST(FDv2ConfigBuilderTest, SourcesCanOverrideTheirOwnBaseUrl) { + FDv2Builder builder; + builder.CustomizeMode( + ConnectionMode::kStreaming, + FDv2Builder::Mode().Synchronizer( + FDv2Builder::Polling().BaseUrl("https://relay.example.com"))); + + auto const config = BuildFDv2(std::move(builder)); + auto const* polling = std::get_if( + &config.modes.at(ConnectionMode::kStreaming).synchronizers[0]); + + ASSERT_NE(nullptr, polling); + ASSERT_TRUE(polling->base_url_override.has_value()); + EXPECT_EQ("https://relay.example.com", *polling->base_url_override); +} + +TEST(FDv2ConfigBuilderTest, TheFDv1FallbackIsConfigurable) { + FDv2Builder builder; + builder.CustomizeMode( + ConnectionMode::kStreaming, + FDv2Builder::Mode() + .Synchronizer(FDv2Builder::Streaming()) + .FallbackToFDv1( + FDv2Builder::FDv1Fallback().PollInterval(600s).BaseUrl( + "https://relay.example.com"))); + + auto const config = BuildFDv2(std::move(builder)); + auto const& fallback = + config.modes.at(ConnectionMode::kStreaming).fdv1_fallback; + + ASSERT_TRUE(fallback.has_value()); + EXPECT_EQ(600s, fallback->poll_interval); + ASSERT_TRUE(fallback->base_url_override.has_value()); + EXPECT_EQ("https://relay.example.com", *fallback->base_url_override); +} + +// ============================================================================ +// Endpoint defaults +// ============================================================================ + +namespace { + +FDv2Config BuiltFDv2(ConfigBuilder& builder) { + auto config = builder.Build(); + EXPECT_TRUE(config.has_value()); + return std::get(config->DataSourceConfig().method); +} + +} // namespace + +// FDv2 polls a different endpoint than FDv1 does, so an application that +// configured nothing polls there rather than where FDv1 would. +TEST(FDv2ConfigBuilderTest, FDv2KeepsItsOwnDefaultEndpoints) { + auto builder = ConfigBuilder("sdk-key"); + builder.DataSource().Method(DataSourceBuilder::FDv2()); + + auto const fdv2 = BuiltFDv2(builder); + + EXPECT_EQ("https://sdk.launchdarkly.com", fdv2.polling_base_url); + EXPECT_EQ("https://clientstream.launchdarkly.com", fdv2.streaming_base_url); + EXPECT_NE(Defaults::ServiceEndpoints().PollingBaseUrl(), + fdv2.polling_base_url); +} + +// An application that pointed the SDK somewhere else — a Relay Proxy, say — +// has FDv2 follow it there. +TEST(FDv2ConfigBuilderTest, ConfiguredEndpointsRedirectFDv2) { + auto builder = ConfigBuilder("sdk-key"); + builder.DataSource().Method(DataSourceBuilder::FDv2()); + builder.ServiceEndpoints().RelayProxyBaseURL("https://relay.example.com"); + + auto const fdv2 = BuiltFDv2(builder); + + EXPECT_EQ("https://relay.example.com", fdv2.polling_base_url); + EXPECT_EQ("https://relay.example.com", fdv2.streaming_base_url); +} + +// Endpoints set to the SDK's own values are still the application's choice, +// and redirect FDv2 like any other. +TEST(FDv2ConfigBuilderTest, ConfiguringOnlyTheEventsUrlStillRedirectsFDv2) { + auto defaults = Defaults::ServiceEndpoints(); + auto builder = ConfigBuilder("sdk-key"); + builder.DataSource().Method(DataSourceBuilder::FDv2()); + builder.ServiceEndpoints() + .EventsBaseUrl("https://events.example.com") + .PollingBaseUrl(defaults.PollingBaseUrl()) + .StreamingBaseUrl(defaults.StreamingBaseUrl()); + + auto const fdv2 = BuiltFDv2(builder); + + EXPECT_EQ(defaults.PollingBaseUrl(), fdv2.polling_base_url); +} diff --git a/libs/common/include/launchdarkly/config/client.hpp b/libs/common/include/launchdarkly/config/client.hpp index dd288a82a..f18c013bd 100644 --- a/libs/common/include/launchdarkly/config/client.hpp +++ b/libs/common/include/launchdarkly/config/client.hpp @@ -4,7 +4,9 @@ #include #include #include +#include #include +#include #include #include @@ -20,6 +22,8 @@ using EventsBuilder = config::shared::builders::EventsBuilder; using HttpPropertiesBuilder = config::shared::builders::HttpPropertiesBuilder; using DataSourceBuilder = config::shared::builders::DataSourceBuilder; +using FDv2Builder = config::shared::builders::FDv2Builder; +using ConnectionMode = config::shared::ConnectionMode; using LoggingBuilder = config::shared::builders::LoggingBuilder; using PersistenceBuilder = config::shared::builders::PersistenceBuilder; using TlsBuilder = config::shared::builders::TlsBuilder; diff --git a/libs/common/include/launchdarkly/config/shared/builders/data_source_builder.hpp b/libs/common/include/launchdarkly/config/shared/builders/data_source_builder.hpp index a69bac6e1..e2e6ce93f 100644 --- a/libs/common/include/launchdarkly/config/shared/builders/data_source_builder.hpp +++ b/libs/common/include/launchdarkly/config/shared/builders/data_source_builder.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -134,6 +135,7 @@ class DataSourceBuilder { public: using Streaming = StreamingBuilder; using Polling = PollingBuilder; + using FDv2 = FDv2Builder; DataSourceBuilder(); @@ -189,6 +191,18 @@ class DataSourceBuilder { */ DataSourceBuilder& Method(Polling polling_builder); + /** + * Set the FDv2 configuration for the builder. + * + * FDv2 is the current flag delivery protocol. It supersedes the + * streaming and polling methods above, which speak FDv1, and configures + * both transports itself through named connection modes. + * + * @param fdv2_builder The FDv2 builder. + * @return Reference to this builder. + */ + DataSourceBuilder& Method(FDv2 fdv2_builder); + /** * Build a data source config. This is used internal to the SDK. * @@ -197,7 +211,7 @@ class DataSourceBuilder { [[nodiscard]] built::DataSourceConfig Build() const; private: - std::variant method_; + std::variant method_; bool with_reasons_; bool use_report_; }; diff --git a/libs/common/include/launchdarkly/config/shared/builders/endpoints_builder.hpp b/libs/common/include/launchdarkly/config/shared/builders/endpoints_builder.hpp index 166822cbb..d3bc73a3e 100644 --- a/libs/common/include/launchdarkly/config/shared/builders/endpoints_builder.hpp +++ b/libs/common/include/launchdarkly/config/shared/builders/endpoints_builder.hpp @@ -14,6 +14,9 @@ namespace launchdarkly::config::shared::builders { template class EndpointsBuilder; +template +class ConfigBuilder; + template bool operator==(EndpointsBuilder const& lhs, EndpointsBuilder const& rhs); @@ -27,8 +30,8 @@ bool operator==(EndpointsBuilder const& lhs, template class EndpointsBuilder { public: - friend bool operator==(EndpointsBuilder const& lhs, - EndpointsBuilder const& rhs); + friend bool operator== (EndpointsBuilder const& lhs, + EndpointsBuilder const& rhs); /** * Constructs an EndpointsBuilder. */ @@ -72,6 +75,15 @@ class EndpointsBuilder { [[nodiscard]] tl::expected Build() const; private: + friend class ConfigBuilder; + + /** + * Whether the application set any of these URLs, as opposed to accepting + * the SDK's defaults. Read when resolving a data source whose own default + * endpoint differs from the SDK-wide one. + */ + [[nodiscard]] bool IsCustom() const; + std::optional polling_base_url_; std::optional streaming_base_url_; std::optional events_base_url_; diff --git a/libs/common/include/launchdarkly/config/shared/builders/fdv2_builder.hpp b/libs/common/include/launchdarkly/config/shared/builders/fdv2_builder.hpp new file mode 100644 index 000000000..21feb982b --- /dev/null +++ b/libs/common/include/launchdarkly/config/shared/builders/fdv2_builder.hpp @@ -0,0 +1,178 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +namespace launchdarkly::config::shared::builders { + +/** + * Configures the FDv2 data system: how the SDK obtains flag data, and how it + * keeps that data current. + * + * A default-constructed builder is the configuration the SDK uses when the + * application customizes nothing. It starts in streaming mode, and provides + * streaming, polling, and offline modes. + */ +class FDv2Builder { + public: + using Config = built::FDv2Config; + + /** + * Configures the local cache as a source. Reading persisted flag data + * lets evaluation begin before the network answers. It has no options. + */ + class Cache { + public: + [[nodiscard]] Config::CacheConfig Build() const; + }; + + /** + * Configures a streaming source, which receives updates as the service + * publishes them. + */ + class Streaming { + public: + /** + * Sets where the reconnection backoff starts. The delay for the + * first reconnection starts near this value and grows exponentially + * for subsequent failures. + */ + Streaming& InitialReconnectDelay(std::chrono::milliseconds delay); + + /** + * Sends this source's requests to the given URL instead of the + * streaming URL the rest of the SDK uses. Useful for routing one tier + * to different infrastructure, such as a Relay Proxy used only as a + * fallback. + */ + Streaming& BaseUrl(std::string base_url); + + [[nodiscard]] Config::StreamingConfig Build() const; + + private: + std::chrono::milliseconds initial_reconnect_delay_{1000}; + std::optional base_url_override_; + }; + + /** + * Configures a polling source, which asks the service for updates on an + * interval. + */ + class Polling { + public: + /** + * Sets how long to wait between polls. Intervals shorter than the + * minimum the SDK permits are raised to it. + */ + Polling& PollInterval(std::chrono::seconds interval); + + /** + * Sends this source's requests to the given URL instead of the + * polling URL the rest of the SDK uses. + */ + Polling& BaseUrl(std::string base_url); + + [[nodiscard]] Config::PollingConfig Build() const; + + private: + std::chrono::seconds poll_interval_{std::chrono::minutes(5)}; + std::optional base_url_override_; + }; + + /** + * Configures the FDv1 polling source the SDK uses while the service has + * directed it away from FDv2. The SDK returns to FDv2 on its own once the + * service's fallback period has elapsed. + */ + class FDv1Fallback { + public: + /** Sets how long to wait between polls while on FDv1. */ + FDv1Fallback& PollInterval(std::chrono::seconds interval); + + /** + * Sends the fallback's requests to the given URL instead of the + * polling URL the rest of the SDK uses. + */ + FDv1Fallback& BaseUrl(std::string base_url); + + [[nodiscard]] Config::FDv1FallbackConfig Build() const; + + private: + std::chrono::seconds poll_interval_{std::chrono::minutes(5)}; + std::optional base_url_override_; + }; + + /** + * Configures what one connection mode does: which sources load flag data, + * and which keep it current. + * + * A mode built this way replaces the SDK's built-in definition entirely, + * so it should list every source the mode needs, cache included. + */ + class Mode { + public: + /** + * Appends a source to the list that runs, in order, until one loads a + * complete data set. + */ + Mode& Initializer(Cache source); + Mode& Initializer(Polling source); + + /** + * Appends a source to the list that keeps data current. Order is + * preference. The first entry is the primary, and the SDK falls back + * to later entries when it cannot keep the primary running. + */ + Mode& Synchronizer(Streaming source); + Mode& Synchronizer(Polling source); + + /** Sets the FDv1 source to use if the service directs the SDK to it. */ + Mode& FallbackToFDv1(FDv1Fallback source); + + /** + * Leaves the mode with no FDv1 source. A fallback directive then + * stops the mode's synchronizer until the SDK returns to FDv2. + */ + Mode& DisableFDv1Fallback(); + + [[nodiscard]] Config::ModeDefinition Build() const; + + private: + Config::ModeDefinition definition_; + }; + + FDv2Builder(); + + /** + * Sets the mode the SDK starts in. Defaults to streaming. + */ + FDv2Builder& InitialMode(ConnectionMode mode); + + /** + * Replaces what the given mode does. Modes left uncustomized keep their + * built-in definitions. + */ + FDv2Builder& CustomizeMode(ConnectionMode mode, Mode definition); + + /** + * Sends the evaluation context in a request body rather than encoded into + * the request path. This keeps the context out of URL-based request logs + * and CDN logs, at the cost of CDN caching. + */ + FDv2Builder& UsePost(bool use_post); + + /** + * Builds the FDv2 config. Used internal to the SDK. + */ + [[nodiscard]] Config Build() const; + + private: + Config config_; +}; + +} // namespace launchdarkly::config::shared::builders diff --git a/libs/common/include/launchdarkly/config/shared/built/data_source_config.hpp b/libs/common/include/launchdarkly/config/shared/built/data_source_config.hpp index 32b9cc5b2..560e08eea 100644 --- a/libs/common/include/launchdarkly/config/shared/built/data_source_config.hpp +++ b/libs/common/include/launchdarkly/config/shared/built/data_source_config.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -56,9 +57,16 @@ struct DataSourceConfig; template <> struct DataSourceConfig { - std::variant, PollingConfig> method; + std::variant, + PollingConfig, + FDv2Config> + method; bool with_reasons; + /** + * Ignored when the method is FDv2, which supersedes the REPORT + * transport with its own choice of GET or POST. + */ bool use_report; }; diff --git a/libs/common/src/CMakeLists.txt b/libs/common/src/CMakeLists.txt index c39644fd0..f78a7691a 100644 --- a/libs/common/src/CMakeLists.txt +++ b/libs/common/src/CMakeLists.txt @@ -41,6 +41,7 @@ add_library(${LIBNAME} OBJECT config/app_info_builder.cpp config/http_properties.cpp config/data_source_builder.cpp + config/fdv2_builder.cpp config/http_properties_builder.cpp bindings/c/value.cpp bindings/c/array_builder.cpp diff --git a/libs/common/src/config/config_builder.cpp b/libs/common/src/config/config_builder.cpp index 0c306cb1f..10da1ba23 100644 --- a/libs/common/src/config/config_builder.cpp +++ b/libs/common/src/config/config_builder.cpp @@ -1,6 +1,9 @@ #include #include +#include +#include + namespace launchdarkly::config::shared::builders { template @@ -72,6 +75,19 @@ ConfigBuilder::Build() const { auto data_source_config = data_source_builder_.Build(); + if constexpr (std::is_same_v) { + // FDv2 polls a different endpoint than FDv1 does, so it keeps its own + // default until the application configures its own endpoints. Once it + // has, FDv2 follows them, so that pointing the SDK at a Relay Proxy + // redirects FDv2 too. + if (auto* fdv2 = + std::get_if>(&data_source_config.method); + fdv2 != nullptr && service_endpoints_builder_.IsCustom()) { + fdv2->polling_base_url = endpoints_config->PollingBaseUrl(); + fdv2->streaming_base_url = endpoints_config->StreamingBaseUrl(); + } + } + auto http_properties = http_properties_builder_.Build(); auto logging = logging_config_builder_.Build(); diff --git a/libs/common/src/config/data_source_builder.cpp b/libs/common/src/config/data_source_builder.cpp index e19a9417e..4d1e846c6 100644 --- a/libs/common/src/config/data_source_builder.cpp +++ b/libs/common/src/config/data_source_builder.cpp @@ -8,8 +8,9 @@ struct MethodVisitor {}; template <> struct MethodVisitor { using SDK = ClientSDK; - using Result = - std::variant, built::PollingConfig>; + using Result = std::variant, + built::PollingConfig, + built::FDv2Config>; Result operator()(StreamingBuilder const& streaming) const { return streaming.Build(); @@ -18,6 +19,8 @@ struct MethodVisitor { Result operator()(PollingBuilder const& polling) const { return polling.Build(); } + + Result operator()(FDv2Builder const& fdv2) const { return fdv2.Build(); } }; template @@ -79,6 +82,12 @@ DataSourceBuilder& DataSourceBuilder::Method( return *this; } +DataSourceBuilder& DataSourceBuilder::Method( + FDv2Builder builder) { + method_ = std::move(builder); + return *this; +} + built::DataSourceConfig DataSourceBuilder::Build() const { return {std::visit(MethodVisitor(), method_), with_reasons_, use_report_}; diff --git a/libs/common/src/config/endpoints_builder.cpp b/libs/common/src/config/endpoints_builder.cpp index 9701b195e..1b80cdad1 100644 --- a/libs/common/src/config/endpoints_builder.cpp +++ b/libs/common/src/config/endpoints_builder.cpp @@ -42,6 +42,12 @@ bool empty_string(std::optional const& opt_string) { return opt_string.has_value() && opt_string->empty(); } +template +bool EndpointsBuilder::IsCustom() const { + return polling_base_url_.has_value() || streaming_base_url_.has_value() || + events_base_url_.has_value(); +} + template tl::expected EndpointsBuilder::Build() const { diff --git a/libs/common/src/config/fdv2_builder.cpp b/libs/common/src/config/fdv2_builder.cpp new file mode 100644 index 000000000..46cfa3432 --- /dev/null +++ b/libs/common/src/config/fdv2_builder.cpp @@ -0,0 +1,114 @@ +#include +#include + +#include + +namespace launchdarkly::config::shared::builders { + +FDv2Builder::Config::CacheConfig FDv2Builder::Cache::Build() const { + return {}; +} + +FDv2Builder::Streaming& FDv2Builder::Streaming::InitialReconnectDelay( + std::chrono::milliseconds delay) { + initial_reconnect_delay_ = delay; + return *this; +} + +FDv2Builder::Streaming& FDv2Builder::Streaming::BaseUrl(std::string base_url) { + base_url_override_ = std::move(base_url); + return *this; +} + +FDv2Builder::Config::StreamingConfig FDv2Builder::Streaming::Build() const { + return {initial_reconnect_delay_, base_url_override_}; +} + +FDv2Builder::Polling& FDv2Builder::Polling::PollInterval( + std::chrono::seconds interval) { + poll_interval_ = interval; + return *this; +} + +FDv2Builder::Polling& FDv2Builder::Polling::BaseUrl(std::string base_url) { + base_url_override_ = std::move(base_url); + return *this; +} + +FDv2Builder::Config::PollingConfig FDv2Builder::Polling::Build() const { + return {poll_interval_, base_url_override_}; +} + +FDv2Builder::FDv1Fallback& FDv2Builder::FDv1Fallback::PollInterval( + std::chrono::seconds interval) { + poll_interval_ = interval; + return *this; +} + +FDv2Builder::FDv1Fallback& FDv2Builder::FDv1Fallback::BaseUrl( + std::string base_url) { + base_url_override_ = std::move(base_url); + return *this; +} + +FDv2Builder::Config::FDv1FallbackConfig FDv2Builder::FDv1Fallback::Build() + const { + return {poll_interval_, base_url_override_}; +} + +FDv2Builder::Mode& FDv2Builder::Mode::Initializer(Cache source) { + definition_.initializers.emplace_back(source.Build()); + return *this; +} + +FDv2Builder::Mode& FDv2Builder::Mode::Initializer(Polling source) { + definition_.initializers.emplace_back(source.Build()); + return *this; +} + +FDv2Builder::Mode& FDv2Builder::Mode::Synchronizer(Streaming source) { + definition_.synchronizers.emplace_back(source.Build()); + return *this; +} + +FDv2Builder::Mode& FDv2Builder::Mode::Synchronizer(Polling source) { + definition_.synchronizers.emplace_back(source.Build()); + return *this; +} + +FDv2Builder::Mode& FDv2Builder::Mode::FallbackToFDv1(FDv1Fallback source) { + definition_.fdv1_fallback = source.Build(); + return *this; +} + +FDv2Builder::Mode& FDv2Builder::Mode::DisableFDv1Fallback() { + definition_.fdv1_fallback = std::nullopt; + return *this; +} + +FDv2Builder::Config::ModeDefinition FDv2Builder::Mode::Build() const { + return definition_; +} + +FDv2Builder::FDv2Builder() : config_(Defaults::FDv2Config()) {} + +FDv2Builder& FDv2Builder::InitialMode(ConnectionMode mode) { + config_.initial_mode = mode; + return *this; +} + +FDv2Builder& FDv2Builder::CustomizeMode(ConnectionMode mode, Mode definition) { + config_.modes[mode] = definition.Build(); + return *this; +} + +FDv2Builder& FDv2Builder::UsePost(bool use_post) { + config_.use_post = use_post; + return *this; +} + +FDv2Builder::Config FDv2Builder::Build() const { + return config_; +} + +} // namespace launchdarkly::config::shared::builders