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 679fd0015..83816623f 100644 --- a/contract-tests/data-model/include/data_model/data_model.hpp +++ b/contract-tests/data-model/include/data_model/data_model.hpp @@ -150,6 +150,21 @@ struct ConfigWrapper { NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigWrapper, name, version); +struct ConfigBigSegmentsParams { + std::string callbackUri; + std::optional userCacheSize; + std::optional userCacheTimeMs; + std::optional statusPollIntervalMs; + std::optional staleAfterMs; +}; + +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigBigSegmentsParams, + callbackUri, + userCacheSize, + userCacheTimeMs, + statusPollIntervalMs, + staleAfterMs); + struct ConfigParams { std::string credential; std::optional startWaitTimeMs; @@ -164,6 +179,7 @@ struct ConfigParams { std::optional proxy; std::optional hooks; std::optional wrapper; + std::optional bigSegments; }; NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigParams, @@ -179,7 +195,8 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigParams, tls, proxy, hooks, - wrapper); + wrapper, + bigSegments); struct ContextSingleParams { std::optional kind; @@ -328,6 +345,15 @@ struct IdentifyEventParams { NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(IdentifyEventParams, context); +struct BigSegmentStoreStatusResponse { + bool available; + bool stale; +}; + +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(BigSegmentStoreStatusResponse, + available, + stale); + enum class Command { Unknown = -1, EvaluateFlag, @@ -336,7 +362,8 @@ enum class Command { CustomEvent, FlushEvents, ContextBuild, - ContextConvert + ContextConvert, + GetBigSegmentStoreStatus }; NLOHMANN_JSON_SERIALIZE_ENUM(Command, @@ -347,7 +374,9 @@ NLOHMANN_JSON_SERIALIZE_ENUM(Command, {Command::CustomEvent, "customEvent"}, {Command::FlushEvents, "flushEvents"}, {Command::ContextBuild, "contextBuild"}, - {Command::ContextConvert, "contextConvert"}}); + {Command::ContextConvert, "contextConvert"}, + {Command::GetBigSegmentStoreStatus, + "getBigSegmentStoreStatus"}}); struct CommandParams { Command command; diff --git a/contract-tests/server-contract-tests/CMakeLists.txt b/contract-tests/server-contract-tests/CMakeLists.txt index 9455cd27e..000dea286 100644 --- a/contract-tests/server-contract-tests/CMakeLists.txt +++ b/contract-tests/server-contract-tests/CMakeLists.txt @@ -17,6 +17,7 @@ add_executable(server-tests src/entity_manager.cpp src/client_entity.cpp src/contract_test_hook.cpp + src/contract_test_big_segment_store.cpp ) target_link_libraries(server-tests PRIVATE diff --git a/contract-tests/server-contract-tests/include/client_entity.hpp b/contract-tests/server-contract-tests/include/client_entity.hpp index 39b4a3be3..f62c7f2a6 100644 --- a/contract-tests/server-contract-tests/include/client_entity.hpp +++ b/contract-tests/server-contract-tests/include/client_entity.hpp @@ -27,6 +27,8 @@ class ClientEntity { tl::expected Custom(CustomEventParams const&); + tl::expected GetBigSegmentStoreStatus(); + std::unique_ptr client_; }; diff --git a/contract-tests/server-contract-tests/include/contract_test_big_segment_store.hpp b/contract-tests/server-contract-tests/include/contract_test_big_segment_store.hpp new file mode 100644 index 000000000..5c1d3a58f --- /dev/null +++ b/contract-tests/server-contract-tests/include/contract_test_big_segment_store.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include + +#include + +#include + +#include + +/** + * A Big Segment store that delegates membership and metadata lookups to the + * contract-test harness over HTTP, mirroring how ContractTestHook posts to the + * harness. Each lookup is a synchronous request/response (unlike the hook's + * fire-and-forget) because IBigSegmentStore must return the result. + * + * Thread-safe: holds only the immutable callback URI and performs each request + * on its own local io_context. + */ +class ContractTestBigSegmentStore + : public launchdarkly::server_side::integrations::IBigSegmentStore { + public: + explicit ContractTestBigSegmentStore(std::string callback_uri); + + [[nodiscard]] GetMembershipResult GetMembership( + std::string const& context_hash) const noexcept override; + + [[nodiscard]] GetMetadataResult GetMetadata() const noexcept override; + + private: + // Synchronous POST of `body` to ``. Returns the parsed + // JSON response on HTTP 200, or an error string (non-200 body, transport + // failure, or parse failure). + [[nodiscard]] tl::expected Post( + std::string const& path, + nlohmann::json const& body) const noexcept; + + std::string const callback_uri_; +}; diff --git a/contract-tests/server-contract-tests/src/client_entity.cpp b/contract-tests/server-contract-tests/src/client_entity.cpp index 4fc220307..9cd5c2f86 100644 --- a/contract-tests/server-contract-tests/src/client_entity.cpp +++ b/contract-tests/server-contract-tests/src/client_entity.cpp @@ -4,10 +4,10 @@ #include #include -#include -#include #include #include +#include +#include #include #include @@ -172,6 +172,15 @@ tl::expected ClientEntity::Custom( return nlohmann::json{}; } +tl::expected +ClientEntity::GetBigSegmentStoreStatus() { + auto status = client_->BigSegmentStoreStatus().Status(); + BigSegmentStoreStatusResponse resp{}; + resp.available = status.IsAvailable(); + resp.stale = status.IsStale(); + return resp; +} + tl::expected ClientEntity::EvaluateAll( EvaluateAllFlagParams const& params) { EvaluateAllFlagsResponse resp{}; @@ -388,6 +397,8 @@ tl::expected ClientEntity::Command( return tl::make_unexpected("contextConvert params must be set"); } return ContextConvert(*params.contextConvert); + case Command::GetBigSegmentStoreStatus: + return GetBigSegmentStoreStatus(); } return tl::make_unexpected("unrecognized command"); } diff --git a/contract-tests/server-contract-tests/src/contract_test_big_segment_store.cpp b/contract-tests/server-contract-tests/src/contract_test_big_segment_store.cpp new file mode 100644 index 000000000..13dd6181e --- /dev/null +++ b/contract-tests/server-contract-tests/src/contract_test_big_segment_store.cpp @@ -0,0 +1,121 @@ +#include "contract_test_big_segment_store.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::integrations; + +ContractTestBigSegmentStore::ContractTestBigSegmentStore( + std::string callback_uri) + : callback_uri_(std::move(callback_uri)) {} + +tl::expected ContractTestBigSegmentStore::Post( + std::string const& path, + nlohmann::json const& body) const noexcept { + try { + auto uri_result = boost::urls::parse_uri(callback_uri_); + if (!uri_result) { + return tl::make_unexpected("invalid callback URI: " + + callback_uri_); + } + auto uri = *uri_result; + std::string const host(uri.host()); + std::string const port = + uri.has_port() ? std::string(uri.port()) : "80"; + std::string base(uri.path()); + // The callback URI carries a base path; the sub-path (/getMembership, + // /getMetadata) is appended. Drop any trailing slash to avoid "//". + if (!base.empty() && base.back() == '/') { + base.pop_back(); + } + std::string const target = base + path; + + net::io_context ioc; + tcp::resolver resolver(ioc); + beast::tcp_stream stream(ioc); + stream.connect(resolver.resolve(host, port)); + + http::request req{http::verb::post, target, 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.dump(); + req.prepare_payload(); + http::write(stream, req); + + beast::flat_buffer buffer; + http::response res; + http::read(stream, buffer, res); + + beast::error_code ec; + stream.socket().shutdown(tcp::socket::shutdown_both, ec); + + if (res.result_int() != 200) { + return tl::make_unexpected(res.body()); + } + return nlohmann::json::parse(res.body()); + } catch (std::exception const& e) { + return tl::make_unexpected(e.what()); + } +} + +ContractTestBigSegmentStore::GetMembershipResult +ContractTestBigSegmentStore::GetMembership( + std::string const& context_hash) const noexcept { + auto result = Post("/getMembership", {{"contextHash", context_hash}}); + if (!result) { + return tl::make_unexpected(result.error()); + } + try { + std::vector included; + std::vector excluded; + auto const it = result->find("values"); + if (it != result->end() && it->is_object()) { + for (auto const& [segment_ref, member] : it->items()) { + (member.get() ? included : excluded) + .push_back(segment_ref); + } + } + return Membership::FromSegmentRefs(included, excluded); + } catch (std::exception const& e) { + return tl::make_unexpected(e.what()); + } +} + +ContractTestBigSegmentStore::GetMetadataResult +ContractTestBigSegmentStore::GetMetadata() const noexcept { + auto result = Post("/getMetadata", nlohmann::json::object()); + if (!result) { + return tl::make_unexpected(result.error()); + } + try { + auto const it = result->find("lastUpToDate"); + // Absent or zero means the store has never been synchronized. + if (it == result->end() || it->is_null()) { + return std::optional{std::nullopt}; + } + auto const millis = it->get(); + if (millis == 0) { + return std::optional{std::nullopt}; + } + return std::optional{ + StoreMetadata{std::chrono::system_clock::time_point{ + std::chrono::milliseconds{millis}}}}; + } catch (std::exception const& e) { + return tl::make_unexpected(e.what()); + } +} diff --git a/contract-tests/server-contract-tests/src/entity_manager.cpp b/contract-tests/server-contract-tests/src/entity_manager.cpp index 085df1c2d..f839dd78c 100644 --- a/contract-tests/server-contract-tests/src/entity_manager.cpp +++ b/contract-tests/server-contract-tests/src/entity_manager.cpp @@ -1,8 +1,10 @@ #include "entity_manager.hpp" +#include "contract_test_big_segment_store.hpp" #include "contract_test_hook.hpp" #include #include +#include #include #include @@ -140,7 +142,8 @@ std::optional EntityManager::create(ConfigParams const& in) { if (in.hooks) { for (auto const& hook_config : in.hooks->hooks) { - auto hook = std::make_shared(executor_, hook_config); + auto hook = + std::make_shared(executor_, hook_config); config_builder.Hooks(hook); } } @@ -154,6 +157,28 @@ std::optional EntityManager::create(ConfigParams const& in) { } } + if (in.bigSegments) { + auto store = std::make_shared( + in.bigSegments->callbackUri); + auto big_segments = config::builders::BigSegmentsBuilder(store); + if (in.bigSegments->userCacheSize) { + big_segments.ContextCacheSize(*in.bigSegments->userCacheSize); + } + if (in.bigSegments->userCacheTimeMs) { + big_segments.ContextCacheTime( + std::chrono::milliseconds(*in.bigSegments->userCacheTimeMs)); + } + if (in.bigSegments->statusPollIntervalMs) { + big_segments.StatusPollInterval(std::chrono::milliseconds( + *in.bigSegments->statusPollIntervalMs)); + } + if (in.bigSegments->staleAfterMs) { + big_segments.StaleAfter( + std::chrono::milliseconds(*in.bigSegments->staleAfterMs)); + } + config_builder.BigSegments(std::move(big_segments)); + } + 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 d81d12081..21ba09cf0 100644 --- a/contract-tests/server-contract-tests/src/main.cpp +++ b/contract-tests/server-contract-tests/src/main.cpp @@ -52,6 +52,7 @@ int main(int argc, char* argv[]) { srv.add_capability("track-hooks"); srv.add_capability("wrapper"); srv.add_capability("instance-id"); + srv.add_capability("big-segments"); net::signal_set signals{ioc, SIGINT, SIGTERM};