Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions contract-tests/data-model/include/data_model/data_model.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,21 @@ struct ConfigWrapper {

NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigWrapper, name, version);

struct ConfigBigSegmentsParams {
std::string callbackUri;
std::optional<uint32_t> userCacheSize;
std::optional<uint64_t> userCacheTimeMs;
std::optional<uint64_t> statusPollIntervalMs;
std::optional<uint64_t> staleAfterMs;
};

NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigBigSegmentsParams,
callbackUri,
userCacheSize,
userCacheTimeMs,
statusPollIntervalMs,
staleAfterMs);

struct ConfigParams {
std::string credential;
std::optional<uint32_t> startWaitTimeMs;
Expand All @@ -164,6 +179,7 @@ struct ConfigParams {
std::optional<ConfigProxyParams> proxy;
std::optional<ConfigHooksParams> hooks;
std::optional<ConfigWrapper> wrapper;
std::optional<ConfigBigSegmentsParams> bigSegments;
};

NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigParams,
Expand All @@ -179,7 +195,8 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigParams,
tls,
proxy,
hooks,
wrapper);
wrapper,
bigSegments);

struct ContextSingleParams {
std::optional<std::string> kind;
Expand Down Expand Up @@ -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,
Expand All @@ -336,7 +362,8 @@ enum class Command {
CustomEvent,
FlushEvents,
ContextBuild,
ContextConvert
ContextConvert,
GetBigSegmentStoreStatus
};

NLOHMANN_JSON_SERIALIZE_ENUM(Command,
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions contract-tests/server-contract-tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ class ClientEntity {

tl::expected<nlohmann::json, std::string> Custom(CustomEventParams const&);

tl::expected<nlohmann::json, std::string> GetBigSegmentStoreStatus();

std::unique_ptr<launchdarkly::server_side::Client> client_;
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#pragma once

#include <launchdarkly/server_side/integrations/big_segments/ibig_segment_store.hpp>

#include <nlohmann/json.hpp>

#include <tl/expected.hpp>

#include <string>

/**
* 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 `<callback_uri><path>`. Returns the parsed
// JSON response on HTTP 200, or an error string (non-200 body, transport
// failure, or parse failure).
[[nodiscard]] tl::expected<nlohmann::json, std::string> Post(
std::string const& path,
nlohmann::json const& body) const noexcept;

std::string const callback_uri_;
};
15 changes: 13 additions & 2 deletions contract-tests/server-contract-tests/src/client_entity.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
#include <boost/json.hpp>

#include <launchdarkly/context_builder.hpp>
#include <launchdarkly/serialization/json_context.hpp>
#include <launchdarkly/serialization/json_evaluation_reason.hpp>
#include <launchdarkly/detail/serialization/json_primitives.hpp>
#include <launchdarkly/detail/serialization/json_value.hpp>
#include <launchdarkly/serialization/json_context.hpp>
#include <launchdarkly/serialization/json_evaluation_reason.hpp>
#include <launchdarkly/server_side/serialization/json_all_flags_state.hpp>
#include <launchdarkly/value.hpp>

Expand Down Expand Up @@ -172,6 +172,15 @@ tl::expected<nlohmann::json, std::string> ClientEntity::Custom(
return nlohmann::json{};
}

tl::expected<nlohmann::json, std::string>
ClientEntity::GetBigSegmentStoreStatus() {
auto status = client_->BigSegmentStoreStatus().Status();
BigSegmentStoreStatusResponse resp{};
resp.available = status.IsAvailable();
resp.stale = status.IsStale();
return resp;
}

tl::expected<nlohmann::json, std::string> ClientEntity::EvaluateAll(
EvaluateAllFlagParams const& params) {
EvaluateAllFlagsResponse resp{};
Expand Down Expand Up @@ -388,6 +397,8 @@ tl::expected<nlohmann::json, std::string> 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");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
#include "contract_test_big_segment_store.hpp"

#include <boost/asio/connect.hpp>
#include <boost/asio/io_context.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/beast/core.hpp>
#include <boost/beast/http.hpp>
#include <boost/url.hpp>

#include <chrono>
#include <optional>
#include <utility>
#include <vector>

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<nlohmann::json, std::string> 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<http::string_body> 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<http::string_body> 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<std::string> included;
std::vector<std::string> excluded;
auto const it = result->find("values");
if (it != result->end() && it->is_object()) {
for (auto const& [segment_ref, member] : it->items()) {
(member.get<bool>() ? 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<StoreMetadata>{std::nullopt};
}
auto const millis = it->get<std::uint64_t>();
if (millis == 0) {
return std::optional<StoreMetadata>{std::nullopt};
}
return std::optional<StoreMetadata>{
StoreMetadata{std::chrono::system_clock::time_point{
std::chrono::milliseconds{millis}}}};
} catch (std::exception const& e) {
return tl::make_unexpected(e.what());
}
}
27 changes: 26 additions & 1 deletion contract-tests/server-contract-tests/src/entity_manager.cpp
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
#include "entity_manager.hpp"
#include "contract_test_big_segment_store.hpp"
#include "contract_test_hook.hpp"

#include <launchdarkly/context_builder.hpp>
#include <launchdarkly/serialization/json_context.hpp>
#include <launchdarkly/server_side/config/builders/big_segments_builder.hpp>
#include <launchdarkly/server_side/config/config_builder.hpp>

#include <boost/json.hpp>
Expand Down Expand Up @@ -140,7 +142,8 @@ std::optional<std::string> EntityManager::create(ConfigParams const& in) {

if (in.hooks) {
for (auto const& hook_config : in.hooks->hooks) {
auto hook = std::make_shared<ContractTestHook>(executor_, hook_config);
auto hook =
std::make_shared<ContractTestHook>(executor_, hook_config);
config_builder.Hooks(hook);
}
}
Expand All @@ -154,6 +157,28 @@ std::optional<std::string> EntityManager::create(ConfigParams const& in) {
}
}

if (in.bigSegments) {
auto store = std::make_shared<ContractTestBigSegmentStore>(
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)
Expand Down
1 change: 1 addition & 0 deletions contract-tests/server-contract-tests/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down
Loading