From b6ebec5d17cb9ff24181a0718282a008300ee333 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 30 Oct 2025 15:28:56 -0700 Subject: [PATCH 1/3] fix: Handle null payloads. --- .github/workflows/server.yml | 2 - .../test-suppressions.txt | 7 - .../sources/streaming/event_handler.cpp | 4 + .../streaming/streaming_data_source.cpp | 15 +- .../tests/data_source_event_handler_test.cpp | 273 ++++++++++++++++++ .../include/launchdarkly/sse/client.hpp | 9 + libs/server-sent-events/src/client.cpp | 23 ++ libs/server-sent-events/src/curl_client.cpp | 11 + libs/server-sent-events/src/curl_client.hpp | 16 + 9 files changed, 348 insertions(+), 12 deletions(-) delete mode 100644 contract-tests/server-contract-tests/test-suppressions.txt diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index 7740e1585..3542c8b50 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -32,7 +32,6 @@ jobs: with: # Inform the test harness of test service's port. test_service_port: ${{ env.TEST_SERVICE_PORT }} - extra_params: '-skip-from ./contract-tests/server-contract-tests/test-suppressions.txt' token: ${{ secrets.GITHUB_TOKEN }} contract-tests-curl: @@ -54,7 +53,6 @@ jobs: with: # Inform the test harness of test service's port. test_service_port: ${{ env.TEST_SERVICE_PORT }} - extra_params: '-skip-from ./contract-tests/server-contract-tests/test-suppressions.txt' token: ${{ secrets.GITHUB_TOKEN }} build-test-server: diff --git a/contract-tests/server-contract-tests/test-suppressions.txt b/contract-tests/server-contract-tests/test-suppressions.txt deleted file mode 100644 index 460d051db..000000000 --- a/contract-tests/server-contract-tests/test-suppressions.txt +++ /dev/null @@ -1,7 +0,0 @@ -# SC-204387 -streaming/validation/drop and reconnect if stream event has malformed JSON/put event -streaming/validation/drop and reconnect if stream event has malformed JSON/patch event -streaming/validation/drop and reconnect if stream event has malformed JSON/delete event -streaming/validation/drop and reconnect if stream event has well-formed JSON not matching schema/put event -streaming/validation/drop and reconnect if stream event has well-formed JSON not matching schema/patch event -streaming/validation/drop and reconnect if stream event has well-formed JSON not matching schema/delete event diff --git a/libs/server-sdk/src/data_systems/background_sync/sources/streaming/event_handler.cpp b/libs/server-sdk/src/data_systems/background_sync/sources/streaming/event_handler.cpp index 194fa895c..078f00ca8 100644 --- a/libs/server-sdk/src/data_systems/background_sync/sources/streaming/event_handler.cpp +++ b/libs/server-sdk/src/data_systems/background_sync/sources/streaming/event_handler.cpp @@ -41,6 +41,10 @@ tl::expected Patch( if (!data.has_value()) { return tl::unexpected(JsonError::kSchemaFailure); } + // Check if the optional is empty (indicates null data) + if (!data->has_value()) { + return tl::unexpected(JsonError::kSchemaFailure); + } return DataSourceEventHandler::Patch{ TStreamingDataKind::Key(path), data_model::ItemDescriptor(data->value())}; diff --git a/libs/server-sdk/src/data_systems/background_sync/sources/streaming/streaming_data_source.cpp b/libs/server-sdk/src/data_systems/background_sync/sources/streaming/streaming_data_source.cpp index 4c433c12b..5f7a27a7b 100644 --- a/libs/server-sdk/src/data_systems/background_sync/sources/streaming/streaming_data_source.cpp +++ b/libs/server-sdk/src/data_systems/background_sync/sources/streaming/streaming_data_source.cpp @@ -127,9 +127,18 @@ void StreamingDataSource::StartAsync( client_builder.receiver([weak_self](launchdarkly::sse::Event const& event) { if (auto self = weak_self.lock()) { - self->event_handler_->HandleMessage(event.type(), event.data()); - // TODO: Use the result of handle message to restart the - // event source if we got bad data. sc-204387 + auto status = + self->event_handler_->HandleMessage(event.type(), event.data()); + if (status == DataSourceEventHandler::MessageStatus::kInvalidMessage) { + // Invalid data received - restart the connection with backoff + // to get a fresh stream. The backoff mechanism prevents rapid + // reconnection attempts. + LD_LOG(self->logger_, LogLevel::kWarn) + << "Received invalid data from stream, restarting connection"; + if (self->client_) { + self->client_->async_restart("invalid data in stream"); + } + } } }); diff --git a/libs/server-sdk/tests/data_source_event_handler_test.cpp b/libs/server-sdk/tests/data_source_event_handler_test.cpp index 90c43ba66..d2591fe8e 100644 --- a/libs/server-sdk/tests/data_source_event_handler_test.cpp +++ b/libs/server-sdk/tests/data_source_event_handler_test.cpp @@ -203,3 +203,276 @@ TEST(DataSourceEventHandlerTests, HandlesDeleteSegment) { ASSERT_FALSE(store->GetSegment("segmentA")->item); } + +TEST(DataSourceEventHandlerTests, HandlesPatchWithNullDataForFlag) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + + // Initialize the store + event_handler.HandleMessage("put", R"({"path":"/", "data":{}})"); + + // Null data should be treated as invalid, not crash the application + auto res = event_handler.HandleMessage( + "patch", R"({"path": "/flags/flagA", "data": null})"); + + ASSERT_EQ(DataSourceEventHandler::MessageStatus::kInvalidMessage, res); + // The error should be recorded, but we stay in Valid state after a previous successful PUT + EXPECT_EQ(DataSourceStatus::DataSourceState::kValid, + manager.Status().State()); + ASSERT_TRUE(manager.Status().LastError().has_value()); + EXPECT_EQ(DataSourceStatus::ErrorInfo::ErrorKind::kInvalidData, + manager.Status().LastError()->Kind()); +} + +TEST(DataSourceEventHandlerTests, HandlesPatchWithNullDataForSegment) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + + // Initialize the store + event_handler.HandleMessage("put", R"({"path":"/", "data":{}})"); + + // Null data should be treated as invalid, not crash the application + auto res = event_handler.HandleMessage( + "patch", R"({"path": "/segments/segmentA", "data": null})"); + + ASSERT_EQ(DataSourceEventHandler::MessageStatus::kInvalidMessage, res); + // The error should be recorded, but we stay in Valid state after a previous successful PUT + EXPECT_EQ(DataSourceStatus::DataSourceState::kValid, + manager.Status().State()); + ASSERT_TRUE(manager.Status().LastError().has_value()); + EXPECT_EQ(DataSourceStatus::ErrorInfo::ErrorKind::kInvalidData, + manager.Status().LastError()->Kind()); +} + +TEST(DataSourceEventHandlerTests, HandlesPatchWithMissingDataField) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + + // Initialize the store + event_handler.HandleMessage("put", R"({"path":"/", "data":{}})"); + + // Missing data field should also be treated as invalid + auto res = event_handler.HandleMessage( + "patch", R"({"path": "/flags/flagA"})"); + + ASSERT_EQ(DataSourceEventHandler::MessageStatus::kInvalidMessage, res); +} + +TEST(DataSourceEventHandlerTests, HandlesPutWithNullData) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + + // PUT with null data should also be handled safely + auto res = event_handler.HandleMessage( + "put", R"({"path":"/", "data": null})"); + + // PUT handles this differently - it may succeed with empty data + // The important thing is it doesn't crash + ASSERT_TRUE(res == DataSourceEventHandler::MessageStatus::kMessageHandled || + res == DataSourceEventHandler::MessageStatus::kInvalidMessage); +} + +// Tests for wrong data types (schema validation errors) + +TEST(DataSourceEventHandlerTests, HandlesPatchWithBooleanData) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + + // Initialize the store + event_handler.HandleMessage("put", R"({"path":"/", "data":{}})"); + + // Boolean data instead of object should be treated as invalid + auto res = event_handler.HandleMessage( + "patch", R"({"path": "/flags/flagA", "data": true})"); + + ASSERT_EQ(DataSourceEventHandler::MessageStatus::kInvalidMessage, res); +} + +TEST(DataSourceEventHandlerTests, HandlesPatchWithStringData) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + + // Initialize the store + event_handler.HandleMessage("put", R"({"path":"/", "data":{}})"); + + // String data instead of object should be treated as invalid + auto res = event_handler.HandleMessage( + "patch", R"({"path": "/flags/flagA", "data": "not an object"})"); + + ASSERT_EQ(DataSourceEventHandler::MessageStatus::kInvalidMessage, res); +} + +TEST(DataSourceEventHandlerTests, HandlesPatchWithArrayData) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + + // Initialize the store + event_handler.HandleMessage("put", R"({"path":"/", "data":{}})"); + + // Array data instead of object should be treated as invalid + auto res = event_handler.HandleMessage( + "patch", R"({"path": "/flags/flagA", "data": []})"); + + ASSERT_EQ(DataSourceEventHandler::MessageStatus::kInvalidMessage, res); +} + +TEST(DataSourceEventHandlerTests, HandlesPatchWithNumberData) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + + // Initialize the store + event_handler.HandleMessage("put", R"({"path":"/", "data":{}})"); + + // Number data instead of object should be treated as invalid + auto res = event_handler.HandleMessage( + "patch", R"({"path": "/flags/flagA", "data": 42})"); + + ASSERT_EQ(DataSourceEventHandler::MessageStatus::kInvalidMessage, res); +} + +TEST(DataSourceEventHandlerTests, HandlesDeleteWithStringVersion) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + + // Initialize the store + event_handler.HandleMessage("put", R"({"path":"/", "data":{}})"); + + // String version instead of number should be treated as invalid + auto res = event_handler.HandleMessage( + "delete", R"({"path": "/flags/flagA", "version": "not a number"})"); + + ASSERT_EQ(DataSourceEventHandler::MessageStatus::kInvalidMessage, res); +} + +TEST(DataSourceEventHandlerTests, HandlesPutWithInvalidFlagsType) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + + // Flags should be an object, not a boolean + auto res = event_handler.HandleMessage( + "put", R"({"path": "/", "data": {"flags": true, "segments": {}}})"); + + ASSERT_EQ(DataSourceEventHandler::MessageStatus::kInvalidMessage, res); +} + +TEST(DataSourceEventHandlerTests, HandlesPutWithInvalidSegmentsType) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + + // Segments should be an object, not an array + auto res = event_handler.HandleMessage( + "put", R"({"path": "/", "data": {"flags": {}, "segments": []}})"); + + ASSERT_EQ(DataSourceEventHandler::MessageStatus::kInvalidMessage, res); +} + +// Tests for additional malformed JSON variants + +TEST(DataSourceEventHandlerTests, HandlesUnterminatedString) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + + // Unterminated string should be treated as malformed JSON + auto res = event_handler.HandleMessage( + "patch", R"({"path": "/flags/x", "data": "unterminated)"); + + ASSERT_EQ(DataSourceEventHandler::MessageStatus::kInvalidMessage, res); +} + +TEST(DataSourceEventHandlerTests, HandlesTrailingComma) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + + // Trailing comma should be treated as malformed JSON + auto res = event_handler.HandleMessage( + "patch", R"({"path": "/flags/x", "data": {},})"); + + ASSERT_EQ(DataSourceEventHandler::MessageStatus::kInvalidMessage, res); +} + +// Tests for missing required fields + +TEST(DataSourceEventHandlerTests, HandlesDeleteWithMissingPath) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + + // Initialize the store + event_handler.HandleMessage("put", R"({"path":"/", "data":{}})"); + + // Missing path field should be treated as invalid + auto res = event_handler.HandleMessage( + "delete", R"({"version": 1})"); + + ASSERT_EQ(DataSourceEventHandler::MessageStatus::kInvalidMessage, res); +} + +TEST(DataSourceEventHandlerTests, HandlesDeleteWithMissingVersion) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + + // Initialize the store + event_handler.HandleMessage("put", R"({"path":"/", "data":{}})"); + + // Missing version field should be treated as invalid + auto res = event_handler.HandleMessage( + "delete", R"({"path": "/flags/flagA"})"); + + ASSERT_EQ(DataSourceEventHandler::MessageStatus::kInvalidMessage, res); +} + +TEST(DataSourceEventHandlerTests, HandlesPutWithMissingPath) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + + // Missing/empty path is treated as unrecognized (safely ignored) + // This provides forward compatibility + auto res = event_handler.HandleMessage( + "put", R"({"data": {}})"); + + ASSERT_EQ(DataSourceEventHandler::MessageStatus::kMessageHandled, res); +} + +TEST(DataSourceEventHandlerTests, HandlesEmptyJsonObject) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + + // Empty JSON object with missing path is treated as unrecognized (safely ignored) + // This provides forward compatibility with future event types + auto res = event_handler.HandleMessage("patch", "{}"); + + ASSERT_EQ(DataSourceEventHandler::MessageStatus::kMessageHandled, res); +} diff --git a/libs/server-sent-events/include/launchdarkly/sse/client.hpp b/libs/server-sent-events/include/launchdarkly/sse/client.hpp index ca0b93a7a..500467fd0 100644 --- a/libs/server-sent-events/include/launchdarkly/sse/client.hpp +++ b/libs/server-sent-events/include/launchdarkly/sse/client.hpp @@ -207,6 +207,15 @@ class Client { virtual ~Client() = default; virtual void async_connect() = 0; virtual void async_shutdown(std::function completion) = 0; + + /** + * Restart the connection with exponential backoff. This should be called + * when the SDK detects invalid data from the stream and needs to + * reconnect. The backoff mechanism prevents rapid reconnection attempts + * that could overload the service. + * @param reason A description of why the restart was triggered (for logging) + */ + virtual void async_restart(std::string const& reason) = 0; }; } // namespace launchdarkly::sse diff --git a/libs/server-sent-events/src/client.cpp b/libs/server-sent-events/src/client.cpp index 46246f3b5..99e8fdb10 100644 --- a/libs/server-sent-events/src/client.cpp +++ b/libs/server-sent-events/src/client.cpp @@ -161,6 +161,29 @@ class FoxyClient : public Client, beast::bind_front_handler(&FoxyClient::do_run, shared_from_this())); } + void async_restart(std::string const& reason) override { + boost::asio::post( + session_->get_executor(), + beast::bind_front_handler(&FoxyClient::do_restart, + shared_from_this(), reason)); + } + + void do_restart(std::string const& reason) { + // Cancel any ongoing read operations + try { + if (session_->stream.is_ssl()) { + session_->stream.ssl().next_layer().cancel(); + } else { + session_->stream.plain().cancel(); + } + } catch (boost::system::system_error const& err) { + logger_("exception canceling stream during restart: " + + std::string(err.what())); + } + // Trigger backoff and reconnect + async_backoff(reason); + } + void do_run() { session_->async_connect( host_, port_, diff --git a/libs/server-sent-events/src/curl_client.cpp b/libs/server-sent-events/src/curl_client.cpp index c410db004..c99b6830a 100644 --- a/libs/server-sent-events/src/curl_client.cpp +++ b/libs/server-sent-events/src/curl_client.cpp @@ -76,6 +76,17 @@ void CurlClient::async_connect() { [self = shared_from_this()]() { self->do_run(); }); } +void CurlClient::async_restart(std::string const& reason) { + boost::asio::post(backoff_timer_.get_executor(), + [self = shared_from_this(), reason]() { + // Close the socket to abort the current transfer. + // CURL will detect the error and call the completion + // handler, which will trigger backoff and reconnection. + self->log_message("async_restart: aborting transfer due to " + reason); + self->request_context_->abort_transfer(); + }); +} + void CurlClient::do_run() { if (request_context_->is_shutting_down()) { return; diff --git a/libs/server-sent-events/src/curl_client.hpp b/libs/server-sent-events/src/curl_client.hpp index a3ab9e173..e210791d1 100644 --- a/libs/server-sent-events/src/curl_client.hpp +++ b/libs/server-sent-events/src/curl_client.hpp @@ -170,6 +170,21 @@ class CurlClient final : public Client, curl_socket_ = curl_socket; } + void abort_transfer() { + std::lock_guard lock(mutex_); + if (shutting_down_) { + return; + } + if (curl_socket_ != CURL_SOCKET_BAD) { +#ifdef _WIN32 + closesocket(curl_socket_); +#else + close(curl_socket_); +#endif + curl_socket_ = CURL_SOCKET_BAD; + } + } + void shutdown() { std::lock_guard lock(mutex_); shutting_down_ = true; @@ -232,6 +247,7 @@ class CurlClient final : public Client, void async_connect() override; void async_shutdown(std::function completion) override; + void async_restart(std::string const& reason) override; private: void do_run(); From e62e5754e335639f945822e4112bac2cb2b93639 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 31 Oct 2025 14:33:46 -0700 Subject: [PATCH 2/3] chore: Add support for server-side hooks tests. --- .../include/data_model/data_model.hpp | 34 ++- .../server-contract-tests/CMakeLists.txt | 1 + .../include/contract_test_hook.hpp | 72 +++++ .../src/contract_test_hook.cpp | 272 ++++++++++++++++++ .../src/entity_manager.cpp | 8 + .../server-contract-tests/src/main.cpp | 2 + 6 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 contract-tests/server-contract-tests/include/contract_test_hook.hpp create mode 100644 contract-tests/server-contract-tests/src/contract_test_hook.cpp diff --git a/contract-tests/data-model/include/data_model/data_model.hpp b/contract-tests/data-model/include/data_model/data_model.hpp index 7346f0e41..31413de32 100644 --- a/contract-tests/data-model/include/data_model/data_model.hpp +++ b/contract-tests/data-model/include/data_model/data_model.hpp @@ -113,6 +113,36 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigTags, applicationId, applicationVersion); +enum class HookStage { + BeforeEvaluation, + AfterEvaluation, + AfterTrack +}; + +NLOHMANN_JSON_SERIALIZE_ENUM(HookStage, + {{HookStage::BeforeEvaluation, "beforeEvaluation"}, + {HookStage::AfterEvaluation, "afterEvaluation"}, + {HookStage::AfterTrack, "afterTrack"}}) + +struct ConfigHookInstance { + std::string name; + std::string callbackUri; + std::optional>> data; + std::optional> errors; +}; + +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigHookInstance, + name, + callbackUri, + data, + errors); + +struct ConfigHooksParams { + std::vector hooks; +}; + +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigHooksParams, hooks); + struct ConfigParams { std::string credential; std::optional startWaitTimeMs; @@ -125,6 +155,7 @@ struct ConfigParams { std::optional tags; std::optional tls; std::optional proxy; + std::optional hooks; }; NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigParams, @@ -138,7 +169,8 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigParams, clientSide, tags, tls, - proxy); + proxy, + hooks); struct ContextSingleParams { std::optional kind; diff --git a/contract-tests/server-contract-tests/CMakeLists.txt b/contract-tests/server-contract-tests/CMakeLists.txt index 3bd94cb96..9455cd27e 100644 --- a/contract-tests/server-contract-tests/CMakeLists.txt +++ b/contract-tests/server-contract-tests/CMakeLists.txt @@ -16,6 +16,7 @@ add_executable(server-tests src/session.cpp src/entity_manager.cpp src/client_entity.cpp + src/contract_test_hook.cpp ) target_link_libraries(server-tests PRIVATE diff --git a/contract-tests/server-contract-tests/include/contract_test_hook.hpp b/contract-tests/server-contract-tests/include/contract_test_hook.hpp new file mode 100644 index 000000000..5d081a01c --- /dev/null +++ b/contract-tests/server-contract-tests/include/contract_test_hook.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include +#include + +#include +#include +#include + +/** + * ContractTestHook implements the Hook interface for contract testing. + * + * It posts hook execution payloads to a callback URI specified in the test + * configuration, allowing the test harness to verify hook behavior. + */ +class ContractTestHook : public launchdarkly::server_side::hooks::Hook { + public: + /** + * Constructs a contract test hook. + * @param executor IO executor for async HTTP operations. + * @param config Hook configuration from the test harness. + */ + ContractTestHook(boost::asio::any_io_executor executor, + ConfigHookInstance config); + + ~ContractTestHook() override = default; + + [[nodiscard]] launchdarkly::server_side::hooks::HookMetadata const& + Metadata() const override; + + launchdarkly::server_side::hooks::EvaluationSeriesData BeforeEvaluation( + launchdarkly::server_side::hooks::EvaluationSeriesContext const& + series_context, + launchdarkly::server_side::hooks::EvaluationSeriesData data) override; + + launchdarkly::server_side::hooks::EvaluationSeriesData AfterEvaluation( + launchdarkly::server_side::hooks::EvaluationSeriesContext const& + series_context, + launchdarkly::server_side::hooks::EvaluationSeriesData data, + launchdarkly::EvaluationDetail const& detail) + override; + + void AfterTrack(launchdarkly::server_side::hooks::TrackSeriesContext const& + series_context) override; + + private: + /** + * Posts a hook execution payload to the callback URI. + * @param stage The stage being executed. + * @param payload The JSON payload to send. + */ + void PostCallback(std::string const& stage, nlohmann::json const& payload); + + /** + * Gets configured data for a specific stage. + * @param stage The stage name. + * @return Optional map of key-value pairs for this stage. + */ + std::optional> GetDataForStage( + std::string const& stage) const; + + /** + * Gets configured error for a specific stage. + * @param stage The stage name. + * @return Optional error message for this stage. + */ + std::optional GetErrorForStage(std::string const& stage) const; + + boost::asio::any_io_executor executor_; + ConfigHookInstance config_; + launchdarkly::server_side::hooks::HookMetadata metadata_; +}; diff --git a/contract-tests/server-contract-tests/src/contract_test_hook.cpp b/contract-tests/server-contract-tests/src/contract_test_hook.cpp new file mode 100644 index 000000000..6d2f93f33 --- /dev/null +++ b/contract-tests/server-contract-tests/src/contract_test_hook.cpp @@ -0,0 +1,272 @@ +#include "contract_test_hook.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace beast = boost::beast; +namespace http = beast::http; +namespace net = boost::asio; +using tcp = net::ip::tcp; + +using namespace launchdarkly::server_side::hooks; + +ContractTestHook::ContractTestHook(boost::asio::any_io_executor executor, + ConfigHookInstance config) + : executor_(std::move(executor)), + config_(std::move(config)), + metadata_(config_.name) {} + +HookMetadata const& ContractTestHook::Metadata() const { + return metadata_; +} + +std::optional> +ContractTestHook::GetDataForStage(std::string const& stage) const { + if (!config_.data) { + return std::nullopt; + } + auto it = config_.data->find(stage); + if (it == config_.data->end()) { + return std::nullopt; + } + return it->second; +} + +std::optional ContractTestHook::GetErrorForStage( + std::string const& stage) const { + if (!config_.errors) { + return std::nullopt; + } + auto it = config_.errors->find(stage); + if (it == config_.errors->end()) { + return std::nullopt; + } + return it->second; +} + +void ContractTestHook::PostCallback(std::string const& stage, + nlohmann::json const& payload) { + // Parse the callback URI + auto uri_result = boost::urls::parse_uri(config_.callbackUri); + if (!uri_result) { + // Invalid URI, skip callback + return; + } + + auto uri = *uri_result; + std::string host(uri.host()); + std::string port = uri.has_port() ? std::string(uri.port()) : "80"; + std::string target(uri.path()); + if (target.empty()) { + target = "/"; + } + + std::string body = payload.dump(); + + // Create a shared connection and post asynchronously without blocking + // This uses fire-and-forget semantics - we don't wait for response + auto conn = std::make_shared(executor_); + auto resolver = std::make_shared(executor_); + + // Capture everything needed by value in shared_ptrs + auto req = std::make_shared>(); + req->method(http::verb::post); + req->target(target); + req->version(11); + req->set(http::field::host, host); + req->set(http::field::user_agent, "cpp-server-sdk-contract-tests"); + req->set(http::field::content_type, "application/json"); + req->body() = body; + req->prepare_payload(); + + // Start async resolution + resolver->async_resolve(host, port, + [conn, resolver, req](beast::error_code ec, tcp::resolver::results_type results) { + if (ec) return; // Silently ignore resolution errors + + // Connect asynchronously + conn->async_connect(results, + [conn, resolver, req](beast::error_code ec, tcp::resolver::results_type::endpoint_type) { + if (ec) return; // Silently ignore connection errors + + // Write request asynchronously + http::async_write(*conn, *req, + [conn, resolver, req](beast::error_code ec, std::size_t) { + if (ec) return; // Silently ignore write errors + + // Read response asynchronously (but don't wait for result) + auto res = std::make_shared>(); + auto buffer = std::make_shared(); + http::async_read(*conn, *buffer, *res, + [conn, resolver, req, res, buffer](beast::error_code ec, std::size_t) { + // Close connection gracefully + conn->socket().shutdown(tcp::socket::shutdown_both, ec); + // Cleanup happens automatically via shared_ptr + }); + }); + }); + }); +} + +EvaluationSeriesData ContractTestHook::BeforeEvaluation( + EvaluationSeriesContext const& series_context, + EvaluationSeriesData data) { + + // Check if we should throw an error for this stage + auto error = GetErrorForStage("beforeEvaluation"); + if (error) { + throw std::runtime_error(*error); + } + + // Build the evaluation series data from configured data + EvaluationSeriesDataBuilder builder(data); + auto stage_data = GetDataForStage("beforeEvaluation"); + if (stage_data) { + for (auto const& [key, value] : *stage_data) { + // Convert nlohmann::json to launchdarkly::Value via boost::json + auto maybe_value = boost::json::value_to< + tl::expected>( + boost::json::parse(value.dump())); + if (maybe_value) { + builder.Set(key, *maybe_value); + } + } + } + + // Build payload for callback + nlohmann::json payload; + payload["stage"] = "beforeEvaluation"; + + // EvaluationSeriesContext + nlohmann::json ctx; + ctx["flagKey"] = std::string(series_context.FlagKey()); + ctx["context"] = nlohmann::json::parse( + boost::json::serialize( + boost::json::value_from(series_context.EvaluationContext()))); + ctx["defaultValue"] = nlohmann::json::parse( + boost::json::serialize( + boost::json::value_from(series_context.DefaultValue()))); + ctx["method"] = std::string(series_context.Method()); + if (series_context.EnvironmentId()) { + ctx["environmentId"] = std::string(*series_context.EnvironmentId()); + } + payload["evaluationSeriesContext"] = ctx; + + // EvaluationSeriesData + nlohmann::json series_data_json; + for (auto const& key : data.Keys()) { + auto val = data.Get(key); + if (val) { + series_data_json[key] = nlohmann::json::parse( + boost::json::serialize(boost::json::value_from(val->get()))); + } + } + payload["evaluationSeriesData"] = series_data_json; + + PostCallback("beforeEvaluation", payload); + + return builder.Build(); +} + +EvaluationSeriesData ContractTestHook::AfterEvaluation( + EvaluationSeriesContext const& series_context, + EvaluationSeriesData data, + launchdarkly::EvaluationDetail const& detail) { + + // Check if we should throw an error for this stage + auto error = GetErrorForStage("afterEvaluation"); + if (error) { + throw std::runtime_error(*error); + } + + // Build payload for callback + nlohmann::json payload; + payload["stage"] = "afterEvaluation"; + + // EvaluationSeriesContext + nlohmann::json ctx; + ctx["flagKey"] = std::string(series_context.FlagKey()); + ctx["context"] = nlohmann::json::parse( + boost::json::serialize( + boost::json::value_from(series_context.EvaluationContext()))); + ctx["defaultValue"] = nlohmann::json::parse( + boost::json::serialize( + boost::json::value_from(series_context.DefaultValue()))); + ctx["method"] = std::string(series_context.Method()); + if (series_context.EnvironmentId()) { + ctx["environmentId"] = std::string(*series_context.EnvironmentId()); + } + payload["evaluationSeriesContext"] = ctx; + + // EvaluationSeriesData + nlohmann::json series_data_json; + for (auto const& key : data.Keys()) { + auto val = data.Get(key); + if (val) { + series_data_json[key] = nlohmann::json::parse( + boost::json::serialize(boost::json::value_from(val->get()))); + } + } + payload["evaluationSeriesData"] = series_data_json; + + // EvaluationDetail + nlohmann::json detail_json; + detail_json["value"] = nlohmann::json::parse( + boost::json::serialize(boost::json::value_from(detail.Value()))); + if (detail.VariationIndex()) { + detail_json["variationIndex"] = *detail.VariationIndex(); + } + if (detail.Reason()) { + detail_json["reason"] = nlohmann::json::parse( + boost::json::serialize(boost::json::value_from(*detail.Reason()))); + } + payload["evaluationDetail"] = detail_json; + + PostCallback("afterEvaluation", payload); + + return data; +} + +void ContractTestHook::AfterTrack( + TrackSeriesContext const& series_context) { + + // Check if we should throw an error for this stage + auto error = GetErrorForStage("afterTrack"); + if (error) { + throw std::runtime_error(*error); + } + + // Build payload for callback + nlohmann::json payload; + payload["stage"] = "afterTrack"; + + // TrackSeriesContext + nlohmann::json ctx; + ctx["context"] = nlohmann::json::parse( + boost::json::serialize( + boost::json::value_from(series_context.TrackContext()))); + ctx["key"] = std::string(series_context.Key()); + if (series_context.MetricValue()) { + ctx["metricValue"] = *series_context.MetricValue(); + } + if (series_context.Data()) { + ctx["data"] = nlohmann::json::parse( + boost::json::serialize( + boost::json::value_from(series_context.Data()->get()))); + } + if (series_context.EnvironmentId()) { + ctx["environmentId"] = std::string(*series_context.EnvironmentId()); + } + payload["trackSeriesContext"] = ctx; + + PostCallback("afterTrack", payload); +} diff --git a/contract-tests/server-contract-tests/src/entity_manager.cpp b/contract-tests/server-contract-tests/src/entity_manager.cpp index 6a9548a3b..8c8fb7c21 100644 --- a/contract-tests/server-contract-tests/src/entity_manager.cpp +++ b/contract-tests/server-contract-tests/src/entity_manager.cpp @@ -1,4 +1,5 @@ #include "entity_manager.hpp" +#include "contract_test_hook.hpp" #include #include @@ -137,6 +138,13 @@ std::optional EntityManager::create(ConfigParams const& in) { config_builder.HttpProperties().Tls(std::move(builder)); } + if (in.hooks) { + for (auto const& hook_config : in.hooks->hooks) { + auto hook = std::make_shared(executor_, hook_config); + config_builder.Hooks(hook); + } + } + auto config = config_builder.Build(); if (!config) { LD_LOG(logger_, LogLevel::kWarn) diff --git a/contract-tests/server-contract-tests/src/main.cpp b/contract-tests/server-contract-tests/src/main.cpp index 1e76c5224..8584c35ea 100644 --- a/contract-tests/server-contract-tests/src/main.cpp +++ b/contract-tests/server-contract-tests/src/main.cpp @@ -48,6 +48,8 @@ int main(int argc, char* argv[]) { srv.add_capability("filtering"); srv.add_capability("filtering-strict"); srv.add_capability("client-prereq-events"); + srv.add_capability("evaluation-hooks"); + srv.add_capability("track-hooks"); net::signal_set signals{ioc, SIGINT, SIGTERM}; From 86ca2a9ff182d9280ad2adb577551d5665e4ab96 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 31 Oct 2025 15:07:01 -0700 Subject: [PATCH 3/3] fix: Fix a wrapper support issue where the wrapper header didn't properly handle a missing version. --- .../client-contract-tests/src/entity_manager.cpp | 9 +++++++++ contract-tests/client-contract-tests/src/main.cpp | 1 + .../data-model/include/data_model/data_model.hpp | 11 ++++++++++- .../server-contract-tests/src/entity_manager.cpp | 9 +++++++++ contract-tests/server-contract-tests/src/main.cpp | 1 + libs/common/src/config/http_properties_builder.cpp | 8 ++++++-- 6 files changed, 36 insertions(+), 3 deletions(-) diff --git a/contract-tests/client-contract-tests/src/entity_manager.cpp b/contract-tests/client-contract-tests/src/entity_manager.cpp index 2bb833926..9a76e4821 100644 --- a/contract-tests/client-contract-tests/src/entity_manager.cpp +++ b/contract-tests/client-contract-tests/src/entity_manager.cpp @@ -146,6 +146,15 @@ std::optional EntityManager::create(ConfigParams const& in) { config_builder.HttpProperties().Tls(std::move(builder)); } + if (in.wrapper) { + if (!in.wrapper->name.empty()) { + config_builder.HttpProperties().WrapperName(in.wrapper->name); + } + if (!in.wrapper->version.empty()) { + config_builder.HttpProperties().WrapperVersion(in.wrapper->version); + } + } + auto config = config_builder.Build(); if (!config) { LD_LOG(logger_, LogLevel::kWarn) diff --git a/contract-tests/client-contract-tests/src/main.cpp b/contract-tests/client-contract-tests/src/main.cpp index 9b2d4cada..7b5a878ae 100644 --- a/contract-tests/client-contract-tests/src/main.cpp +++ b/contract-tests/client-contract-tests/src/main.cpp @@ -47,6 +47,7 @@ int main(int argc, char* argv[]) { srv.add_capability("tls:skip-verify-peer"); srv.add_capability("tls:custom-ca"); srv.add_capability("client-prereq-events"); + srv.add_capability("wrapper"); // Proxies are supported only with CURL networking. #ifdef LD_CURL_NETWORKING srv.add_capability("http-proxy"); diff --git a/contract-tests/data-model/include/data_model/data_model.hpp b/contract-tests/data-model/include/data_model/data_model.hpp index 31413de32..679fd0015 100644 --- a/contract-tests/data-model/include/data_model/data_model.hpp +++ b/contract-tests/data-model/include/data_model/data_model.hpp @@ -143,6 +143,13 @@ struct ConfigHooksParams { NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigHooksParams, hooks); +struct ConfigWrapper { + std::string name; + std::string version; +}; + +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigWrapper, name, version); + struct ConfigParams { std::string credential; std::optional startWaitTimeMs; @@ -156,6 +163,7 @@ struct ConfigParams { std::optional tls; std::optional proxy; std::optional hooks; + std::optional wrapper; }; NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigParams, @@ -170,7 +178,8 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigParams, tags, tls, proxy, - hooks); + hooks, + wrapper); struct ContextSingleParams { std::optional kind; diff --git a/contract-tests/server-contract-tests/src/entity_manager.cpp b/contract-tests/server-contract-tests/src/entity_manager.cpp index 8c8fb7c21..085df1c2d 100644 --- a/contract-tests/server-contract-tests/src/entity_manager.cpp +++ b/contract-tests/server-contract-tests/src/entity_manager.cpp @@ -145,6 +145,15 @@ std::optional EntityManager::create(ConfigParams const& in) { } } + if (in.wrapper) { + if (!in.wrapper->name.empty()) { + config_builder.HttpProperties().WrapperName(in.wrapper->name); + } + if (!in.wrapper->version.empty()) { + config_builder.HttpProperties().WrapperVersion(in.wrapper->version); + } + } + auto config = config_builder.Build(); if (!config) { LD_LOG(logger_, LogLevel::kWarn) diff --git a/contract-tests/server-contract-tests/src/main.cpp b/contract-tests/server-contract-tests/src/main.cpp index 8584c35ea..e017a8634 100644 --- a/contract-tests/server-contract-tests/src/main.cpp +++ b/contract-tests/server-contract-tests/src/main.cpp @@ -50,6 +50,7 @@ int main(int argc, char* argv[]) { srv.add_capability("client-prereq-events"); srv.add_capability("evaluation-hooks"); srv.add_capability("track-hooks"); + srv.add_capability("wrapper"); net::signal_set signals{ioc, SIGINT, SIGTERM}; diff --git a/libs/common/src/config/http_properties_builder.cpp b/libs/common/src/config/http_properties_builder.cpp index df5b31c65..43d45b41a 100644 --- a/libs/common/src/config/http_properties_builder.cpp +++ b/libs/common/src/config/http_properties_builder.cpp @@ -133,8 +133,12 @@ template built::HttpProperties HttpPropertiesBuilder::Build() const { if (!wrapper_name_.empty()) { std::map headers_with_wrapper(base_headers_); - headers_with_wrapper["X-LaunchDarkly-Wrapper"] = - wrapper_name_ + "/" + wrapper_version_; + if (wrapper_version_.empty()) { + headers_with_wrapper["X-LaunchDarkly-Wrapper"] = wrapper_name_; + } else { + headers_with_wrapper["X-LaunchDarkly-Wrapper"] = + wrapper_name_ + "/" + wrapper_version_; + } return {connect_timeout_, read_timeout_, write_timeout_, response_timeout_, headers_with_wrapper, tls_.Build(), proxy_}; }