diff --git a/libs/internal/include/launchdarkly/data_model/change_set.hpp b/libs/internal/include/launchdarkly/data_model/change_set.hpp new file mode 100644 index 000000000..972c24f4e --- /dev/null +++ b/libs/internal/include/launchdarkly/data_model/change_set.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include + +namespace launchdarkly::data_model { + +enum class ChangeSetType { + kFull = 0, + kPartial = 1, + kNone = 2, +}; + +template +struct ChangeSet { + ChangeSetType type; + T data; + Selector selector; +}; + +} // namespace launchdarkly::data_model diff --git a/libs/internal/include/launchdarkly/data_model/fdv2_change.hpp b/libs/internal/include/launchdarkly/data_model/fdv2_change.hpp index 0d45470aa..4600eb3a1 100644 --- a/libs/internal/include/launchdarkly/data_model/fdv2_change.hpp +++ b/libs/internal/include/launchdarkly/data_model/fdv2_change.hpp @@ -1,29 +1,28 @@ #pragma once -#include -#include -#include +#include #include +#include + +#include #include -#include #include namespace launchdarkly::data_model { struct FDv2Change { + enum class ChangeType { kPut, kDelete }; + + ChangeType change_type; + std::string kind; std::string key; - std::variant, ItemDescriptor> object; + uint64_t version; + boost::json::value object; // set for kPut; unused for kDelete }; struct FDv2ChangeSet { - enum class Type { - kFull = 0, - kPartial = 1, - kNone = 2, - }; - - Type type; + ChangeSetType type; std::vector changes; Selector selector; }; diff --git a/libs/internal/src/fdv2_protocol_handler.cpp b/libs/internal/src/fdv2_protocol_handler.cpp index d0564e45f..13b8e6823 100644 --- a/libs/internal/src/fdv2_protocol_handler.cpp +++ b/libs/internal/src/fdv2_protocol_handler.cpp @@ -1,11 +1,5 @@ #include -#include -#include -#include -#include -#include - #include #include @@ -20,66 +14,6 @@ static char const* const kGoodbye = "goodbye"; using Error = FDv2ProtocolHandler::Error; -// Returns the parsed FDv2Change on success, nullopt for unknown kinds (which -// should be silently skipped for forward-compatibility), or an Error if -// a known kind fails to deserialize. -static tl::expected, Error> ParsePut( - PutObject const& put) { - if (put.kind == "flag") { - auto result = boost::json::value_to< - tl::expected, JsonError>>( - put.object); - // One bad flag aborts the entire transfer so the store is never - // left in a partially-updated state. - if (!result) { - return tl::make_unexpected(Error::JsonParseError( - result.error(), - "could not deserialize flag '" + put.key + "'")); - } - if (!result->has_value()) { - return tl::make_unexpected(Error::JsonParseError( - "flag '" + put.key + "' object was null")); - } - return data_model::FDv2Change{ - put.key, - data_model::ItemDescriptor{std::move(**result)}}; - } - if (put.kind == "segment") { - auto result = boost::json::value_to< - tl::expected, JsonError>>( - put.object); - // One bad segment aborts the entire transfer so the store is never - // left in a partially-updated state. - if (!result) { - return tl::make_unexpected(Error::JsonParseError( - result.error(), - "could not deserialize segment '" + put.key + "'")); - } - if (!result->has_value()) { - return tl::make_unexpected(Error::JsonParseError( - "segment '" + put.key + "' object was null")); - } - return data_model::FDv2Change{ - put.key, data_model::ItemDescriptor{ - std::move(**result)}}; - } - // Silently skip unknown kinds for forward-compatibility. - return std::nullopt; -} - -static data_model::FDv2Change MakeDeleteChange(DeleteObject const& del) { - if (del.kind == "flag") { - return data_model::FDv2Change{ - del.key, - data_model::ItemDescriptor{ - data_model::Tombstone{static_cast(del.version)}}}; - } - return data_model::FDv2Change{ - del.key, - data_model::ItemDescriptor{ - data_model::Tombstone{static_cast(del.version)}}}; -} - FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleEvent( std::string_view event_type, boost::json::value const& data) { @@ -137,7 +71,7 @@ FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleServerIntent( // kNone or kUnknown: emit an empty changeset immediately. state_ = State::kInactive; return data_model::FDv2ChangeSet{ - data_model::FDv2ChangeSet::Type::kNone, {}, data_model::Selector{}}; + data_model::ChangeSetType::kNone, {}, data_model::Selector{}}; } return std::monostate{}; } @@ -158,14 +92,10 @@ FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandlePutObject( Reset(); return Error::JsonParseError("put-object data was null"); } - auto change = ParsePut(**result); - if (!change) { - Reset(); - return std::move(change.error()); - } - if (*change) { - changes_.push_back(std::move(**change)); - } + auto const& put = **result; + changes_.push_back(data_model::FDv2Change{ + data_model::FDv2Change::ChangeType::kPut, put.kind, put.key, + static_cast(put.version), put.object}); return std::monostate{}; } @@ -186,11 +116,12 @@ FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleDeleteObject( return Error::JsonParseError("delete-object data was null"); } auto const& del = **result; - // Silently skip unknown kinds for forward-compatibility. - if (del.kind != "flag" && del.kind != "segment") { - return std::monostate{}; - } - changes_.push_back(MakeDeleteChange(del)); + changes_.push_back( + data_model::FDv2Change{data_model::FDv2Change::ChangeType::kDelete, + del.kind, + del.key, + static_cast(del.version), + {}}); return std::monostate{}; } @@ -215,8 +146,8 @@ FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandlePayloadTransferred( } auto const& transferred = **result; auto type = (state_ == State::kPartial) - ? data_model::FDv2ChangeSet::Type::kPartial - : data_model::FDv2ChangeSet::Type::kFull; + ? data_model::ChangeSetType::kPartial + : data_model::ChangeSetType::kFull; data_model::FDv2ChangeSet changeset{ type, std::move(changes_), data_model::Selector{data_model::Selector::State{transferred.version, diff --git a/libs/internal/tests/fdv2_protocol_handler_test.cpp b/libs/internal/tests/fdv2_protocol_handler_test.cpp index 4a7d9da0d..fb428c243 100644 --- a/libs/internal/tests/fdv2_protocol_handler_test.cpp +++ b/libs/internal/tests/fdv2_protocol_handler_test.cpp @@ -1,5 +1,6 @@ #include +#include #include #include @@ -56,7 +57,7 @@ TEST(FDv2ProtocolHandlerTest, NoneIntentEmitsEmptyChangeSetImmediately) { auto* cs = std::get_if(&result); ASSERT_NE(cs, nullptr); - EXPECT_EQ(cs->type, data_model::FDv2ChangeSet::Type::kNone); + EXPECT_EQ(cs->type, data_model::ChangeSetType::kNone); EXPECT_TRUE(cs->changes.empty()); EXPECT_FALSE(cs->selector.value.has_value()); } @@ -81,7 +82,7 @@ TEST(FDv2ProtocolHandlerTest, FullIntentEmitsChangeSetOnPayloadTransferred) { auto* cs = std::get_if(&r3); ASSERT_NE(cs, nullptr); - EXPECT_EQ(cs->type, data_model::FDv2ChangeSet::Type::kFull); + EXPECT_EQ(cs->type, data_model::ChangeSetType::kFull); EXPECT_EQ(cs->changes.size(), 1u); EXPECT_EQ(cs->changes[0].key, "my-flag"); ASSERT_TRUE(cs->selector.value.has_value()); @@ -105,7 +106,7 @@ TEST(FDv2ProtocolHandlerTest, FullIntentAccumulatesMultipleObjects) { auto* cs = std::get_if(&result); ASSERT_NE(cs, nullptr); - EXPECT_EQ(cs->type, data_model::FDv2ChangeSet::Type::kFull); + EXPECT_EQ(cs->type, data_model::ChangeSetType::kFull); EXPECT_EQ(cs->changes.size(), 3u); } @@ -132,7 +133,7 @@ TEST(FDv2ProtocolHandlerTest, auto* cs = std::get_if(&result); ASSERT_NE(cs, nullptr); - EXPECT_EQ(cs->type, data_model::FDv2ChangeSet::Type::kPartial); + EXPECT_EQ(cs->type, data_model::ChangeSetType::kPartial); ASSERT_EQ(cs->changes.size(), 1u); EXPECT_EQ(cs->changes[0].key, "flag-2"); } @@ -153,7 +154,7 @@ TEST(FDv2ProtocolHandlerTest, PartialIntentEmitsPartialChangeSet) { auto* cs = std::get_if(&result); ASSERT_NE(cs, nullptr); - EXPECT_EQ(cs->type, data_model::FDv2ChangeSet::Type::kPartial); + EXPECT_EQ(cs->type, data_model::ChangeSetType::kPartial); EXPECT_EQ(cs->changes.size(), 1u); EXPECT_EQ(cs->changes[0].key, "my-seg"); ASSERT_TRUE(cs->selector.value.has_value()); @@ -164,7 +165,7 @@ TEST(FDv2ProtocolHandlerTest, PartialIntentEmitsPartialChangeSet) { // Unknown kind in put-object → silently skipped // ============================================================================ -TEST(FDv2ProtocolHandlerTest, UnknownKindInPutObjectIsSilentlySkipped) { +TEST(FDv2ProtocolHandlerTest, UnknownKindInPutObjectIsPassedThrough) { FDv2ProtocolHandler handler; handler.HandleEvent("server-intent", MakeServerIntent("xfer-full")); @@ -179,12 +180,11 @@ TEST(FDv2ProtocolHandlerTest, UnknownKindInPutObjectIsSilentlySkipped) { auto* cs = std::get_if(&result); ASSERT_NE(cs, nullptr); - // Only the known kind (flag) should appear. - EXPECT_EQ(cs->changes.size(), 1u); - EXPECT_EQ(cs->changes[0].key, "my-flag"); + // All kinds pass through; filtering happens in the translator. + EXPECT_EQ(cs->changes.size(), 2u); } -TEST(FDv2ProtocolHandlerTest, UnknownKindInDeleteObjectIsSilentlySkipped) { +TEST(FDv2ProtocolHandlerTest, UnknownKindInDeleteObjectIsPassedThrough) { FDv2ProtocolHandler handler; handler.HandleEvent("server-intent", MakeServerIntent("xfer-full")); @@ -198,9 +198,8 @@ TEST(FDv2ProtocolHandlerTest, UnknownKindInDeleteObjectIsSilentlySkipped) { auto* cs = std::get_if(&result); ASSERT_NE(cs, nullptr); - // Only the known kind (flag) should appear. - EXPECT_EQ(cs->changes.size(), 1u); - EXPECT_EQ(cs->changes[0].key, "my-flag"); + // All kinds pass through; filtering happens in the translator. + EXPECT_EQ(cs->changes.size(), 2u); } // ============================================================================ @@ -252,23 +251,6 @@ TEST(FDv2ProtocolHandlerTest, ErrorEventWithIdSetsServerId) { EXPECT_EQ(err->server_error->reason, "overloaded"); } -TEST(FDv2ProtocolHandlerTest, MalformedPutObjectReturnsJsonError) { - FDv2ProtocolHandler handler; - - handler.HandleEvent("server-intent", MakeServerIntent("xfer-full")); - - // 'object' field is missing required flag fields — deserialisation fails. - auto result = handler.HandleEvent( - "put-object", - boost::json::parse( - R"({"version":1,"kind":"flag","key":"f","object":{}})")); - - auto* err = std::get_if(&result); - ASSERT_NE(err, nullptr); - EXPECT_EQ(err->kind, FDv2ProtocolHandler::Error::Kind::kJsonError); - EXPECT_TRUE(err->json_error.has_value()); -} - // ============================================================================ // goodbye event → return Goodbye // ============================================================================ diff --git a/libs/server-sdk/src/CMakeLists.txt b/libs/server-sdk/src/CMakeLists.txt index 09f98465b..f8fac7fb3 100644 --- a/libs/server-sdk/src/CMakeLists.txt +++ b/libs/server-sdk/src/CMakeLists.txt @@ -49,6 +49,8 @@ target_sources(${LIBNAME} data_systems/background_sync/detail/payload_filter_validation/payload_filter_validation.cpp data_systems/background_sync/sources/polling/polling_data_source.hpp data_systems/background_sync/sources/polling/polling_data_source.cpp + data_systems/fdv2/fdv2_changeset_translation.hpp + data_systems/fdv2/fdv2_changeset_translation.cpp data_systems/fdv2/fdv2_polling_impl.hpp data_systems/fdv2/fdv2_polling_impl.cpp data_systems/fdv2/polling_initializer.hpp diff --git a/libs/server-sdk/src/data_components/memory_store/memory_store.cpp b/libs/server-sdk/src/data_components/memory_store/memory_store.cpp index c71acf08a..2eee8ff03 100644 --- a/libs/server-sdk/src/data_components/memory_store/memory_store.cpp +++ b/libs/server-sdk/src/data_components/memory_store/memory_store.cpp @@ -84,15 +84,16 @@ bool MemoryStore::RemoveSegment(std::string const& key) { return segments_.erase(key) == 1; } -void MemoryStore::Apply(data_model::FDv2ChangeSet changeSet) { +void MemoryStore::Apply( + data_model::ChangeSet changeSet) { std::lock_guard lock{data_mutex_}; switch (changeSet.type) { - case data_model::FDv2ChangeSet::Type::kNone: + case data_model::ChangeSetType::kNone: return; - case data_model::FDv2ChangeSet::Type::kPartial: + case data_model::ChangeSetType::kPartial: break; - case data_model::FDv2ChangeSet::Type::kFull: + case data_model::ChangeSetType::kFull: initialized_ = true; flags_.clear(); segments_.clear(); @@ -101,7 +102,7 @@ void MemoryStore::Apply(data_model::FDv2ChangeSet changeSet) { detail::unreachable(); } - for (auto& change : changeSet.changes) { + for (auto& change : changeSet.data) { if (std::holds_alternative(change.object)) { flags_[change.key] = std::make_shared( std::move(std::get(change.object))); diff --git a/libs/server-sdk/src/data_components/memory_store/memory_store.hpp b/libs/server-sdk/src/data_components/memory_store/memory_store.hpp index e9a067881..7bda17d3f 100644 --- a/libs/server-sdk/src/data_components/memory_store/memory_store.hpp +++ b/libs/server-sdk/src/data_components/memory_store/memory_store.hpp @@ -1,9 +1,10 @@ #pragma once #include "../../data_interfaces/destination/idestination.hpp" +#include "../../data_interfaces/item_change.hpp" #include "../../data_interfaces/store/istore.hpp" -#include +#include #include #include @@ -46,7 +47,7 @@ class MemoryStore final : public data_interfaces::IStore, bool RemoveSegment(std::string const& key); - void Apply(data_model::FDv2ChangeSet changeSet); + void Apply(data_model::ChangeSet changeSet); MemoryStore() = default; ~MemoryStore() override = default; diff --git a/libs/server-sdk/src/data_interfaces/item_change.hpp b/libs/server-sdk/src/data_interfaces/item_change.hpp new file mode 100644 index 000000000..1c2cc8292 --- /dev/null +++ b/libs/server-sdk/src/data_interfaces/item_change.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +namespace launchdarkly::server_side::data_interfaces { + +struct ItemChange { + std::string key; + std::variant, + data_model::ItemDescriptor> + object; +}; + +using ChangeSetData = std::vector; + +} // namespace launchdarkly::server_side::data_interfaces diff --git a/libs/server-sdk/src/data_interfaces/source/fdv2_source_result.hpp b/libs/server-sdk/src/data_interfaces/source/fdv2_source_result.hpp index 069fe5a38..a2a7589e3 100644 --- a/libs/server-sdk/src/data_interfaces/source/fdv2_source_result.hpp +++ b/libs/server-sdk/src/data_interfaces/source/fdv2_source_result.hpp @@ -1,6 +1,8 @@ #pragma once -#include +#include "../item_change.hpp" + +#include #include #include @@ -11,8 +13,6 @@ namespace launchdarkly::server_side::data_interfaces { /** * Result returned by IFDv2Initializer::Run and IFDv2Synchronizer::Next. - * - * Mirrors Java's FDv2SourceResult. */ struct FDv2SourceResult { using ErrorInfo = common::data_sources::DataSourceStatusErrorInfo; @@ -21,7 +21,7 @@ struct FDv2SourceResult { * A changeset was successfully received and is ready to apply. */ struct ChangeSet { - data_model::FDv2ChangeSet change_set; + data_model::ChangeSet change_set; /** If true, the server signaled that the client should fall back to * FDv1. */ bool fdv1_fallback; @@ -61,8 +61,12 @@ struct FDv2SourceResult { */ struct Timeout {}; - using Value = std::variant; + using Value = std::variant; Value value; }; diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv2_changeset_translation.cpp b/libs/server-sdk/src/data_systems/fdv2/fdv2_changeset_translation.cpp new file mode 100644 index 000000000..1847ba2b7 --- /dev/null +++ b/libs/server-sdk/src/data_systems/fdv2/fdv2_changeset_translation.cpp @@ -0,0 +1,107 @@ +#include "fdv2_changeset_translation.hpp" + +#include +#include +#include +#include +#include + +#include +#include + +namespace launchdarkly::server_side::data_systems { + +using data_interfaces::ChangeSetData; +using data_interfaces::ItemChange; +using data_model::ChangeSet; +using data_model::ChangeSetType; +using data_model::FDv2ChangeSet; + +static std::optional TranslateDelete( + data_model::FDv2Change const& change, + Logger const& logger) { + if (change.kind == "flag") { + return ItemChange{change.key, + data_model::ItemDescriptor{ + data_model::Tombstone{change.version}}}; + } + if (change.kind == "segment") { + return ItemChange{change.key, + data_model::ItemDescriptor{ + data_model::Tombstone{change.version}}}; + } + LD_LOG(logger, LogLevel::kWarn) << "FDv2: unknown kind '" << change.kind + << "' in delete-object, skipping"; + return std::nullopt; +} + +template +static bool TranslatePut(data_model::FDv2Change const& change, + char const* kind_name, + ChangeSetData* changes, + Logger const& logger) { + auto result = + boost::json::value_to, JsonError>>( + change.object); + if (!result) { + LD_LOG(logger, LogLevel::kError) + << "FDv2: could not deserialize " << kind_name << " '" << change.key + << "'"; + return false; + } + if (!result->has_value()) { + LD_LOG(logger, LogLevel::kWarn) + << "FDv2: " << kind_name << " '" << change.key + << "' object was null, skipping"; + return true; + } + changes->push_back(ItemChange{ + change.key, data_model::ItemDescriptor{std::move(**result)}}); + return true; +} + +std::optional> TranslateChangeSet( + FDv2ChangeSet const& change_set, + Logger const& logger) { + if (change_set.type == ChangeSetType::kNone) { + return ChangeSet{ + change_set.type, {}, change_set.selector}; + } + + ChangeSetData changes; + changes.reserve(change_set.changes.size()); + + for (auto const& change : change_set.changes) { + if (change.change_type == data_model::FDv2Change::ChangeType::kDelete) { + if (auto item = TranslateDelete(change, logger)) { + changes.push_back(std::move(*item)); + } + } else if (change.change_type == + data_model::FDv2Change::ChangeType::kPut) { + if (change.kind == "flag") { + if (!TranslatePut(change, "flag", &changes, + logger)) { + return std::nullopt; + } + } else if (change.kind == "segment") { + if (!TranslatePut(change, "segment", + &changes, logger)) { + return std::nullopt; + } + } else { + LD_LOG(logger, LogLevel::kWarn) + << "FDv2: unknown kind '" << change.kind + << "' in put-object, skipping"; + } + } else { + LD_LOG(logger, LogLevel::kWarn) + << "FDv2: unrecognized change type " + << static_cast(change.change_type) << ", skipping"; + } + } + + return ChangeSet{change_set.type, std::move(changes), + change_set.selector}; +} + +} // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv2_changeset_translation.hpp b/libs/server-sdk/src/data_systems/fdv2/fdv2_changeset_translation.hpp new file mode 100644 index 000000000..8e3aea9f1 --- /dev/null +++ b/libs/server-sdk/src/data_systems/fdv2/fdv2_changeset_translation.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include "../../data_interfaces/item_change.hpp" + +#include +#include +#include + +#include + +namespace launchdarkly::server_side::data_systems { + +/** + * Translates an FDv2ChangeSet into typed changes ready to apply to the store. + * + * Unknown kinds are warned and skipped. If any known kind fails to + * deserialize, the entire changeset is aborted and nullopt is returned. + */ +std::optional> +TranslateChangeSet(data_model::FDv2ChangeSet const& change_set, + Logger const& logger); + +} // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp index df6fda9af..54b9dc867 100644 --- a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp @@ -1,4 +1,5 @@ #include "fdv2_polling_impl.hpp" +#include "fdv2_changeset_translation.hpp" #include #include @@ -17,6 +18,8 @@ static char const* const kErrorMissingEvents = "FDv2 polling response missing 'events' array"; static char const* const kErrorIncompletePayload = "FDv2 polling response did not contain a complete payload"; +static char const* const kErrorTranslation = + "FDv2 polling response could not be translated"; using data_interfaces::FDv2SourceResult; using ErrorInfo = FDv2SourceResult::ErrorInfo; @@ -57,7 +60,8 @@ network::HttpRequest MakeFDv2PollRequest( static FDv2SourceResult ParseFDv2PollEvents( boost::json::array const& events, - FDv2ProtocolHandler* protocol_handler) { + FDv2ProtocolHandler* protocol_handler, + Logger const& logger) { for (auto const& event_val : events) { auto const* event_obj = event_val.if_object(); if (!event_obj) { @@ -79,9 +83,16 @@ static FDv2SourceResult ParseFDv2PollEvents( std::string_view{event_type_str->data(), event_type_str->size()}, *event_data_val); - if (auto* changeset = std::get_if(&result)) { + if (auto* change_set = + std::get_if(&result)) { + auto typed = TranslateChangeSet(*change_set, logger); + if (!typed) { + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorTranslation), + false}}; + } return FDv2SourceResult{ - FDv2SourceResult::ChangeSet{std::move(*changeset), false}}; + FDv2SourceResult::ChangeSet{std::move(*typed), false}}; } if (auto* goodbye = std::get_if(&result)) { return FDv2SourceResult{ @@ -110,7 +121,8 @@ static FDv2SourceResult ParseFDv2PollEvents( static FDv2SourceResult ParseFDv2PollResponse( std::string const& body, - FDv2ProtocolHandler* protocol_handler) { + FDv2ProtocolHandler* protocol_handler, + Logger const& logger) { boost::system::error_code ec; auto parsed = boost::json::parse(body, ec); if (ec) { @@ -136,7 +148,7 @@ static FDv2SourceResult ParseFDv2PollResponse( MakeError(ErrorKind::kInvalidData, 0, kErrorMissingEvents), false}}; } - return ParseFDv2PollEvents(*events_arr, protocol_handler); + return ParseFDv2PollEvents(*events_arr, protocol_handler, logger); } data_interfaces::FDv2SourceResult HandleFDv2PollResponse( @@ -155,8 +167,8 @@ data_interfaces::FDv2SourceResult HandleFDv2PollResponse( if (res.Status() == 304) { return FDv2SourceResult{FDv2SourceResult::ChangeSet{ - data_model::FDv2ChangeSet{ - data_model::FDv2ChangeSet::Type::kNone, {}, {}}, + data_model::ChangeSet{ + data_model::ChangeSetType::kNone, {}, data_model::Selector{}}, false}}; } @@ -169,7 +181,7 @@ data_interfaces::FDv2SourceResult HandleFDv2PollResponse( false}}; } - auto result = ParseFDv2PollResponse(*body, protocol_handler); + auto result = ParseFDv2PollResponse(*body, protocol_handler, logger); if (auto* interrupted = std::get_if(&result.value)) { if (interrupted->error.Kind() == ErrorKind::kErrorResponse) { diff --git a/libs/server-sdk/tests/fdv2_changeset_translation_test.cpp b/libs/server-sdk/tests/fdv2_changeset_translation_test.cpp new file mode 100644 index 000000000..bc8a086f2 --- /dev/null +++ b/libs/server-sdk/tests/fdv2_changeset_translation_test.cpp @@ -0,0 +1,227 @@ +#include + +#include + +#include +#include +#include + +#include + +using namespace launchdarkly; +using namespace launchdarkly::data_model; +using namespace launchdarkly::server_side::data_interfaces; +using namespace launchdarkly::server_side::data_systems; + +// Minimal valid flag JSON accepted by the Flag deserializer. +static char const* const kFlagJson = + R"({"key":"my-flag","on":true,"fallthrough":{"variation":0},)" + R"("variations":[true,false],"version":1})"; + +// Minimal valid segment JSON accepted by the Segment deserializer. +static char const* const kSegmentJson = + R"({"key":"my-seg","version":2,"rules":[],"included":[],"excluded":[]})"; + +static Logger MakeNullLogger() { + struct NullBackend : ILogBackend { + bool Enabled(LogLevel) noexcept override { return false; } + void Write(LogLevel, std::string) noexcept override {} + }; + return Logger{std::make_shared()}; +} + +// ============================================================================ +// kNone changeset +// ============================================================================ + +TEST(FDv2ChangeSetTranslationTest, NoneChangeSetProducesEmptyTypedChangeSet) { + auto logger = MakeNullLogger(); + + FDv2ChangeSet raw{ChangeSetType::kNone, {}, Selector{}}; + auto result = TranslateChangeSet(raw, logger); + + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->type, ChangeSetType::kNone); + EXPECT_TRUE(result->data.empty()); +} + +// ============================================================================ +// Known kinds — put +// ============================================================================ + +TEST(FDv2ChangeSetTranslationTest, PutFlagProducesTypedFlag) { + auto logger = MakeNullLogger(); + + FDv2ChangeSet raw{ChangeSetType::kFull, + {FDv2Change{FDv2Change::ChangeType::kPut, "flag", + "my-flag", 1, boost::json::parse(kFlagJson)}}, + Selector{}}; + auto result = TranslateChangeSet(raw, logger); + + ASSERT_TRUE(result.has_value()); + ASSERT_EQ(result->data.size(), 1u); + EXPECT_EQ(result->data[0].key, "my-flag"); + EXPECT_TRUE( + std::holds_alternative>(result->data[0].object)); +} + +TEST(FDv2ChangeSetTranslationTest, PutSegmentProducesTypedSegment) { + auto logger = MakeNullLogger(); + + FDv2ChangeSet raw{ + ChangeSetType::kFull, + {FDv2Change{FDv2Change::ChangeType::kPut, "segment", "my-seg", 2, + boost::json::parse(kSegmentJson)}}, + Selector{}}; + auto result = TranslateChangeSet(raw, logger); + + ASSERT_TRUE(result.has_value()); + ASSERT_EQ(result->data.size(), 1u); + EXPECT_EQ(result->data[0].key, "my-seg"); + EXPECT_TRUE(std::holds_alternative>( + result->data[0].object)); +} + +// ============================================================================ +// Known kinds — delete +// ============================================================================ + +TEST(FDv2ChangeSetTranslationTest, DeleteFlagProducesFlagTombstone) { + auto logger = MakeNullLogger(); + + FDv2ChangeSet raw{ + ChangeSetType::kPartial, + {FDv2Change{FDv2Change::ChangeType::kDelete, "flag", "my-flag", 5, {}}}, + Selector{}}; + auto result = TranslateChangeSet(raw, logger); + + ASSERT_TRUE(result.has_value()); + ASSERT_EQ(result->data.size(), 1u); + EXPECT_EQ(result->data[0].key, "my-flag"); + auto const* desc = + std::get_if>(&result->data[0].object); + ASSERT_NE(desc, nullptr); + EXPECT_EQ(desc->version, 5u); + EXPECT_FALSE(desc->item.has_value()); +} + +TEST(FDv2ChangeSetTranslationTest, DeleteSegmentProducesSegmentTombstone) { + auto logger = MakeNullLogger(); + + FDv2ChangeSet raw{ + ChangeSetType::kPartial, + {FDv2Change{ + FDv2Change::ChangeType::kDelete, "segment", "my-seg", 3, {}}}, + Selector{}}; + auto result = TranslateChangeSet(raw, logger); + + ASSERT_TRUE(result.has_value()); + ASSERT_EQ(result->data.size(), 1u); + auto const* desc = + std::get_if>(&result->data[0].object); + ASSERT_NE(desc, nullptr); + EXPECT_EQ(desc->version, 3u); + EXPECT_FALSE(desc->item.has_value()); +} + +// ============================================================================ +// Unknown kind — skipped +// ============================================================================ + +TEST(FDv2ChangeSetTranslationTest, UnknownKindInPutIsSkipped) { + auto logger = MakeNullLogger(); + + FDv2ChangeSet raw{ + ChangeSetType::kFull, + {FDv2Change{FDv2Change::ChangeType::kPut, "experiment", "exp-1", 1, + boost::json::parse(R"({"key":"exp-1","version":1})")}, + FDv2Change{FDv2Change::ChangeType::kPut, "flag", "my-flag", 1, + boost::json::parse(kFlagJson)}}, + Selector{}}; + auto result = TranslateChangeSet(raw, logger); + + ASSERT_TRUE(result.has_value()); + ASSERT_EQ(result->data.size(), 1u); + EXPECT_EQ(result->data[0].key, "my-flag"); +} + +TEST(FDv2ChangeSetTranslationTest, UnknownKindInDeleteIsSkipped) { + auto logger = MakeNullLogger(); + + FDv2ChangeSet raw{ + ChangeSetType::kFull, + {FDv2Change{ + FDv2Change::ChangeType::kDelete, "experiment", "exp-1", 1, {}}, + FDv2Change{FDv2Change::ChangeType::kDelete, "flag", "my-flag", 2, {}}}, + Selector{}}; + auto result = TranslateChangeSet(raw, logger); + + ASSERT_TRUE(result.has_value()); + ASSERT_EQ(result->data.size(), 1u); + EXPECT_EQ(result->data[0].key, "my-flag"); +} + +// ============================================================================ +// Null object on put — skipped +// ============================================================================ + +TEST(FDv2ChangeSetTranslationTest, NullObjectInPutFlagIsSkipped) { + auto logger = MakeNullLogger(); + + FDv2ChangeSet raw{ChangeSetType::kFull, + {FDv2Change{FDv2Change::ChangeType::kPut, "flag", + "my-flag", 1, boost::json::value(nullptr)}}, + Selector{}}; + auto result = TranslateChangeSet(raw, logger); + + ASSERT_TRUE(result.has_value()); + EXPECT_TRUE(result->data.empty()); +} + +// ============================================================================ +// Deserialization failure — abort +// ============================================================================ + +TEST(FDv2ChangeSetTranslationTest, MalformedFlagAbortsTranslation) { + auto logger = MakeNullLogger(); + + FDv2ChangeSet raw{ChangeSetType::kFull, + {FDv2Change{FDv2Change::ChangeType::kPut, "flag", + "bad-flag", 1, boost::json::parse(R"({})")}}, + Selector{}}; + auto result = TranslateChangeSet(raw, logger); + + EXPECT_FALSE(result.has_value()); +} + +TEST(FDv2ChangeSetTranslationTest, MalformedSegmentAbortsTranslation) { + auto logger = MakeNullLogger(); + + // A non-empty object missing required fields triggers a schema failure + // (the deserializer treats an empty object as null/skip, not an error). + FDv2ChangeSet raw{ + ChangeSetType::kFull, + {FDv2Change{FDv2Change::ChangeType::kPut, "segment", "bad-seg", 1, + boost::json::parse(R"({"key":"bad-seg"})")}}, + Selector{}}; + auto result = TranslateChangeSet(raw, logger); + + EXPECT_FALSE(result.has_value()); +} + +// ============================================================================ +// Selector is preserved +// ============================================================================ + +TEST(FDv2ChangeSetTranslationTest, SelectorIsPreserved) { + auto logger = MakeNullLogger(); + + FDv2ChangeSet raw{ + ChangeSetType::kFull, {}, Selector{Selector::State{7, "state-abc"}}}; + auto result = TranslateChangeSet(raw, logger); + + ASSERT_TRUE(result.has_value()); + ASSERT_TRUE(result->selector.value.has_value()); + EXPECT_EQ(result->selector.value->state, "state-abc"); + EXPECT_EQ(result->selector.value->version, 7); +} diff --git a/libs/server-sdk/tests/memory_store_apply_test.cpp b/libs/server-sdk/tests/memory_store_apply_test.cpp index 003285c53..e853f9808 100644 --- a/libs/server-sdk/tests/memory_store_apply_test.cpp +++ b/libs/server-sdk/tests/memory_store_apply_test.cpp @@ -1,10 +1,14 @@ #include #include +#include + +#include #include using namespace launchdarkly::data_model; using namespace launchdarkly::server_side::data_components; +using namespace launchdarkly::server_side::data_interfaces; // --------------------------------------------------------------------------- // kNone tests @@ -27,7 +31,7 @@ TEST(MemoryStoreApplyTest, ApplyNone_IsNoOp) { {"segA", SegmentDescriptor(seg_a)}}, }); - store.Apply(FDv2ChangeSet{FDv2ChangeSet::Type::kNone, {}, Selector{}}); + store.Apply(ChangeSet{ChangeSetType::kNone, {}, Selector{}}); auto fetched_flag = store.GetFlag("flagA"); ASSERT_TRUE(fetched_flag); @@ -39,7 +43,7 @@ TEST(MemoryStoreApplyTest, ApplyNone_IsNoOp) { TEST(MemoryStoreApplyTest, ApplyNone_DoesNotInitialize) { MemoryStore store; - store.Apply(FDv2ChangeSet{FDv2ChangeSet::Type::kNone, {}, Selector{}}); + store.Apply(ChangeSet{ChangeSetType::kNone, {}, Selector{}}); EXPECT_FALSE(store.Initialized()); } @@ -50,7 +54,7 @@ TEST(MemoryStoreApplyTest, ApplyNone_DoesNotInitialize) { TEST(MemoryStoreApplyTest, ApplyFull_SetsInitialized) { MemoryStore store; ASSERT_FALSE(store.Initialized()); - store.Apply(FDv2ChangeSet{FDv2ChangeSet::Type::kFull, {}, Selector{}}); + store.Apply(ChangeSet{ChangeSetType::kFull, {}, Selector{}}); EXPECT_TRUE(store.Initialized()); } @@ -64,10 +68,10 @@ TEST(MemoryStoreApplyTest, ApplyFull_StoresItems) { seg_a.version = 1; seg_a.key = "segA"; - store.Apply(FDv2ChangeSet{ - FDv2ChangeSet::Type::kFull, - std::vector{{"flagA", FlagDescriptor(flag_a)}, - {"segA", SegmentDescriptor(seg_a)}}, + store.Apply(ChangeSet{ + ChangeSetType::kFull, + ChangeSetData{ItemChange{"flagA", FlagDescriptor(flag_a)}, + ItemChange{"segA", SegmentDescriptor(seg_a)}}, Selector{}, }); @@ -114,10 +118,10 @@ TEST(MemoryStoreApplyTest, ApplyFull_ClearsExistingItems) { seg_b.version = 1; seg_b.key = "segB"; - store.Apply(FDv2ChangeSet{ - FDv2ChangeSet::Type::kFull, - std::vector{{"flagC", FlagDescriptor(flag_c)}, - {"segB", SegmentDescriptor(seg_b)}}, + store.Apply(ChangeSet{ + ChangeSetType::kFull, + ChangeSetData{ItemChange{"flagC", FlagDescriptor(flag_c)}, + ItemChange{"segB", SegmentDescriptor(seg_b)}}, Selector{}, }); @@ -157,10 +161,10 @@ TEST(MemoryStoreApplyTest, ApplyPartial_AppliesItems) { seg_a_new.version = 6; seg_a_new.key = "segA"; - store.Apply(FDv2ChangeSet{ - FDv2ChangeSet::Type::kPartial, - std::vector{{"flagA", FlagDescriptor(flag_a_new)}, - {"segA", SegmentDescriptor(seg_a_new)}}, + store.Apply(ChangeSet{ + ChangeSetType::kPartial, + ChangeSetData{ItemChange{"flagA", FlagDescriptor(flag_a_new)}, + ItemChange{"segA", SegmentDescriptor(seg_a_new)}}, Selector{}, }); @@ -205,10 +209,10 @@ TEST(MemoryStoreApplyTest, ApplyPartial_PreservesUnchangedItems) { seg_b_new.version = 2; seg_b_new.key = "segB"; - store.Apply(FDv2ChangeSet{ - FDv2ChangeSet::Type::kPartial, - std::vector{{"flagB", FlagDescriptor(flag_b_new)}, - {"segB", SegmentDescriptor(seg_b_new)}}, + store.Apply(ChangeSet{ + ChangeSetType::kPartial, + ChangeSetData{ItemChange{"flagB", FlagDescriptor(flag_b_new)}, + ItemChange{"segB", SegmentDescriptor(seg_b_new)}}, Selector{}, });