diff --git a/.clang-tidy b/.clang-tidy index 418e839a5..154cb4e32 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -1,5 +1,5 @@ --- CheckOptions: - { key: readability-identifier-length.IgnoredParameterNames, value: 'i|j|k|c|os|it' } - - { key: readability-identifier-length.IgnoredVariableNames, value: 'ec|id' } + - { key: readability-identifier-length.IgnoredVariableNames, value: 'ec|id|it' } - { key: readability-identifier-length.IgnoredLoopCounterNames, value: 'i|j|k|c|os|it' } diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index fcbea06d5..173969727 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -6,7 +6,7 @@ on: paths-ignore: - '**.md' #Do not need to run CI for markdown changes. pull_request: - branches: [ main ] + branches: [ main, server-side ] paths-ignore: - '**.md' @@ -16,12 +16,12 @@ jobs: env: # Port the test service (implemented in this repo) should bind to. TEST_SERVICE_PORT: 8123 - TEST_SERVICE_BINARY: ./build/contract-tests/sdk-contract-tests/sdk-tests + TEST_SERVICE_BINARY: ./build/contract-tests/client-contract-tests/client-tests steps: - uses: actions/checkout@v3 - uses: ./.github/actions/ci with: - cmake_target: sdk-tests + cmake_target: client-tests run_tests: false - name: 'Launch test service as background task' run: $TEST_SERVICE_BINARY $TEST_SERVICE_PORT 2>&1 & @@ -29,15 +29,15 @@ jobs: with: # Inform the test harness of test service's port. test_service_port: ${{ env.TEST_SERVICE_PORT }} - extra_params: '-skip-from ./contract-tests/sdk-contract-tests/test-suppressions.txt' - build-test: + extra_params: '-skip-from ./contract-tests/client-contract-tests/test-suppressions.txt' + build-test-client: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v3 - uses: ./.github/actions/ci with: cmake_target: launchdarkly-cpp-client - build-test-mac: + build-test-client-mac: runs-on: macos-12 steps: - run: | @@ -52,7 +52,7 @@ jobs: with: cmake_target: launchdarkly-cpp-client platform_version: 12 - build-test-windows: + build-test-client-windows: runs-on: windows-2022 steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/common.yml b/.github/workflows/common.yml index 9c1ce1752..438bcdc6c 100644 --- a/.github/workflows/common.yml +++ b/.github/workflows/common.yml @@ -6,12 +6,12 @@ on: paths-ignore: - '**.md' #Do not need to run CI for markdown changes. pull_request: - branches: [ main ] + branches: [ main, server-side ] paths-ignore: - '**.md' jobs: - build-test: + build-test-common: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/cpp-linter.yml b/.github/workflows/cpp-linter.yml index 55a3f4d9e..4a969f4be 100644 --- a/.github/workflows/cpp-linter.yml +++ b/.github/workflows/cpp-linter.yml @@ -7,7 +7,7 @@ on: push: branches: [ "main" ] pull_request: - branches: [ "main" ] + branches: [ "main", server-side ] jobs: cpp-linter: diff --git a/.github/workflows/internal.yml b/.github/workflows/internal.yml index 0986b723d..d116de8bb 100644 --- a/.github/workflows/internal.yml +++ b/.github/workflows/internal.yml @@ -6,12 +6,12 @@ on: paths-ignore: - '**.md' #Do not need to run CI for markdown changes. pull_request: - branches: [ main ] + branches: [ main, server-side ] paths-ignore: - '**.md' jobs: - build-test: + build-test-internal: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml new file mode 100644 index 000000000..1ae481daf --- /dev/null +++ b/.github/workflows/server.yml @@ -0,0 +1,68 @@ +name: libs/server-sdk + +on: + push: + branches: [ main ] + paths-ignore: + - '**.md' #Do not need to run CI for markdown changes. + pull_request: + branches: [ main, server-side ] + paths-ignore: + - '**.md' + +jobs: + contract-tests: + runs-on: ubuntu-22.04 + env: + # Port the test service (implemented in this repo) should bind to. + TEST_SERVICE_PORT: 8123 + TEST_SERVICE_BINARY: ./build/contract-tests/server-contract-tests/server-tests + steps: + - uses: actions/checkout@v3 + - uses: ./.github/actions/ci + with: + cmake_target: server-tests + run_tests: false + - name: 'Launch test service as background task' + run: $TEST_SERVICE_BINARY $TEST_SERVICE_PORT 2>&1 & + - uses: ./.github/actions/contract-tests + 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' + build-test-server: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3 + - uses: ./.github/actions/ci + with: + cmake_target: launchdarkly-cpp-server + build-test-server-mac: + runs-on: macos-12 + steps: + - run: | + brew link --overwrite openssl@1.1 + echo "OPENSSL_ROOT_DIR=$(brew --prefix openssl@1.1)" >> "$GITHUB_ENV" + # For debugging + echo "OPENSSL_ROOT_DIR=$(brew --prefix openssl@1.1)" + - uses: actions/checkout@v3 + - uses: ./.github/actions/ci + env: + OPENSSL_ROOT_DIR: ${{ env.OPENSSL_ROOT_DIR }} + with: + cmake_target: launchdarkly-cpp-server + platform_version: 12 + build-test-server-windows: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v3 + - uses: ilammy/msvc-dev-cmd@v1 + - uses: ./.github/actions/ci + env: + OPENSSL_ROOT_DIR: 'C:\Program Files\OpenSSL' + BOOST_LIBRARY_DIR: 'C:\local\boost_1_81_0\lib64-msvc-14.3' + BOOST_LIBRARYDIR: 'C:\local\boost_1_81_0\lib64-msvc-14.3' + with: + cmake_target: launchdarkly-cpp-server + platform_version: 2022 + toolset: msvc diff --git a/.github/workflows/sse.yml b/.github/workflows/sse.yml index b132886eb..9452f6389 100644 --- a/.github/workflows/sse.yml +++ b/.github/workflows/sse.yml @@ -11,7 +11,7 @@ on: - '**.md' jobs: - build-test: + build-test-sse: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v3 diff --git a/CMakeLists.txt b/CMakeLists.txt index 0f1575442..cec73e149 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -73,6 +73,7 @@ find_package(Boost 1.81 REQUIRED COMPONENTS json url coroutine) message(STATUS "LaunchDarkly: using Boost v${Boost_VERSION}") add_subdirectory(libs/client-sdk) +add_subdirectory(libs/server-sdk) set(ORIGINAL_BUILD_SHARED_LIBS "${BUILD_SHARED_LIBS}") set(BUILD_SHARED_LIBS OFF) diff --git a/architecture/event_processor.md b/architecture/event_processor.md new file mode 100644 index 000000000..022ea59ec --- /dev/null +++ b/architecture/event_processor.md @@ -0,0 +1,145 @@ +# Analytic Event Processor + +The Event Processor is responsible for consuming, batching, and delivering events generated +by the server and client-side LaunchDarkly SDKs. + +```mermaid +classDiagram + IEventProcessor <|-- NullEventProcessor + IEventProcessor <|-- AsioEventProcessor + + AsioEventProcessor *-- LRUCache + AsioEventProcessor *-- Outbox + AsioEventProcessor *-- WorkerPool + AsioEventProcessor *-- Summarizer + + RequestWorker *-- EventBatch + WorkerPool *-- "5" RequestWorker + + TrackEvent -- TrackEventParams: (alias) + InputEvent *-- IdentifyEventParams + InputEvent *-- FeatureEventParams + InputEvent *-- TrackEventParams + + + OutputEvent *-- IndexEvent + OutputEvent *-- FeatureEvent + OutputEvent *-- DebugEvent + OutputEvent *-- IdentifyEvent + OutputEvent *-- TrackEvent + + EventBatch --> Outbox: Pulls individual events from.. + EventBatch --> Summarizer: Pulls summary events from.. + + IEventProcessor --> InputEvent + Outbox --> OutputEvent + + Summarizer --> FeatureEventParams + + + class IEventProcessor { + <> + +SendAsync(InputEvent event) void + +FlushAsync() void + +ShutdownAsync() void + } + + class NullEventProcessor { + + } + + class AsioEventProcessor { + + } + + class EventBatch { + +const Count() size_t + +const Request() network:: HttpRequest + +const Target() std:: string + } + + class LRUCache { + +Notice(std:: string value) bool + +const Size() size_t + +Clear() void + } + + class Outbox { + +PushDiscardingOverflow(std:: vector~OutputEvent~ events) bool + +Consume() std:: vector~OutputEvent~ + +const Empty() bool + } + + class RequestWorker { + +const Available() bool + +AsyncDeliver(EventBatch, delivery_callback) + } + + class WorkerPool { + +Get(worker_callback) void + } + + class Summarizer { + +Update(FeatureEventParams) void + +Finish() + +const StartTime() Time + +const EndTime() Time + } + +%% note: the 'namespace' feature isn't supported on Github yet +%% namespace events { + class InputEvent { + +std:: variant + } + + + class OutputEvent { + +std:: variant + } + + class FeatureEventParams { + + } + + class IdentifyEventParams { + + } + + class TrackEventParams { + + } + + class FeatureEvent { + + } + + class DebugEvent { + + } + + class IdentifyEvent { + + } + + class IndexEvent { + + } + + class TrackEvent { + + } + +%% } +``` + +### Notes + +SDKs may be configured to disable events, so `NullEventProcessor` is made available. This component accepts +events generated +by the SDK and discards them. + +If events are enabled, SDKs use the `AsioEventProcessor` implementation, which is an asynchronous processor +utilizing `boost::asio`. + +Most event definitions are shared between the server and client-side SDKs. Unique to the server-side SDK +is `IndexEvent`. diff --git a/architecture/server_data_source_arch.md b/architecture/server_data_source_arch.md new file mode 100644 index 000000000..254e8675f --- /dev/null +++ b/architecture/server_data_source_arch.md @@ -0,0 +1,118 @@ +# Server Data Source Architecture + +```mermaid +classDiagram + direction LR + Client --* IDataSource + Client --* IDataSourceStatusProvider + + IDataSource <|-- PollingDataSource + IDataSource <|-- StreamingDataSource + + PollingDataSource --* DataSourceStatusManager + StreamingDataSource --* DataSourceStatusManager + + PollingDataSource --* DataSourceEventHandler + StreamingDataSource --* DataSourceEventHandler + + DataSourceEventHandler --> DataSourceStatusManager + + IDataSourceStatusProvider <-- DataSourceStatusManager + + DataSourceStatusManager --> DataSourceState + DataSourceStatusManager --> ErrorInfo + DataSourceStatusManager --> DataSourceStatus + + DataSourceStatus --* DataSourceState + DataSourceStatus --* ErrorInfo + DataSourceEventHandler --> DataSourceEventHandler_MessageStatus + + + note for IDataSource "Common for Client/Server" + + class IDataSource { + <> + +Start() void + +ShutdownAsync(std::function~void()~ ) void + } + + note for IDataSourceStatusProvider "Different for client/server" + + class IDataSourceStatusProvider { + <> + +const Status() DataSourceStatus + +OnDataSourceStatusChange(std::function<void(DataSourceStatus)> ) std::unique_ptr~IConnection~ + +OnDataSourceStatusChangeEx(std::function<bool(DataSourceStatus)> ) void + } + + note for DataSourceState "Different for client/server" + + class DataSourceState { + <> + Initializing + Valid + Interrupted + Off + } + + note for ErrorInfo_ErrorKind "Common for client/server" + + class ErrorInfo_ErrorKind { + <> + Unknown + NetworkError + ErrorResponse + InvalidData + StoreError + } + + note for ErrorInfo "Common for client/server" + + ErrorInfo --* ErrorInfo_ErrorKind + + class ErrorInfo { + +const Kind() ErrorKind + +const StatusCode() StatusCodeType + +const Message() std::string const& + +const Time() DateTime + } + + class PollingDataSource { + + } + + class StreamingDataSource { + + } + + note for DataSourceEventHandler "Different for client/server" + + + class DataSourceEventHandler { + +HandleMessage(std::string const& type, std::string const& data) MessageStatus + } + + note for DataSourceEventHandler_MessageStatus "Common for client/server" + + class DataSourceEventHandler_MessageStatus { + <> + MessageHandled + InvalidMessage + UnhandledVerb + } + + class DataSourceStatus { + +const State() DataSourceState + +const StateSince() DateTime + +const LastError() std::optional~ErrorInfo~ + } + + class DataSourceStatusManager { + +SetState(DataSourceStatus status) void + +SetState(DataSourceState state, StatusCodeType code, std::string message) void + +SetState(DataSourceState state, ErrorInfo_ErrorKind kind, std::string message) void + +SetError(ErrorInfo::ErrorKind kind, std::string message) void + +SetError(StatusCodeType code, std::string message) void + } + +``` \ No newline at end of file diff --git a/architecture/server_store_arch.md b/architecture/server_store_arch.md new file mode 100644 index 000000000..8b9a1ec31 --- /dev/null +++ b/architecture/server_store_arch.md @@ -0,0 +1,98 @@ +# Server Data Store Architecture + +```mermaid + +classDiagram + Client --* IDataStore : Contains an IDataStore which is\n either a MemoryStore or a PersistentStore + Client --* DataStoreUpdater + Client --* IChangeNotifier + + IDataStore <|-- MemoryStore + IDataSourceUpdateSink <|-- MemoryStore + + IDataStore <|-- PersistentStore + IDataSourceUpdateSink <|-- PersistentStore + IDataSourceUpdateSink <|-- DataStoreUpdater + + IChangeNotifier <|-- DataStoreUpdater + + DataStoreUpdater --> IDataStore + + PersistentStore --* MemoryStore : PersistentStore contains a MemoryStore + PersistentStore --* ExpirationTracker + + IPersistentStoreCore <|-- RedisPersistentStore + + note for IPersistentStoreCore "The Get/All/Initialized are behaviorally\n const, but cache/memoize." + + IPersistentStoreCore --> SerializedItemDescriptor + IPersistentStoreCore --> PersistentKind + PersistentStore --* IPersistentStoreCore + + class PersistentKind{ + +std::string namespace + %% There are some cases where the store may need to extract a version from the serialized representation. + %% Specifically when the store cannot put the version in a column, such as with Redis. + +DeserializeVersion(std::string data): uint64_t + } + + class SerializedItemDescriptor{ + +uint64_t version + +bool deleted + +std::string serializedItem + } + + class IPersistentStoreCore { + <> + +Init(OrderedDataSets dataSets) + +Upsert(PersistentKind kind, std::string key, SerializedItemDescriptor descriptor) SerializedItemDescriptor + + +const Get(PersistentKind kind, std::string key) SerializedItemDescriptor + +const All(PersistentKind kind) std::unordered_map<std::string, SerializedItemDescriptor> + + +const Description() std::string const& + +const Initialized() bool + } + + + class IDataSourceUpdateSink{ + <> + +void Init(SDKDataSet allData) + +void Upsert(std::string key, ItemDescriptor~Flag~ data) + +void Upsert(std::string key, ItemDescriptor~Segment~ data) + } + + note for IDataStore "The shared_ptr from GetFlag or GetSegment may be null." + + class IDataStore{ + <> + +const GetFlag(std::string key) std::shared_ptr<const ItemDescriptor<Flag>> + +const GetSegment(std::string key) std::shared_ptr<const ItemDescriptor<Segment>> + +const AllFlags() std::unordered_map<std::string, std::shared_ptr<const ItemDescriptor<Flag>>> + +const AllSegments() std::unordered_map<std::string, std::shared_ptr<const ItemDescriptor<Segment>>> + +const Initialized() bool + +const Description() string + } + + class ExpirationTracker{ + } + + class MemoryStore{ + } + + class PersistentStore{ + +PersistentStore(std::shared_ptr~IPersistentStoreCore~ core) + } + + class RedisPersistentStore{ + + } + + class IChangeNotifier{ + +OnChange(std::function<void(std::shared_ptr<ChangeSet>)> handler): std::unique_ptr~IConnection~ + } + + class DataStoreUpdater{ + + } +``` \ No newline at end of file diff --git a/cmake/rfc3339_timestamp.cmake b/cmake/rfc3339_timestamp.cmake new file mode 100644 index 000000000..b114f1938 --- /dev/null +++ b/cmake/rfc3339_timestamp.cmake @@ -0,0 +1,31 @@ +FetchContent_Declare(timestamp + GIT_REPOSITORY https://github.com/chansen/c-timestamp + GIT_TAG "b205c407ae6680d23d74359ac00444b80989792f" + ) + +FetchContent_GetProperties(timestamp) +if (NOT timestamp_POPULATED) + FetchContent_Populate(timestamp) +endif () + +add_library(timestamp OBJECT + ${timestamp_SOURCE_DIR}/timestamp_tm.c + ${timestamp_SOURCE_DIR}/timestamp_valid.c + ${timestamp_SOURCE_DIR}/timestamp_parse.c + ) + +if (BUILD_SHARED_LIBS) + set_target_properties(timestamp PROPERTIES + POSITION_INDEPENDENT_CODE 1 + C_VISIBILITY_PRESET hidden + ) +endif () + +target_include_directories(timestamp PUBLIC + $ + $ + ) +install( + TARGETS timestamp + EXPORT ${PROJECT_NAME}-targets +) diff --git a/contract-tests/CMakeLists.txt b/contract-tests/CMakeLists.txt index cf6a9dd44..4da668ade 100644 --- a/contract-tests/CMakeLists.txt +++ b/contract-tests/CMakeLists.txt @@ -1,2 +1,4 @@ +add_subdirectory(data-model) add_subdirectory(sse-contract-tests) -add_subdirectory(sdk-contract-tests) +add_subdirectory(client-contract-tests) +add_subdirectory(server-contract-tests) diff --git a/contract-tests/sdk-contract-tests/CMakeLists.txt b/contract-tests/client-contract-tests/CMakeLists.txt similarity index 62% rename from contract-tests/sdk-contract-tests/CMakeLists.txt rename to contract-tests/client-contract-tests/CMakeLists.txt index 9e1b0af2a..a77e15c6c 100644 --- a/contract-tests/sdk-contract-tests/CMakeLists.txt +++ b/contract-tests/client-contract-tests/CMakeLists.txt @@ -2,29 +2,29 @@ cmake_minimum_required(VERSION 3.19) project( - LaunchDarklyCPPSDKTestHarness + LaunchDarklyCPPClientSDKTestHarness VERSION 0.1 - DESCRIPTION "LaunchDarkly CPP SDK Test Harness" + DESCRIPTION "LaunchDarkly CPP Client-side SDK Test Harness" LANGUAGES CXX ) include(${CMAKE_FILES}/json.cmake) -add_executable(sdk-tests +add_executable(client-tests src/main.cpp src/server.cpp src/session.cpp - src/definitions.cpp src/entity_manager.cpp src/client_entity.cpp ) -target_link_libraries(sdk-tests PRIVATE +target_link_libraries(client-tests PRIVATE launchdarkly::client launchdarkly::internal foxy nlohmann_json::nlohmann_json Boost::coroutine + contract-test-data-model ) -target_include_directories(sdk-tests PUBLIC include) +target_include_directories(client-tests PUBLIC include) diff --git a/contract-tests/sdk-contract-tests/README.md b/contract-tests/client-contract-tests/README.md similarity index 100% rename from contract-tests/sdk-contract-tests/README.md rename to contract-tests/client-contract-tests/README.md diff --git a/contract-tests/sdk-contract-tests/include/client_entity.hpp b/contract-tests/client-contract-tests/include/client_entity.hpp similarity index 96% rename from contract-tests/sdk-contract-tests/include/client_entity.hpp rename to contract-tests/client-contract-tests/include/client_entity.hpp index 66a60d9cd..2fc40a3fd 100644 --- a/contract-tests/sdk-contract-tests/include/client_entity.hpp +++ b/contract-tests/client-contract-tests/include/client_entity.hpp @@ -1,8 +1,8 @@ #pragma once +#include #include #include -#include "definitions.hpp" class ClientEntity { public: diff --git a/contract-tests/sdk-contract-tests/include/entity_manager.hpp b/contract-tests/client-contract-tests/include/entity_manager.hpp similarity index 97% rename from contract-tests/sdk-contract-tests/include/entity_manager.hpp rename to contract-tests/client-contract-tests/include/entity_manager.hpp index e8a5802ad..92c165d62 100644 --- a/contract-tests/sdk-contract-tests/include/entity_manager.hpp +++ b/contract-tests/client-contract-tests/include/entity_manager.hpp @@ -5,8 +5,8 @@ #include #include +#include #include "client_entity.hpp" -#include "definitions.hpp" #include #include diff --git a/contract-tests/sdk-contract-tests/include/server.hpp b/contract-tests/client-contract-tests/include/server.hpp similarity index 100% rename from contract-tests/sdk-contract-tests/include/server.hpp rename to contract-tests/client-contract-tests/include/server.hpp diff --git a/contract-tests/sdk-contract-tests/include/session.hpp b/contract-tests/client-contract-tests/include/session.hpp similarity index 100% rename from contract-tests/sdk-contract-tests/include/session.hpp rename to contract-tests/client-contract-tests/include/session.hpp diff --git a/contract-tests/sdk-contract-tests/src/client_entity.cpp b/contract-tests/client-contract-tests/src/client_entity.cpp similarity index 87% rename from contract-tests/sdk-contract-tests/src/client_entity.cpp rename to contract-tests/client-contract-tests/src/client_entity.cpp index d4012c38a..15446bbfd 100644 --- a/contract-tests/sdk-contract-tests/src/client_entity.cpp +++ b/contract-tests/client-contract-tests/src/client_entity.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -65,8 +66,12 @@ static void BuildContextFromParams(launchdarkly::ContextBuilder& builder, if (single.custom) { for (auto const& [key, value] : *single.custom) { - attrs.Set(key, boost::json::value_to( - boost::json::parse(value.dump()))); + auto maybe_attr = boost::json::value_to< + tl::expected>( + boost::json::parse(value.dump())); + if (maybe_attr) { + attrs.Set(key, *maybe_attr); + } } } } @@ -126,9 +131,16 @@ tl::expected ContextConvert( tl::expected ClientEntity::Custom( CustomEventParams const& params) { - auto data = params.data ? boost::json::value_to( - boost::json::parse(params.data->dump())) - : launchdarkly::Value::Null(); + auto data = + params.data + ? boost::json::value_to< + tl::expected>( + boost::json::parse(params.data->dump())) + : launchdarkly::Value::Null(); + + if (!data) { + return tl::make_unexpected("couldn't parse custom event data"); + } if (params.omitNullData.value_or(false) && !params.metricValue && !params.data) { @@ -137,11 +149,11 @@ tl::expected ClientEntity::Custom( } if (!params.metricValue) { - client_->Track(params.eventKey, std::move(data)); + client_->Track(params.eventKey, std::move(*data)); return nlohmann::json{}; } - client_->Track(params.eventKey, std::move(data), *params.metricValue); + client_->Track(params.eventKey, std::move(*data), *params.metricValue); return nlohmann::json{}; } @@ -204,15 +216,19 @@ tl::expected ClientEntity::EvaluateDetail( } case ValueType::Any: case ValueType::Unspecified: { - auto fallback = boost::json::value_to( + auto maybe_fallback = boost::json::value_to< + tl::expected>( boost::json::parse(defaultVal.dump())); + if (!maybe_fallback) { + return tl::make_unexpected("unable to parse fallback value"); + } /* This switcharoo from nlohmann/json to boost/json to Value, then * back is because we're using nlohmann/json for the test harness * protocol, but boost::json in the SDK. We could swap over to * boost::json entirely here to remove the awkwardness. */ - auto detail = client_->JsonVariationDetail(key, fallback); + auto detail = client_->JsonVariationDetail(key, *maybe_fallback); auto serialized = boost::json::serialize(boost::json::value_from(*detail)); @@ -264,15 +280,18 @@ tl::expected ClientEntity::Evaluate( } case ValueType::Any: case ValueType::Unspecified: { - auto fallback = boost::json::value_to( + auto maybe_fallback = boost::json::value_to< + tl::expected>( boost::json::parse(defaultVal.dump())); - + if (!maybe_fallback) { + return tl::make_unexpected("unable to parse fallback value"); + } /* This switcharoo from nlohmann/json to boost/json to Value, then * back is because we're using nlohmann/json for the test harness * protocol, but boost::json in the SDK. We could swap over to * boost::json entirely here to remove the awkwardness. */ - auto evaluation = client_->JsonVariation(key, fallback); + auto evaluation = client_->JsonVariation(key, *maybe_fallback); auto serialized = boost::json::serialize(boost::json::value_from(evaluation)); diff --git a/contract-tests/sdk-contract-tests/src/entity_manager.cpp b/contract-tests/client-contract-tests/src/entity_manager.cpp similarity index 100% rename from contract-tests/sdk-contract-tests/src/entity_manager.cpp rename to contract-tests/client-contract-tests/src/entity_manager.cpp diff --git a/contract-tests/sdk-contract-tests/src/main.cpp b/contract-tests/client-contract-tests/src/main.cpp similarity index 96% rename from contract-tests/sdk-contract-tests/src/main.cpp rename to contract-tests/client-contract-tests/src/main.cpp index c886b34b8..78d2b3afe 100644 --- a/contract-tests/sdk-contract-tests/src/main.cpp +++ b/contract-tests/client-contract-tests/src/main.cpp @@ -19,7 +19,7 @@ using launchdarkly::LogLevel; int main(int argc, char* argv[]) { launchdarkly::Logger logger{ - std::make_unique("sdk-contract-tests")}; + std::make_unique("client-contract-tests")}; const std::string default_port = "8123"; std::string port = default_port; diff --git a/contract-tests/sdk-contract-tests/src/server.cpp b/contract-tests/client-contract-tests/src/server.cpp similarity index 100% rename from contract-tests/sdk-contract-tests/src/server.cpp rename to contract-tests/client-contract-tests/src/server.cpp diff --git a/contract-tests/sdk-contract-tests/src/session.cpp b/contract-tests/client-contract-tests/src/session.cpp similarity index 97% rename from contract-tests/sdk-contract-tests/src/session.cpp rename to contract-tests/client-contract-tests/src/session.cpp index 8de83db1a..5213ab6f9 100644 --- a/contract-tests/sdk-contract-tests/src/session.cpp +++ b/contract-tests/client-contract-tests/src/session.cpp @@ -1,6 +1,10 @@ #include "session.hpp" + +#include + #include #include + #include const std::string kEntityPath = "/entity/"; @@ -81,7 +85,7 @@ std::optional Session::generate_response(Request& req) { }; if (req.method() == http::verb::get && req.target() == "/") { - return capabilities_response(caps_, "c-client-sdk", "0.0.0"); + return capabilities_response(caps_, "cpp-client-sdk", launchdarkly::client_side::Client::Version()); } if (req.method() == http::verb::head && req.target() == "/") { diff --git a/contract-tests/sdk-contract-tests/test-suppressions.txt b/contract-tests/client-contract-tests/test-suppressions.txt similarity index 100% rename from contract-tests/sdk-contract-tests/test-suppressions.txt rename to contract-tests/client-contract-tests/test-suppressions.txt diff --git a/contract-tests/data-model/CMakeLists.txt b/contract-tests/data-model/CMakeLists.txt new file mode 100644 index 000000000..af39cf433 --- /dev/null +++ b/contract-tests/data-model/CMakeLists.txt @@ -0,0 +1,20 @@ +# Required for Apple Silicon support. +cmake_minimum_required(VERSION 3.19) + +project( + LaunchDarklyCPPSDKTestHarnessDataModel + VERSION 0.1 + DESCRIPTION "LaunchDarkly CPP SDK Test Harness Data Model definitions" + LANGUAGES CXX +) + +include(${CMAKE_FILES}/json.cmake) + + +add_library(contract-test-data-model src/data_model.cpp) +target_link_libraries(contract-test-data-model PUBLIC nlohmann_json::nlohmann_json + ) +target_include_directories(contract-test-data-model PUBLIC + $ + $ + ) diff --git a/contract-tests/sdk-contract-tests/include/definitions.hpp b/contract-tests/data-model/include/data_model/data_model.hpp similarity index 97% rename from contract-tests/sdk-contract-tests/include/definitions.hpp rename to contract-tests/data-model/include/data_model/data_model.hpp index 7c08d367c..b4baccb9b 100644 --- a/contract-tests/sdk-contract-tests/include/definitions.hpp +++ b/contract-tests/data-model/include/data_model/data_model.hpp @@ -188,6 +188,7 @@ NLOHMANN_JSON_SERIALIZE_ENUM(ValueType, struct EvaluateFlagParams { std::string flagKey; + std::optional context; ValueType valueType; nlohmann::json defaultValue; bool detail; @@ -195,6 +196,7 @@ struct EvaluateFlagParams { }; NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(EvaluateFlagParams, flagKey, + context, valueType, defaultValue, detail); @@ -210,11 +212,13 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(EvaluateFlagResponse, reason); struct EvaluateAllFlagParams { + std::optional context; std::optional withReasons; std::optional clientSideOnly; std::optional detailsOnlyForTrackedFlags; }; NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(EvaluateAllFlagParams, + context, withReasons, clientSideOnly, detailsOnlyForTrackedFlags); @@ -226,12 +230,14 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(EvaluateAllFlagsResponse, struct CustomEventParams { std::string eventKey; + std::optional context; std::optional data; std::optional omitNullData; std::optional metricValue; }; NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(CustomEventParams, eventKey, + context, data, omitNullData, metricValue); diff --git a/contract-tests/data-model/src/data_model.cpp b/contract-tests/data-model/src/data_model.cpp new file mode 100644 index 000000000..3172a7c4b --- /dev/null +++ b/contract-tests/data-model/src/data_model.cpp @@ -0,0 +1,6 @@ +#include "data_model/data_model.hpp" + +EvaluateFlagParams::EvaluateFlagParams() + : valueType{ValueType::Unspecified}, detail{false}, context{std::nullopt} {} + +CommandParams::CommandParams() : command{Command::Unknown} {} diff --git a/contract-tests/sdk-contract-tests/src/definitions.cpp b/contract-tests/sdk-contract-tests/src/definitions.cpp deleted file mode 100644 index aba257b39..000000000 --- a/contract-tests/sdk-contract-tests/src/definitions.cpp +++ /dev/null @@ -1,6 +0,0 @@ -#include "definitions.hpp" - -EvaluateFlagParams::EvaluateFlagParams() - : valueType{ValueType::Unspecified}, detail{false} {} - -CommandParams::CommandParams() : command{Command::Unknown} {} diff --git a/contract-tests/server-contract-tests/CMakeLists.txt b/contract-tests/server-contract-tests/CMakeLists.txt new file mode 100644 index 000000000..3bd94cb96 --- /dev/null +++ b/contract-tests/server-contract-tests/CMakeLists.txt @@ -0,0 +1,30 @@ +# Required for Apple Silicon support. +cmake_minimum_required(VERSION 3.19) + +project( + LaunchDarklyCPPServerSDKTestHarness + VERSION 0.1 + DESCRIPTION "LaunchDarkly CPP Server-side SDK Test Harness" + LANGUAGES CXX +) + +include(${CMAKE_FILES}/json.cmake) + +add_executable(server-tests + src/main.cpp + src/server.cpp + src/session.cpp + src/entity_manager.cpp + src/client_entity.cpp + ) + +target_link_libraries(server-tests PRIVATE + launchdarkly::server + launchdarkly::internal + foxy + nlohmann_json::nlohmann_json + Boost::coroutine + contract-test-data-model + ) + +target_include_directories(server-tests PUBLIC include) diff --git a/contract-tests/server-contract-tests/README.md b/contract-tests/server-contract-tests/README.md new file mode 100644 index 000000000..d59cbd039 --- /dev/null +++ b/contract-tests/server-contract-tests/README.md @@ -0,0 +1,35 @@ +## SDK contract tests + +Contract tests have a "test service" on one side, and the "test harness" on +the other. + +This project implements the test service for the C++ Server-side SDK. + +**session (session.hpp)** + +This provides a simple REST API for creating/destroying +test entities. Examples: + +`GET /` - returns the capabilities of this service. + +`DELETE /` - shutdown the service. + +`POST /` - create a new test entity, and return its ID. + +`DELETE /entity/1` - delete the an entity identified by `1`. + +**entity manager (entity_manager.hpp)** + +This manages "entities", which are unique instances of the SDK client. + +**definitions (definitions.hpp)** + +Contains JSON definitions that are used to communicate with the test harness. + +**server (server.hpp)** + +Glues everything together, mainly providing the TCP acceptor that spawns new sessions. + +**session (session.hpp)** + +Prepares HTTP responses based on the results of commands sent to entities. diff --git a/contract-tests/server-contract-tests/include/client_entity.hpp b/contract-tests/server-contract-tests/include/client_entity.hpp new file mode 100644 index 000000000..39b4a3be3 --- /dev/null +++ b/contract-tests/server-contract-tests/include/client_entity.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include + +class ClientEntity { + public: + explicit ClientEntity( + std::unique_ptr client); + + tl::expected Command(CommandParams params); + + private: + tl::expected Evaluate( + EvaluateFlagParams const&); + + tl::expected EvaluateDetail( + EvaluateFlagParams const&, + launchdarkly::Context const&); + + tl::expected EvaluateAll( + EvaluateAllFlagParams const&); + + tl::expected Identify( + IdentifyEventParams const&); + + tl::expected Custom(CustomEventParams const&); + + std::unique_ptr client_; +}; + +static tl::expected ContextConvert( + ContextConvertParams const&); + +static tl::expected ContextBuild( + ContextBuildParams const&); diff --git a/contract-tests/server-contract-tests/include/entity_manager.hpp b/contract-tests/server-contract-tests/include/entity_manager.hpp new file mode 100644 index 000000000..b99633c32 --- /dev/null +++ b/contract-tests/server-contract-tests/include/entity_manager.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include "client_entity.hpp" + +#include + +#include +#include + +#include + +#include +#include +#include +#include +#include + +class EventOutbox; + +class EntityManager { + std::unordered_map entities_; + + std::size_t counter_; + boost::asio::any_io_executor executor_; + + launchdarkly::Logger& logger_; + + public: + /** + * Create an entity manager, which can be used to create and destroy + * entities (SSE clients + event channel back to test harness). + * @param executor Executor. + * @param logger Logger. + */ + EntityManager(boost::asio::any_io_executor executor, + launchdarkly::Logger& logger); + /** + * Create an entity with the given configuration. + * @param params Config of the entity. + * @return An ID representing the entity, or none if the entity couldn't + * be created. + */ + std::optional create(ConfigParams const& params); + /** + * Destroy an entity with the given ID. + * @param id ID of the entity. + * @return True if the entity was found and destroyed. + */ + bool destroy(std::string const& id); + + tl::expected command( + std::string const& id, + CommandParams const& params); +}; diff --git a/contract-tests/server-contract-tests/include/server.hpp b/contract-tests/server-contract-tests/include/server.hpp new file mode 100644 index 000000000..655321f04 --- /dev/null +++ b/contract-tests/server-contract-tests/include/server.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include "entity_manager.hpp" + +#include + +#include +#include +#include +#include + +#include + +#include + +namespace net = boost::asio; // from + +using tcp = boost::asio::ip::tcp; // from + +class server { + EntityManager manager_; + launchdarkly::foxy::listener listener_; + std::vector caps_; + launchdarkly::Logger& logger_; + + public: + /** + * Constructs a server, which stands up a REST API at the given + * port and address. The server is ready to accept connections upon + * construction. + * @param ioc IO context. + * @param address Address to bind. + * @param port Port to bind. + * @param logger Logger. + */ + server(net::io_context& ioc, + std::string const& address, + unsigned short port, + launchdarkly::Logger& logger); + /** + * Advertise an optional test-harness capability, such as "comments". + * @param cap + */ + void add_capability(std::string cap); + + /** + * Shuts down the server. + */ + void shutdown(); +}; diff --git a/contract-tests/server-contract-tests/include/session.hpp b/contract-tests/server-contract-tests/include/session.hpp new file mode 100644 index 000000000..029a559e0 --- /dev/null +++ b/contract-tests/server-contract-tests/include/session.hpp @@ -0,0 +1,95 @@ +#pragma once + +#include + +#include "entity_manager.hpp" + +#include +#include +#include +#include + +#include + +namespace beast = boost::beast; // from +namespace http = beast::http; // from +namespace net = boost::asio; // from +using tcp = boost::asio::ip::tcp; // from + +class Session : boost::asio::coroutine { + public: + using Request = http::request; + using Response = http::response; + + struct Frame { + Request request_; + Response resp_; + }; + + /** + * Constructs a session, which provides a REST API. + * @param session The HTTP session. + * @param manager Manager through which entities can be created/destroyed. + * @param caps Test service capabilities to advertise. + * @param logger Logger. + */ + Session(launchdarkly::foxy::server_session& session, + EntityManager& manager, + std::vector& caps, + launchdarkly::Logger& logger); + + template + auto operator()(Self& self, + boost::system::error_code ec = {}, + std::size_t const bytes_transferred = 0) -> void { + using launchdarkly::LogLevel; + auto& f = *frame_; + + reenter(*this) { + while (true) { + f.resp_ = {}; + f.request_ = {}; + + yield session_.async_read(f.request_, std::move(self)); + if (ec) { + LD_LOG(logger_, LogLevel::kWarn) + << "session: read: " << ec.what(); + break; + } + + if (auto response = generate_response(f.request_)) { + f.resp_ = *response; + } else { + LD_LOG(logger_, LogLevel::kWarn) + << "session: shutdown requested by client"; + std::exit(0); + } + + yield session_.async_write(f.resp_, std::move(self)); + + if (ec) { + LD_LOG(logger_, LogLevel::kWarn) + << "session: write: " << ec.what(); + break; + } + + if (!f.request_.keep_alive()) { + break; + } + } + + return self.complete({}, 0); + } + } + + std::optional generate_response(Request& req); + + private: + launchdarkly::foxy::server_session& session_; + EntityManager& manager_; + std::unique_ptr frame_; + std::vector& caps_; + launchdarkly::Logger& logger_; +}; + +#include diff --git a/contract-tests/server-contract-tests/src/client_entity.cpp b/contract-tests/server-contract-tests/src/client_entity.cpp new file mode 100644 index 000000000..c8fdb40ca --- /dev/null +++ b/contract-tests/server-contract-tests/src/client_entity.cpp @@ -0,0 +1,393 @@ +#include "client_entity.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +using namespace launchdarkly::server_side; + +tl::expected ParseContext( + nlohmann::json value) { + boost::system::error_code ec; + auto boost_json_val = boost::json::parse(value.dump(), ec); + if (ec) { + return tl::make_unexpected(ec.what()); + } + + auto maybe_ctx = boost::json::value_to< + tl::expected>( + boost_json_val); + if (!maybe_ctx) { + return tl::make_unexpected( + launchdarkly::ErrorToString(maybe_ctx.error())); + } + + if (!maybe_ctx->Valid()) { + return tl::make_unexpected(maybe_ctx->errors()); + } + + return *maybe_ctx; +} + +ClientEntity::ClientEntity( + std::unique_ptr client) + : client_(std::move(client)) {} + +tl::expected ClientEntity::Identify( + IdentifyEventParams const& params) { + boost::system::error_code ec; + + auto maybe_ctx = ParseContext(params.context); + if (!maybe_ctx) { + return tl::make_unexpected(maybe_ctx.error()); + } + client_->Identify(*maybe_ctx); + return nlohmann::json{}; +} + +static void BuildContextFromParams(launchdarkly::ContextBuilder& builder, + ContextSingleParams const& single) { + auto& attrs = builder.Kind(single.kind.value_or("user"), single.key); + if (single.anonymous) { + attrs.Anonymous(*single.anonymous); + } + if (single.name) { + attrs.Name(*single.name); + } + + if (single._private) { + attrs.AddPrivateAttributes(*single._private); + } + + if (single.custom) { + for (auto const& [key, value] : *single.custom) { + auto maybe_attr = boost::json::value_to< + tl::expected>( + boost::json::parse(value.dump())); + if (maybe_attr) { + attrs.Set(key, *maybe_attr); + } + } + } +} + +tl::expected ContextBuild( + ContextBuildParams const& params) { + ContextResponse resp{}; + + auto builder = launchdarkly::ContextBuilder(); + + if (params.multi) { + for (auto const& single : *params.multi) { + BuildContextFromParams(builder, single); + } + } else { + BuildContextFromParams(builder, *params.single); + } + + auto ctx = builder.Build(); + if (!ctx.Valid()) { + resp.error = ctx.errors(); + return resp; + } + + resp.output = boost::json::serialize(boost::json::value_from(ctx)); + return resp; +} + +tl::expected ContextConvert( + ContextConvertParams const& params) { + ContextResponse resp{}; + + boost::system::error_code ec; + auto json_value = boost::json::parse(params.input, ec); + if (ec) { + resp.error = ec.what(); + return resp; + } + + auto maybe_ctx = boost::json::value_to< + tl::expected>( + json_value); + + if (!maybe_ctx) { + resp.error = launchdarkly::ErrorToString(maybe_ctx.error()); + return resp; + } + + if (!maybe_ctx->Valid()) { + resp.error = maybe_ctx->errors(); + return resp; + } + + resp.output = boost::json::serialize(boost::json::value_from(*maybe_ctx)); + return resp; +} + +tl::expected ClientEntity::Custom( + CustomEventParams const& params) { + auto data = + params.data + ? boost::json::value_to< + tl::expected>( + boost::json::parse(params.data->dump())) + : launchdarkly::Value::Null(); + + if (!data) { + return tl::make_unexpected("couldn't parse custom event data"); + } + + if (!params.context) { + return tl::make_unexpected("context is required"); + } + + auto maybe_ctx = ParseContext(*params.context); + if (!maybe_ctx) { + return tl::make_unexpected(maybe_ctx.error()); + } + + if (params.omitNullData.value_or(false) && !params.metricValue && + !params.data) { + client_->Track(*maybe_ctx, params.eventKey); + return nlohmann::json{}; + } + + if (!params.metricValue) { + client_->Track(*maybe_ctx, params.eventKey, std::move(*data)); + return nlohmann::json{}; + } + + client_->Track(*maybe_ctx, params.eventKey, std::move(*data), + *params.metricValue); + return nlohmann::json{}; +} + +tl::expected ClientEntity::EvaluateAll( + EvaluateAllFlagParams const& params) { + EvaluateAllFlagsResponse resp{}; + + boost::ignore_unused(params); + + if (!params.context) { + return tl::make_unexpected("context is required"); + } + + auto maybe_ctx = ParseContext(*params.context); + if (!maybe_ctx) { + return tl::make_unexpected(maybe_ctx.error()); + } + + AllFlagsState::Options options = AllFlagsState::Options::Default; + if (params.withReasons.value_or(false)) { + options |= AllFlagsState::Options::IncludeReasons; + } + if (params.clientSideOnly.value_or(false)) { + options |= AllFlagsState::Options::ClientSideOnly; + } + if (params.detailsOnlyForTrackedFlags.value_or(false)) { + options |= AllFlagsState::Options::DetailsOnlyForTrackedFlags; + } + + auto state = client_->AllFlagsState(*maybe_ctx, options); + + resp.state = nlohmann::json::parse( + boost::json::serialize(boost::json::value_from(state))); + + return resp; +} + +tl::expected ClientEntity::EvaluateDetail( + EvaluateFlagParams const& params, + launchdarkly::Context const& ctx) { + auto const& key = params.flagKey; + + auto const& defaultVal = params.defaultValue; + + EvaluateFlagResponse result; + + std::optional reason; + + switch (params.valueType) { + case ValueType::Bool: { + auto detail = + client_->BoolVariationDetail(ctx, key, defaultVal.get()); + result.value = *detail; + reason = detail.Reason(); + result.variationIndex = detail.VariationIndex(); + break; + } + case ValueType::Int: { + auto detail = + client_->IntVariationDetail(ctx, key, defaultVal.get()); + result.value = *detail; + reason = detail.Reason(); + result.variationIndex = detail.VariationIndex(); + break; + } + case ValueType::Double: { + auto detail = client_->DoubleVariationDetail( + ctx, key, defaultVal.get()); + result.value = *detail; + reason = detail.Reason(); + result.variationIndex = detail.VariationIndex(); + break; + } + case ValueType::String: { + auto detail = client_->StringVariationDetail( + ctx, key, defaultVal.get()); + result.value = *detail; + reason = detail.Reason(); + result.variationIndex = detail.VariationIndex(); + break; + } + case ValueType::Any: + case ValueType::Unspecified: { + auto maybe_fallback = boost::json::value_to< + tl::expected>( + boost::json::parse(defaultVal.dump())); + if (!maybe_fallback) { + return tl::make_unexpected("unable to parse fallback value"); + } + + /* This switcharoo from nlohmann/json to boost/json to Value, then + * back is because we're using nlohmann/json for the test harness + * protocol, but boost::json in the SDK. We could swap over to + * boost::json entirely here to remove the awkwardness. */ + + auto detail = + client_->JsonVariationDetail(ctx, key, *maybe_fallback); + + auto serialized = + boost::json::serialize(boost::json::value_from(*detail)); + + result.value = nlohmann::json::parse(serialized); + reason = detail.Reason(); + result.variationIndex = detail.VariationIndex(); + break; + } + default: + return tl::make_unexpected("unknown variation type"); + } + + result.reason = + reason.has_value() + ? std::make_optional(nlohmann::json::parse( + boost::json::serialize(boost::json::value_from(*reason)))) + : std::nullopt; + + return result; +} +tl::expected ClientEntity::Evaluate( + EvaluateFlagParams const& params) { + auto maybe_ctx = ParseContext(params.context); + if (!maybe_ctx) { + return tl::make_unexpected(maybe_ctx.error()); + } + + if (params.detail) { + return EvaluateDetail(params, *maybe_ctx); + } + + auto const& key = params.flagKey; + + auto const& defaultVal = params.defaultValue; + + EvaluateFlagResponse result; + + switch (params.valueType) { + case ValueType::Bool: + result.value = + client_->BoolVariation(*maybe_ctx, key, defaultVal.get()); + break; + case ValueType::Int: + result.value = + client_->IntVariation(*maybe_ctx, key, defaultVal.get()); + break; + case ValueType::Double: + result.value = client_->DoubleVariation(*maybe_ctx, key, + defaultVal.get()); + break; + case ValueType::String: { + result.value = client_->StringVariation( + *maybe_ctx, key, defaultVal.get()); + break; + } + case ValueType::Any: + case ValueType::Unspecified: { + auto maybe_fallback = boost::json::value_to< + tl::expected>( + boost::json::parse(defaultVal.dump())); + if (!maybe_fallback) { + return tl::make_unexpected("unable to parse fallback value"); + } + /* This switcharoo from nlohmann/json to boost/json to Value, then + * back is because we're using nlohmann/json for the test harness + * protocol, but boost::json in the SDK. We could swap over to + * boost::json entirely here to remove the awkwardness. */ + + auto evaluation = + client_->JsonVariation(*maybe_ctx, key, *maybe_fallback); + + auto serialized = + boost::json::serialize(boost::json::value_from(evaluation)); + + result.value = nlohmann::json::parse(serialized); + break; + } + default: + return tl::make_unexpected("unknown variation type"); + } + + return result; +} +tl::expected ClientEntity::Command( + CommandParams params) { + switch (params.command) { + case Command::Unknown: + return tl::make_unexpected("unknown command"); + case Command::EvaluateFlag: + if (!params.evaluate) { + return tl::make_unexpected("evaluate params must be set"); + } + return Evaluate(*params.evaluate); + case Command::EvaluateAllFlags: + if (!params.evaluateAll) { + return tl::make_unexpected("evaluateAll params must be set"); + } + return EvaluateAll(*params.evaluateAll); + case Command::IdentifyEvent: + if (!params.identifyEvent) { + return tl::make_unexpected("identifyEvent params must be set"); + } + return Identify(*params.identifyEvent); + case Command::CustomEvent: + if (!params.customEvent) { + return tl::make_unexpected("customEvent params must be set"); + } + return Custom(*params.customEvent); + case Command::FlushEvents: + client_->FlushAsync(); + return nlohmann::json{}; + case Command::ContextBuild: + if (!params.contextBuild) { + return tl::make_unexpected("contextBuild params must be set"); + } + return ContextBuild(*params.contextBuild); + case Command::ContextConvert: + if (!params.contextConvert) { + return tl::make_unexpected("contextConvert params must be set"); + } + return ContextConvert(*params.contextConvert); + } + return tl::make_unexpected("unrecognized command"); +} diff --git a/contract-tests/server-contract-tests/src/entity_manager.cpp b/contract-tests/server-contract-tests/src/entity_manager.cpp new file mode 100644 index 000000000..8e8235036 --- /dev/null +++ b/contract-tests/server-contract-tests/src/entity_manager.cpp @@ -0,0 +1,153 @@ +#include "entity_manager.hpp" +#include + +#include +#include +#include + +using launchdarkly::LogLevel; +using namespace launchdarkly::server_side; + +EntityManager::EntityManager(boost::asio::any_io_executor executor, + launchdarkly::Logger& logger) + : counter_{0}, executor_{std::move(executor)}, logger_{logger} {} + +std::optional EntityManager::create(ConfigParams const& in) { + std::string id = std::to_string(counter_++); + + auto config_builder = ConfigBuilder(in.credential); + + auto default_endpoints = + launchdarkly::server_side::Defaults::ServiceEndpoints(); + + auto& endpoints = + config_builder.ServiceEndpoints() + .EventsBaseUrl(default_endpoints.EventsBaseUrl()) + .PollingBaseUrl(default_endpoints.PollingBaseUrl()) + .StreamingBaseUrl(default_endpoints.StreamingBaseUrl()); + + if (in.serviceEndpoints) { + if (in.serviceEndpoints->streaming) { + endpoints.StreamingBaseUrl(*in.serviceEndpoints->streaming); + } + if (in.serviceEndpoints->polling) { + endpoints.PollingBaseUrl(*in.serviceEndpoints->polling); + } + if (in.serviceEndpoints->events) { + endpoints.EventsBaseUrl(*in.serviceEndpoints->events); + } + } + + auto& datasource = config_builder.DataSource(); + + if (in.streaming) { + if (in.streaming->baseUri) { + endpoints.StreamingBaseUrl(*in.streaming->baseUri); + } + if (in.streaming->initialRetryDelayMs) { + auto streaming = DataSourceBuilder::Streaming(); + streaming.InitialReconnectDelay( + std::chrono::milliseconds(*in.streaming->initialRetryDelayMs)); + datasource.Method(std::move(streaming)); + } + } + + if (in.polling) { + if (in.polling->baseUri) { + endpoints.PollingBaseUrl(*in.polling->baseUri); + } + if (!in.streaming) { + auto method = DataSourceBuilder::Polling(); + if (in.polling->pollIntervalMs) { + method.PollInterval( + std::chrono::duration_cast( + std::chrono::milliseconds( + *in.polling->pollIntervalMs))); + } + datasource.Method(std::move(method)); + } + } + + auto& event_config = config_builder.Events(); + + if (in.events) { + ConfigEventParams const& events = *in.events; + + if (events.baseUri) { + endpoints.EventsBaseUrl(*events.baseUri); + } + + if (events.allAttributesPrivate) { + event_config.AllAttributesPrivate(*events.allAttributesPrivate); + } + + if (!events.globalPrivateAttributes.empty()) { + launchdarkly::AttributeReference::SetType attrs( + events.globalPrivateAttributes.begin(), + events.globalPrivateAttributes.end()); + event_config.PrivateAttributes(std::move(attrs)); + } + + if (events.capacity) { + event_config.Capacity(*events.capacity); + } + + if (events.flushIntervalMs) { + event_config.FlushInterval( + std::chrono::milliseconds(*events.flushIntervalMs)); + } + + } else { + event_config.Disable(); + } + + if (in.tags) { + if (in.tags->applicationId) { + config_builder.AppInfo().Identifier(*in.tags->applicationId); + } + if (in.tags->applicationVersion) { + config_builder.AppInfo().Version(*in.tags->applicationVersion); + } + } + + auto config = config_builder.Build(); + if (!config) { + LD_LOG(logger_, LogLevel::kWarn) + << "entity_manager: couldn't build config: " << config.error(); + return std::nullopt; + } + + auto client = std::make_unique(std::move(*config)); + + std::chrono::milliseconds waitForClient = std::chrono::seconds(5); + if (in.startWaitTimeMs) { + waitForClient = std::chrono::milliseconds(*in.startWaitTimeMs); + } + + auto init = client->StartAsync(); + init.wait_for(waitForClient); + + entities_.try_emplace(id, std::move(client)); + + return id; +} + +bool EntityManager::destroy(std::string const& id) { + auto it = entities_.find(id); + if (it == entities_.end()) { + return false; + } + + entities_.erase(it); + return true; +} + +tl::expected EntityManager::command( + std::string const& id, + CommandParams const& params) { + auto it = entities_.find(id); + if (it == entities_.end()) { + return tl::make_unexpected("entity not found"); + } + return it->second.Command(params); +} diff --git a/contract-tests/server-contract-tests/src/main.cpp b/contract-tests/server-contract-tests/src/main.cpp new file mode 100644 index 000000000..61245d1af --- /dev/null +++ b/contract-tests/server-contract-tests/src/main.cpp @@ -0,0 +1,66 @@ +#include "server.hpp" + +#include + +#include +#include +#include +#include +#include + +#include + +namespace net = boost::asio; +namespace beast = boost::beast; + +using launchdarkly::logging::ConsoleBackend; + +using launchdarkly::LogLevel; + +int main(int argc, char* argv[]) { + launchdarkly::Logger logger{ + std::make_unique("server-contract-tests")}; + + const std::string default_port = "8123"; + std::string port = default_port; + if (argc == 2) { + port = + argv[1]; // NOLINT(cppcoreguidelines-pro-bounds-pointer-arithmetic) + } + + try { + net::io_context ioc{1}; + + auto p = boost::lexical_cast(port); + server srv(ioc, "0.0.0.0", p, logger); + + srv.add_capability("server-side"); + srv.add_capability("strongly-typed"); + srv.add_capability("context-type"); + srv.add_capability("service-endpoints"); + srv.add_capability("tags"); + srv.add_capability("server-side-polling"); + + net::signal_set signals{ioc, SIGINT, SIGTERM}; + + boost::asio::spawn(ioc.get_executor(), [&](auto yield) mutable { + signals.async_wait(yield); + LD_LOG(logger, LogLevel::kInfo) << "shutting down.."; + srv.shutdown(); + }); + + ioc.run(); + LD_LOG(logger, LogLevel::kInfo) << "bye!"; + + } catch (boost::bad_lexical_cast&) { + LD_LOG(logger, LogLevel::kError) + << "invalid port (" << port + << "), provide a number (no arguments defaults " + "to port " + << default_port << ")"; + return EXIT_FAILURE; + } catch (std::exception const& e) { + LD_LOG(logger, LogLevel::kError) << e.what(); + return EXIT_FAILURE; + } +} diff --git a/contract-tests/server-contract-tests/src/server.cpp b/contract-tests/server-contract-tests/src/server.cpp new file mode 100644 index 000000000..b7c0a8b88 --- /dev/null +++ b/contract-tests/server-contract-tests/src/server.cpp @@ -0,0 +1,34 @@ +#include "server.hpp" +#include "session.hpp" + +#include +#include +#include +#include + +using launchdarkly::LogLevel; + +server::server(net::io_context& ioc, + std::string const& address, + unsigned short port, + launchdarkly::Logger& logger) + : manager_(ioc.get_executor(), logger), + listener_{ioc.get_executor(), + tcp::endpoint(boost::asio::ip::make_address(address), port)}, + logger_{logger} { + LD_LOG(logger_, LogLevel::kInfo) + << "server: listening on " << address << ":" << port; + listener_.async_accept([this](auto& server) { + return Session(server, manager_, caps_, logger_); + }); +} + +void server::add_capability(std::string cap) { + LD_LOG(logger_, LogLevel::kDebug) + << "server: test capability: <" << cap << ">"; + caps_.push_back(std::move(cap)); +} + +void server::shutdown() { + listener_.shutdown(); +} diff --git a/contract-tests/server-contract-tests/src/session.cpp b/contract-tests/server-contract-tests/src/session.cpp new file mode 100644 index 000000000..16a643c50 --- /dev/null +++ b/contract-tests/server-contract-tests/src/session.cpp @@ -0,0 +1,146 @@ +#include "session.hpp" + +#include + +#include +#include + +#include + +const std::string kEntityPath = "/entity/"; + +namespace net = boost::asio; + +Session::Session(launchdarkly::foxy::server_session& session, + EntityManager& manager, + std::vector& caps, + launchdarkly::Logger& logger) + : session_(session), + frame_(std::make_unique()), + manager_(manager), + caps_(caps), + logger_(logger) {} + +std::optional Session::generate_response(Request& req) { + auto const bad_request = [&req](beast::string_view why) { + Response res{http::status::bad_request, req.version()}; + res.set(http::field::server, BOOST_BEAST_VERSION_STRING); + res.set(http::field::content_type, "application/json"); + res.keep_alive(req.keep_alive()); + res.body() = nlohmann::json{"error", why}.dump(); + res.prepare_payload(); + return res; + }; + + auto const not_found = [&req](beast::string_view target) { + Response res{http::status::not_found, req.version()}; + res.set(http::field::server, BOOST_BEAST_VERSION_STRING); + res.set(http::field::content_type, "text/html"); + res.keep_alive(req.keep_alive()); + res.body() = + "The resource '" + std::string(target) + "' was not found."; + res.prepare_payload(); + return res; + }; + + auto const server_error = [&req](beast::string_view what) { + Response res{http::status::internal_server_error, req.version()}; + res.set(http::field::server, BOOST_BEAST_VERSION_STRING); + res.set(http::field::content_type, "text/html"); + res.keep_alive(req.keep_alive()); + res.body() = "An error occurred: '" + std::string(what) + "'"; + res.prepare_payload(); + return res; + }; + + auto const capabilities_response = + [&req](std::vector const& caps, std::string const& name, + std::string const& version) { + Response res{http::status::ok, req.version()}; + res.set(http::field::content_type, "application/json"); + res.keep_alive(req.keep_alive()); + res.body() = nlohmann::json{ + {"capabilities", caps}, + {"name", name}, + {"clientVersion", + version}}.dump(); + res.prepare_payload(); + return res; + }; + + auto const create_entity_response = [&req](std::string const& id) { + Response res{http::status::ok, req.version()}; + res.keep_alive(req.keep_alive()); + res.set("Location", kEntityPath + id); + res.prepare_payload(); + return res; + }; + + auto const destroy_entity_response = [&req](bool erased) { + auto status = erased ? http::status::ok : http::status::not_found; + Response res{status, req.version()}; + res.keep_alive(req.keep_alive()); + res.prepare_payload(); + return res; + }; + + if (req.method() == http::verb::get && req.target() == "/") { + return capabilities_response(caps_, "cpp-server-sdk", launchdarkly::server_side::Client::Version()); + } + + if (req.method() == http::verb::head && req.target() == "/") { + return http::response{http::status::ok, + req.version()}; + } + + if (req.method() == http::verb::delete_ && req.target() == "/") { + return std::nullopt; + } + + if (req.method() == http::verb::post && req.target() == "/") { + try { + auto json = nlohmann::json::parse(req.body()); + auto params = json.get(); + if (auto entity_id = manager_.create(params.configuration)) { + return create_entity_response(*entity_id); + } + return server_error("couldn't create client entity"); + } catch (nlohmann::json::exception& e) { + return bad_request("unable to parse config JSON"); + } + } + + if (req.method() == http::verb::post && + req.target().starts_with(kEntityPath)) { + std::string entity_id = req.target(); + boost::erase_first(entity_id, kEntityPath); + + try { + auto json = nlohmann::json::parse(req.body()); + auto params = json.get(); + tl::expected res = + manager_.command(entity_id, params); + if (res.has_value()) { + auto response = http::response{ + http::status::ok, req.version()}; + response.body() = res->dump(); + response.prepare_payload(); + return response; + } else { + return bad_request(res.error()); + } + } catch (nlohmann::json::exception& e) { + return bad_request("unable to parse config JSON"); + } + } + + if (req.method() == http::verb::delete_ && + req.target().starts_with(kEntityPath)) { + std::string entity_id = req.target(); + boost::erase_first(entity_id, kEntityPath); + bool erased = manager_.destroy(entity_id); + return destroy_entity_response(erased); + } + + return not_found(req.target()); +} diff --git a/contract-tests/server-contract-tests/test-suppressions.txt b/contract-tests/server-contract-tests/test-suppressions.txt new file mode 100644 index 000000000..89f4db4b8 --- /dev/null +++ b/contract-tests/server-contract-tests/test-suppressions.txt @@ -0,0 +1,40 @@ +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 + +# The Server doesn't need to know how to deserialize users. +context type/convert/old user to context/{"key": ""} +context type/convert/old user to context/{"key": "a"} +context type/convert/old user to context/{"key": "a"} +context type/convert/old user to context/{"key": "a", "custom": {"b": true}} +context type/convert/old user to context/{"key": "a", "custom": {"b": 1}} +context type/convert/old user to context/{"key": "a", "custom": {"b": "c"}} +context type/convert/old user to context/{"key": "a", "custom": {"b": [1, 2]}} +context type/convert/old user to context/{"key": "a", "custom": {"b": {"c": 1}}} +context type/convert/old user to context/{"key": "a", "custom": {"b": 1, "c": 2}} +context type/convert/old user to context/{"key": "a", "custom": {"b": 1, "c": null}} +context type/convert/old user to context/{"key": "a", "custom": {}} +context type/convert/old user to context/{"key": "a", "custom": null} +context type/convert/old user to context/{"key": "a", "anonymous": true} +context type/convert/old user to context/{"key": "a", "anonymous": false} +context type/convert/old user to context/{"key": "a", "anonymous": null} +context type/convert/old user to context/{"key": "a", "privateAttributeNames": ["b"]} +context type/convert/old user to context/{"key": "a", "privateAttributeNames": []} +context type/convert/old user to context/{"key": "a", "privateAttributeNames": null} +context type/convert/old user to context/{"key": "a", "name": "b"} +context type/convert/old user to context/{"key": "a", "name": null} +context type/convert/old user to context/{"key": "a", "firstName": "b"} +context type/convert/old user to context/{"key": "a", "firstName": null} +context type/convert/old user to context/{"key": "a", "lastName": "b"} +context type/convert/old user to context/{"key": "a", "lastName": null} +context type/convert/old user to context/{"key": "a", "email": "b"} +context type/convert/old user to context/{"key": "a", "email": null} +context type/convert/old user to context/{"key": "a", "country": "b"} +context type/convert/old user to context/{"key": "a", "country": null} +context type/convert/old user to context/{"key": "a", "avatar": "b"} +context type/convert/old user to context/{"key": "a", "avatar": null} +context type/convert/old user to context/{"key": "a", "ip": "b"} +context type/convert/old user to context/{"key": "a", "ip": null} diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 74d58b39a..ea3483962 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -1,2 +1,3 @@ add_subdirectory(hello-c-client) add_subdirectory(hello-cpp-client) +add_subdirectory(hello-cpp-server) diff --git a/examples/hello-c-client/CMakeLists.txt b/examples/hello-c-client/CMakeLists.txt index 5d9ce32bf..4f6e4cc1c 100644 --- a/examples/hello-c-client/CMakeLists.txt +++ b/examples/hello-c-client/CMakeLists.txt @@ -4,12 +4,12 @@ cmake_minimum_required(VERSION 3.19) project( LaunchDarklyHelloCClient VERSION 0.1 - DESCRIPTION "LaunchDarkly Hello C Client" + DESCRIPTION "LaunchDarkly Hello C Client-side SDK" LANGUAGES C ) set(THREADS_PREFER_PTHREAD_FLAG ON) find_package(Threads REQUIRED) -add_executable(hello-c main.c) -target_link_libraries(hello-c PRIVATE launchdarkly::client launchdarkly::sse launchdarkly::common Threads::Threads) +add_executable(hello-c-client main.c) +target_link_libraries(hello-c-client PRIVATE launchdarkly::client launchdarkly::sse launchdarkly::common Threads::Threads) diff --git a/examples/hello-cpp-client/CMakeLists.txt b/examples/hello-cpp-client/CMakeLists.txt index 0b96fae34..c99ab3215 100644 --- a/examples/hello-cpp-client/CMakeLists.txt +++ b/examples/hello-cpp-client/CMakeLists.txt @@ -4,12 +4,12 @@ cmake_minimum_required(VERSION 3.19) project( LaunchDarklyHelloCPPClient VERSION 0.1 - DESCRIPTION "LaunchDarkly Hello CPP Client" + DESCRIPTION "LaunchDarkly Hello CPP Client-side SDK" LANGUAGES CXX ) set(THREADS_PREFER_PTHREAD_FLAG ON) find_package(Threads REQUIRED) -add_executable(hello-cpp main.cpp) -target_link_libraries(hello-cpp PRIVATE launchdarkly::client Threads::Threads) +add_executable(hello-cpp-client main.cpp) +target_link_libraries(hello-cpp-client PRIVATE launchdarkly::client Threads::Threads) diff --git a/examples/hello-cpp-client/main.cpp b/examples/hello-cpp-client/main.cpp index 5e0a2c4b7..99ed3c849 100644 --- a/examples/hello-cpp-client/main.cpp +++ b/examples/hello-cpp-client/main.cpp @@ -1,8 +1,8 @@ #include #include -#include #include +#include // Set MOBILE_KEY to your LaunchDarkly mobile key. #define MOBILE_KEY "" @@ -18,7 +18,7 @@ using namespace launchdarkly; int main() { if (!strlen(MOBILE_KEY)) { printf( - "*** Please edit main.c to set MOBILE_KEY to your LaunchDarkly " + "*** Please edit main.cpp to set MOBILE_KEY to your LaunchDarkly " "mobile key first\n\n"); return 1; } diff --git a/examples/hello-cpp-server/CMakeLists.txt b/examples/hello-cpp-server/CMakeLists.txt new file mode 100644 index 000000000..3c2d37cc0 --- /dev/null +++ b/examples/hello-cpp-server/CMakeLists.txt @@ -0,0 +1,15 @@ +# Required for Apple Silicon support. +cmake_minimum_required(VERSION 3.19) + +project( + LaunchDarklyHelloCPPServer + VERSION 0.1 + DESCRIPTION "LaunchDarkly Hello CPP Server-side SDK" + LANGUAGES CXX +) + +set(THREADS_PREFER_PTHREAD_FLAG ON) +find_package(Threads REQUIRED) + +add_executable(hello-cpp-server main.cpp) +target_link_libraries(hello-cpp-server PRIVATE launchdarkly::server Threads::Threads) diff --git a/examples/hello-cpp-server/main.cpp b/examples/hello-cpp-server/main.cpp new file mode 100644 index 000000000..d11e59655 --- /dev/null +++ b/examples/hello-cpp-server/main.cpp @@ -0,0 +1,59 @@ +#include +#include + +#include +#include + +// Set MOBILE_KEY to your LaunchDarkly mobile key. +#define MOBILE_KEY "" + +// Set FEATURE_FLAG_KEY to the feature flag key you want to evaluate. +#define FEATURE_FLAG_KEY "my-boolean-flag" + +// Set INIT_TIMEOUT_MILLISECONDS to the amount of time you will wait for +// the client to become initialized. +#define INIT_TIMEOUT_MILLISECONDS 3000 + +using namespace launchdarkly; +int main() { + if (!strlen(MOBILE_KEY)) { + printf( + "*** Please edit main.cpp to set MOBILE_KEY to your LaunchDarkly " + "mobile key first\n\n"); + return 1; + } + + auto config = server_side::ConfigBuilder(MOBILE_KEY).Build(); + if (!config) { + std::cout << "error: config is invalid: " << config.error() << '\n'; + return 1; + } + + auto client = server_side::Client(std::move(*config)); + + auto start_result = client.StartAsync(); + auto status = start_result.wait_for( + std::chrono::milliseconds(INIT_TIMEOUT_MILLISECONDS)); + if (status == std::future_status::ready) { + if (start_result.get()) { + std::cout << "*** SDK successfully initialized!\n\n"; + } else { + std::cout << "*** SDK failed to initialize\n"; + return 1; + } + } else { + std::cout << "*** SDK initialization didn't complete in " + << INIT_TIMEOUT_MILLISECONDS << "ms\n"; + return 1; + } + + auto context = + ContextBuilder().Kind("user", "example-user-key").Name("Sandy").Build(); + + bool flag_value = client.BoolVariation(context, FEATURE_FLAG_KEY, false); + + std::cout << "*** Feature flag '" << FEATURE_FLAG_KEY << "' is " + << (flag_value ? "true" : "false") << " for this user\n\n"; + + return 0; +} diff --git a/libs/client-sdk/README.md b/libs/client-sdk/README.md index ff4f8b957..aee4dd3b0 100644 --- a/libs/client-sdk/README.md +++ b/libs/client-sdk/README.md @@ -8,6 +8,8 @@ The LaunchDarkly Client-Side SDK for C/C++ is designed primarily for use in desk It follows the client-side LaunchDarkly model for single-user contexts (much like our mobile or JavaScript SDKs). It is not intended for use in multi-user systems such as web servers and applications. +For using LaunchDarkly in server-side C/C++ applications, refer to our [Server-Side C/C++ SDK](../server-sdk/README.md). + LaunchDarkly overview ------------------------- [LaunchDarkly](https://www.launchdarkly.com) is a feature management platform that serves trillions of feature flags diff --git a/libs/client-sdk/include/launchdarkly/client_side/bindings/c/sdk.h b/libs/client-sdk/include/launchdarkly/client_side/bindings/c/sdk.h index 91762e570..90720a0c8 100644 --- a/libs/client-sdk/include/launchdarkly/client_side/bindings/c/sdk.h +++ b/libs/client-sdk/include/launchdarkly/client_side/bindings/c/sdk.h @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -447,7 +448,6 @@ LDClientSDK_FlagNotifier_OnFlagChange(LDClientSDK sdk, struct LDFlagListener listener); typedef struct _LDDataSourceStatus* LDDataSourceStatus; -typedef struct _LDDataSourceStatus_ErrorInfo* LDDataSourceStatus_ErrorInfo; /** * Enumeration of possible data source states. @@ -503,40 +503,6 @@ enum LDDataSourceStatus_State { LD_DATASOURCESTATUS_STATE_SHUTDOWN = 4 }; -/** - * A description of an error condition that the data source encountered. - */ -enum LDDataSourceStatus_ErrorKind { - /** - * An unexpected error, such as an uncaught exception, further - * described by the error message. - */ - LD_DATASOURCESTATUS_ERRORKIND_UNKNOWN = 0, - - /** - * An I/O error such as a dropped connection. - */ - LD_DATASOURCESTATUS_ERRORKIND_NETWORK_ERROR = 1, - - /** - * The LaunchDarkly service returned an HTTP response with an error - * status, available in the status code. - */ - LD_DATASOURCESTATUS_ERRORKIND_ERROR_RESPONSE = 2, - - /** - * The SDK received malformed data from the LaunchDarkly service. - */ - LD_DATASOURCESTATUS_ERRORKIND_INVALID_DATA = 3, - - /** - * The data source itself is working, but when it tried to put an - * update into the data store, the data store failed (so the SDK may - * not have the latest data). - */ - LD_DATASOURCESTATUS_ERRORKIND_STORE_ERROR = 4, -}; - /** * Get an enumerated value representing the overall current state of the data * source. @@ -583,34 +549,6 @@ LDDataSourceStatus_GetLastError(LDDataSourceStatus status); */ LD_EXPORT(time_t) LDDataSourceStatus_StateSince(LDDataSourceStatus status); -/** - * Get an enumerated value representing the general category of the error. - */ -LD_EXPORT(enum LDDataSourceStatus_ErrorKind) -LDDataSourceStatus_ErrorInfo_GetKind(LDDataSourceStatus_ErrorInfo info); - -/** - * The HTTP status code if the error was - * LD_DATASOURCESTATUS_ERRORKIND_ERROR_RESPONSE. - */ -LD_EXPORT(uint64_t) -LDDataSourceStatus_ErrorInfo_StatusCode(LDDataSourceStatus_ErrorInfo info); - -/** - * Any additional human-readable information relevant to the error. - * - * The format is subject to change and should not be relied on - * programmatically. - */ -LD_EXPORT(char const*) -LDDataSourceStatus_ErrorInfo_Message(LDDataSourceStatus_ErrorInfo info); - -/** - * The date/time that the error occurred, in seconds since epoch. - */ -LD_EXPORT(time_t) -LDDataSourceStatus_ErrorInfo_Time(LDDataSourceStatus_ErrorInfo info); - typedef void (*DataSourceStatusCallbackFn)(LDDataSourceStatus status, void* user_data); @@ -685,13 +623,6 @@ LDClientSDK_DataSourceStatus_Status(LDClientSDK sdk); */ LD_EXPORT(void) LDDataSourceStatus_Free(LDDataSourceStatus status); -/** - * Frees the data source status error information. - * @param status The error information to free. - */ -LD_EXPORT(void) -LDDataSourceStatus_ErrorInfo_Free(LDDataSourceStatus_ErrorInfo info); - #ifdef __cplusplus } #endif diff --git a/libs/client-sdk/include/launchdarkly/client_side/data_source_status.hpp b/libs/client-sdk/include/launchdarkly/client_side/data_source_status.hpp index c1591509e..cdf005a35 100644 --- a/libs/client-sdk/include/launchdarkly/client_side/data_source_status.hpp +++ b/libs/client-sdk/include/launchdarkly/client_side/data_source_status.hpp @@ -5,202 +5,75 @@ #include #include #include -#include -#include +#include #include +#include namespace launchdarkly::client_side::data_sources { -class DataSourceStatus { - public: - using DateTime = std::chrono::time_point; - +/** + * Enumeration of possible data source states. + */ +enum class ClientDataSourceState { /** - * Enumeration of possible data source states. + * The initial state of the data source when the SDK is being + * initialized. + * + * If it encounters an error that requires it to retry initialization, + * the state will remain at kInitializing until it either succeeds and + * becomes kValid, or permanently fails and becomes kShutdown. */ - enum class DataSourceState { - /** - * The initial state of the data source when the SDK is being - * initialized. - * - * If it encounters an error that requires it to retry initialization, - * the state will remain at kInitializing until it either succeeds and - * becomes kValid, or permanently fails and becomes kShutdown. - */ - kInitializing = 0, - - /** - * Indicates that the data source is currently operational and has not - * had any problems since the last time it received data. - * - * In streaming mode, this means that there is currently an open stream - * connection and that at least one initial message has been received on - * the stream. In polling mode, it means that the last poll request - * succeeded. - */ - kValid = 1, - - /** - * Indicates that the data source encountered an error that it will - * attempt to recover from. - * - * In streaming mode, this means that the stream connection failed, or - * had to be dropped due to some other error, and will be retried after - * a backoff delay. In polling mode, it means that the last poll request - * failed, and a new poll request will be made after the configured - * polling interval. - */ - kInterrupted = 2, - - /** - * Indicates that the application has told the SDK to stay offline. - */ - kSetOffline = 3, - - /** - * Indicates that the data source has been permanently shut down. - * - * This could be because it encountered an unrecoverable error (for - * instance, the LaunchDarkly service rejected the SDK key; an invalid - * SDK key will never become valid), or because the SDK client was - * explicitly shut down. - */ - kShutdown = 4, - - // BackgroundDisabled, - // TODO: A plugin of sorts would likely be required for some - // functionality like this. kNetworkUnavailable, - }; + kInitializing = 0, /** - * A description of an error condition that the data source encountered. + * Indicates that the data source is currently operational and has not + * had any problems since the last time it received data. + * + * In streaming mode, this means that there is currently an open stream + * connection and that at least one initial message has been received on + * the stream. In polling mode, it means that the last poll request + * succeeded. */ - class ErrorInfo { - public: - using StatusCodeType = uint64_t; - - /** - * An enumeration describing the general type of an error. - */ - enum class ErrorKind { - /** - * An unexpected error, such as an uncaught exception, further - * described by the error message. - */ - kUnknown = 0, - - /** - * An I/O error such as a dropped connection. - */ - kNetworkError = 1, - - /** - * The LaunchDarkly service returned an HTTP response with an error - * status, available in the status code. - */ - kErrorResponse = 2, - - /** - * The SDK received malformed data from the LaunchDarkly service. - */ - kInvalidData = 3, - - /** - * The data source itself is working, but when it tried to put an - * update into the data store, the data store failed (so the SDK may - * not have the latest data). - */ - kStoreError = 4 - }; - - /** - * An enumerated value representing the general category of the error. - */ - [[nodiscard]] ErrorKind Kind() const; - - /** - * The HTTP status code if the error was ErrorKind::kErrorResponse. - */ - [[nodiscard]] StatusCodeType StatusCode() const; - - /** - * Any additional human-readable information relevant to the error. - * - * The format is subject to change and should not be relied on - * programmatically. - */ - [[nodiscard]] std::string const& Message() const; - - /** - * The date/time that the error occurred. - */ - [[nodiscard]] DateTime Time() const; - - ErrorInfo(ErrorKind kind, - StatusCodeType status_code, - std::string message, - DateTime time); - - private: - ErrorKind kind_; - StatusCodeType status_code_; - std::string message_; - DateTime time_; - }; + kValid = 1, /** - * An enumerated value representing the overall current state of the data - * source. + * Indicates that the data source encountered an error that it will + * attempt to recover from. + * + * In streaming mode, this means that the stream connection failed, or + * had to be dropped due to some other error, and will be retried after + * a backoff delay. In polling mode, it means that the last poll request + * failed, and a new poll request will be made after the configured + * polling interval. */ - [[nodiscard]] DataSourceState State() const; + kInterrupted = 2, /** - * The date/time that the value of State most recently changed. - * - * The meaning of this depends on the current state: - * - For DataSourceState::kInitializing, it is the time that the SDK started - * initializing. - * - For DataSourceState::kValid, it is the time that the data - * source most recently entered a valid state, after previously having been - * DataSourceState::kInitializing or an invalid state such as - * DataSourceState::kInterrupted. - * - For DataSourceState::kInterrupted, it is the time that the data source - * most recently entered an error state, after previously having been - * DataSourceState::kValid. - * - For DataSourceState::kShutdown, it is the time that the data source - * encountered an unrecoverable error or that the SDK was explicitly shut - * down. + * Indicates that the application has told the SDK to stay offline. */ - [[nodiscard]] DateTime StateSince() const; + kSetOffline = 3, /** - * Information about the last error that the data source encountered, if - * any. + * Indicates that the data source has been permanently shut down. * - * This property should be updated whenever the data source encounters a - * problem, even if it does not cause the state to change. For instance, if - * a stream connection fails and the state changes to - * DataSourceState::kInterrupted, and then subsequent attempts to restart - * the connection also fail, the state will remain - * DataSourceState::kInterrupted but the error information will be updated - * each time-- and the last error will still be reported in this property - * even if the state later becomes DataSourceState::kValid. + * This could be because it encountered an unrecoverable error (for + * instance, the LaunchDarkly service rejected the SDK key; an invalid + * SDK key will never become valid), or because the SDK client was + * explicitly shut down. */ - [[nodiscard]] std::optional LastError() const; - - DataSourceStatus(DataSourceState state, - DateTime state_since, - std::optional last_error); + kShutdown = 4, - DataSourceStatus(DataSourceStatus const& status); + // BackgroundDisabled, - private: - DataSourceState state_; - DateTime state_since_; - std::optional last_error_; + // TODO: A plugin of sorts would likely be required to implement + // network availability. + // kNetworkUnavailable, }; +using DataSourceStatus = + common::data_sources::DataSourceStatusBase; + /** * Interface for accessing and listening to the data source status. */ @@ -210,7 +83,7 @@ class IDataSourceStatusProvider { * The current status of the data source. Suitable for broadcast to * data source status listeners. */ - virtual DataSourceStatus Status() const = 0; + [[nodiscard]] virtual DataSourceStatus Status() const = 0; /** * Listen to changes to the data source status. @@ -246,12 +119,6 @@ class IDataSourceStatusProvider { std::ostream& operator<<(std::ostream& out, DataSourceStatus::DataSourceState const& state); -std::ostream& operator<<(std::ostream& out, - DataSourceStatus::ErrorInfo::ErrorKind const& kind); - std::ostream& operator<<(std::ostream& out, DataSourceStatus const& status); -std::ostream& operator<<(std::ostream& out, - DataSourceStatus::ErrorInfo const& error); - } // namespace launchdarkly::client_side::data_sources diff --git a/libs/client-sdk/src/CMakeLists.txt b/libs/client-sdk/src/CMakeLists.txt index 4ae3f9557..f4a5081f7 100644 --- a/libs/client-sdk/src/CMakeLists.txt +++ b/libs/client-sdk/src/CMakeLists.txt @@ -9,38 +9,26 @@ add_library(${LIBNAME} ${HEADER_LIST} data_sources/streaming_data_source.cpp data_sources/data_source_event_handler.cpp - data_sources/data_source_update_sink.cpp data_sources/polling_data_source.cpp flag_manager/flag_store.cpp flag_manager/flag_updater.cpp flag_manager/flag_change_event.cpp data_sources/data_source_status.cpp - data_sources/data_source_status_manager.cpp - event_processor/event_processor.cpp - event_processor/null_event_processor.cpp - boost_signal_connection.cpp client_impl.cpp client.cpp - boost_signal_connection.hpp client_impl.hpp - data_sources/data_source.hpp data_sources/data_source_event_handler.hpp data_sources/data_source_status_manager.hpp data_sources/data_source_update_sink.hpp data_sources/polling_data_source.hpp data_sources/streaming_data_source.hpp - event_processor/event_processor.hpp - event_processor/null_event_processor.hpp flag_manager/flag_store.hpp flag_manager/flag_updater.hpp - event_processor.hpp bindings/c/sdk.cpp bindings/c/builder.cpp bindings/c/config.cpp data_sources/null_data_source.cpp flag_manager/context_index.cpp - serialization/json_all_flags.hpp - serialization/json_all_flags.cpp flag_manager/flag_manager.cpp flag_manager/flag_persistence.cpp bindings/c/sdk.cpp) diff --git a/libs/client-sdk/src/bindings/c/sdk.cpp b/libs/client-sdk/src/bindings/c/sdk.cpp index cb4f7c0e6..a5c2971d1 100644 --- a/libs/client-sdk/src/bindings/c/sdk.cpp +++ b/libs/client-sdk/src/bindings/c/sdk.cpp @@ -24,9 +24,6 @@ struct Detail; launchdarkly::client_side::data_sources::DataSourceStatus*>(ptr)) #define FROM_DATASOURCESTATUS(ptr) (reinterpret_cast(ptr)) -#define TO_DATASOURCESTATUS_ERRORINFO(ptr) \ - (reinterpret_cast(ptr)) #define FROM_DATASOURCESTATUS_ERRORINFO(ptr) \ (reinterpret_cast(ptr)) @@ -377,37 +374,6 @@ LD_EXPORT(time_t) LDDataSourceStatus_StateSince(LDDataSourceStatus status) { .count(); } -LD_EXPORT(LDDataSourceStatus_ErrorKind) -LDDataSourceStatus_ErrorInfo_GetKind(LDDataSourceStatus_ErrorInfo info) { - LD_ASSERT_NOT_NULL(info); - - return static_cast( - TO_DATASOURCESTATUS_ERRORINFO(info)->Kind()); -} - -LD_EXPORT(uint64_t) -LDDataSourceStatus_ErrorInfo_StatusCode(LDDataSourceStatus_ErrorInfo info) { - LD_ASSERT_NOT_NULL(info); - - return TO_DATASOURCESTATUS_ERRORINFO(info)->StatusCode(); -} - -LD_EXPORT(char const*) -LDDataSourceStatus_ErrorInfo_Message(LDDataSourceStatus_ErrorInfo info) { - LD_ASSERT_NOT_NULL(info); - - return TO_DATASOURCESTATUS_ERRORINFO(info)->Message().c_str(); -} - -LD_EXPORT(time_t) -LDDataSourceStatus_ErrorInfo_Time(LDDataSourceStatus_ErrorInfo info) { - LD_ASSERT_NOT_NULL(info); - - return std::chrono::duration_cast( - TO_DATASOURCESTATUS_ERRORINFO(info)->Time().time_since_epoch()) - .count(); -} - LD_EXPORT(void) LDDataSourceStatusListener_Init(struct LDDataSourceStatusListener* listener) { listener->StatusChanged = nullptr; @@ -445,10 +411,5 @@ LD_EXPORT(void) LDDataSourceStatus_Free(LDDataSourceStatus status) { delete TO_DATASOURCESTATUS(status); } -LD_EXPORT(void) -LDDataSourceStatus_ErrorInfo_Free(LDDataSourceStatus_ErrorInfo info) { - delete TO_DATASOURCESTATUS_ERRORINFO(info); -} - // NOLINTEND cppcoreguidelines-pro-type-reinterpret-cast // NOLINTEND OCInconsistentNamingInspection diff --git a/libs/client-sdk/src/client_impl.cpp b/libs/client-sdk/src/client_impl.cpp index ed16cbe32..2b4aae39a 100644 --- a/libs/client-sdk/src/client_impl.cpp +++ b/libs/client-sdk/src/client_impl.cpp @@ -1,21 +1,19 @@ - -#include - -#include -#include - #include "client_impl.hpp" #include "data_sources/null_data_source.hpp" #include "data_sources/polling_data_source.hpp" #include "data_sources/streaming_data_source.hpp" -#include "event_processor/event_processor.hpp" -#include "event_processor/null_event_processor.hpp" +#include +#include #include #include #include +#include +#include +#include + namespace launchdarkly::client_side { // The ASIO implementation assumes that the io_context will be run from a @@ -32,14 +30,14 @@ using launchdarkly::client_side::data_sources::DataSourceStatus; using launchdarkly::config::shared::built::DataSourceConfig; using launchdarkly::config::shared::built::HttpProperties; -static std::shared_ptr MakeDataSource( - HttpProperties const& http_properties, - Config const& config, - Context const& context, - boost::asio::any_io_executor const& executor, - IDataSourceUpdateSink& flag_updater, - data_sources::DataSourceStatusManager& status_manager, - Logger& logger) { +static std::shared_ptr<::launchdarkly::data_sources::IDataSource> +MakeDataSource(HttpProperties const& http_properties, + Config const& config, + Context const& context, + boost::asio::any_io_executor const& executor, + IDataSourceUpdateSink& flag_updater, + data_sources::DataSourceStatusManager& status_manager, + Logger& logger) { if (config.Offline()) { return std::make_shared(executor, status_manager); @@ -112,14 +110,15 @@ ClientImpl::ClientImpl(Config config, flag_manager_.LoadCache(context_); if (config.Events().Enabled() && !config.Offline()) { - event_processor_ = std::make_unique( - ioc_.get_executor(), config.ServiceEndpoints(), config.Events(), - http_properties_, logger_); + event_processor_ = + std::make_unique>( + ioc_.get_executor(), config.ServiceEndpoints(), config.Events(), + http_properties_, logger_); } else { - event_processor_ = std::make_unique(); + event_processor_ = std::make_unique(); } - event_processor_->SendAsync(events::client::IdentifyEventParams{ + event_processor_->SendAsync(events::IdentifyEventParams{ std::chrono::system_clock::now(), context_}); run_thread_ = std::move(std::thread([&]() { ioc_.run(); })); @@ -143,7 +142,7 @@ static bool IsInitialized(DataSourceStatus::DataSourceState state) { std::future ClientImpl::IdentifyAsync(Context context) { UpdateContextSynchronized(context); flag_manager_.LoadCache(context); - event_processor_->SendAsync(events::client::IdentifyEventParams{ + event_processor_->SendAsync(events::IdentifyEventParams{ std::chrono::system_clock::now(), std::move(context)}); return StartAsyncInternal(IsInitializedSuccessfully); @@ -192,8 +191,8 @@ bool ClientImpl::Initialized() const { std::unordered_map ClientImpl::AllFlags() const { std::unordered_map result; for (auto& [key, descriptor] : flag_manager_.Store().GetAll()) { - if (descriptor->flag) { - result.try_emplace(key, descriptor->flag->Detail().Value()); + if (descriptor->item) { + result.try_emplace(key, descriptor->item->Detail().Value()); } } return result; @@ -234,7 +233,7 @@ EvaluationDetail ClientImpl::VariationInternal(FlagKey const& key, bool detailed) { auto desc = flag_manager_.Store().Get(key); - events::client::FeatureEventParams event = { + events::FeatureEventParams event = { std::chrono::system_clock::now(), key, ReadContextSynchronized([](Context const& c) { return c; }), @@ -247,13 +246,12 @@ EvaluationDetail ClientImpl::VariationInternal(FlagKey const& key, std::nullopt, }; - if (!desc || !desc->flag) { + if (!desc || !desc->item) { if (!Initialized()) { LD_LOG(logger_, LogLevel::kWarn) << "LaunchDarkly client has not yet been initialized. " "Returning default value"; - // TODO: SC-199918 auto error_reason = EvaluationReason(EvaluationReason::ErrorKind::kClientNotReady); if (eval_reasons_available_) { @@ -282,9 +280,9 @@ EvaluationDetail ClientImpl::VariationInternal(FlagKey const& key, "Returning cached value"; } - assert(desc->flag); + assert(desc->item); - auto const& flag = *(desc->flag); + auto const& flag = *(desc->item); auto const& detail = flag.Detail(); if (check_type && default_value.Type() != Value::Type::kNull && diff --git a/libs/client-sdk/src/client_impl.hpp b/libs/client-sdk/src/client_impl.hpp index 20ab4cf5d..c273e2ad1 100644 --- a/libs/client-sdk/src/client_impl.hpp +++ b/libs/client-sdk/src/client_impl.hpp @@ -11,7 +11,7 @@ #include #include -#include "tl/expected.hpp" +#include #include #include @@ -19,13 +19,14 @@ #include #include #include +#include #include #include #include -#include "data_sources/data_source.hpp" +#include + #include "data_sources/data_source_status_manager.hpp" -#include "event_processor.hpp" #include "flag_manager/flag_manager.hpp" namespace launchdarkly::client_side { @@ -37,7 +38,7 @@ class ClientImpl : public IClient { ClientImpl(ClientImpl const&) = delete; ClientImpl& operator=(ClientImpl) = delete; ClientImpl& operator=(ClientImpl&& other) = delete; - + bool Initialized() const override; using FlagKey = std::string; @@ -129,11 +130,12 @@ class ClientImpl : public IClient { mutable std::shared_mutex context_mutex_; flag_manager::FlagManager flag_manager_; - std::function()> data_source_factory_; + std::function()> + data_source_factory_; - std::shared_ptr data_source_; + std::shared_ptr<::launchdarkly::data_sources::IDataSource> data_source_; - std::unique_ptr event_processor_; + std::unique_ptr event_processor_; mutable std::mutex init_mutex_; std::condition_variable init_waiter_; diff --git a/libs/client-sdk/src/data_sources/data_source_event_handler.cpp b/libs/client-sdk/src/data_sources/data_source_event_handler.cpp index a2a13a4fb..fe182d81d 100644 --- a/libs/client-sdk/src/data_sources/data_source_event_handler.cpp +++ b/libs/client-sdk/src/data_sources/data_source_event_handler.cpp @@ -1,8 +1,9 @@ #include "data_source_event_handler.hpp" -#include "../serialization/json_all_flags.hpp" #include #include +#include +#include #include #include @@ -35,13 +36,14 @@ static tl::expected tag_invoke( auto const& obj = json_value.as_object(); auto const* key_iter = obj.find("key"); auto key = ValueAsOpt(key_iter, obj.end()); - auto result = - boost::json::value_to>( - json_value); + auto result = boost::json::value_to< + tl::expected, JsonError>>( + json_value); - if (result.has_value() && key.has_value()) { + if (result.has_value() && result.value().has_value() && + key.has_value()) { return DataSourceEventHandler::PatchData{key.value(), - result.value()}; + result.value().value()}; } } return tl::unexpected(JsonError::kSchemaFailure); @@ -93,11 +95,15 @@ DataSourceEventHandler::MessageStatus DataSourceEventHandler::HandleMessage( return DataSourceEventHandler::MessageStatus::kInvalidMessage; } auto res = boost::json::value_to, JsonError>>( - parsed); + std::optional>, + JsonError>>(parsed); if (res.has_value()) { - handler_.Init(context_, res.value()); + // If the map was null or omitted, treat it like an empty data set. + auto map = res.value().value_or( + std::unordered_map{}); + + handler_.Init(context_, std::move(map)); status_manager_.SetState(DataSourceStatus::DataSourceState::kValid); return DataSourceEventHandler::MessageStatus::kMessageHandled; } diff --git a/libs/client-sdk/src/data_sources/data_source_event_handler.hpp b/libs/client-sdk/src/data_sources/data_source_event_handler.hpp index a7115f2aa..90866d480 100644 --- a/libs/client-sdk/src/data_sources/data_source_event_handler.hpp +++ b/libs/client-sdk/src/data_sources/data_source_event_handler.hpp @@ -2,13 +2,13 @@ #include -#include "data_source.hpp" #include "data_source_status_manager.hpp" #include "data_source_update_sink.hpp" #include #include #include +#include #include namespace launchdarkly::client_side::data_sources { diff --git a/libs/client-sdk/src/data_sources/data_source_status.cpp b/libs/client-sdk/src/data_sources/data_source_status.cpp index 56770c61d..437b5b357 100644 --- a/libs/client-sdk/src/data_sources/data_source_status.cpp +++ b/libs/client-sdk/src/data_sources/data_source_status.cpp @@ -1,59 +1,9 @@ - #include -#include #include namespace launchdarkly::client_side::data_sources { -DataSourceStatus::ErrorInfo::ErrorKind DataSourceStatus::ErrorInfo::Kind() - const { - return kind_; -} -DataSourceStatus::ErrorInfo::StatusCodeType -DataSourceStatus::ErrorInfo::StatusCode() const { - return status_code_; -} -std::string const& DataSourceStatus::ErrorInfo::Message() const { - return message_; -} -DataSourceStatus::DateTime DataSourceStatus::ErrorInfo::Time() const { - return time_; -} - -DataSourceStatus::ErrorInfo::ErrorInfo(ErrorInfo::ErrorKind kind, - ErrorInfo::StatusCodeType status_code, - std::string message, - DataSourceStatus::DateTime time) - : kind_(kind), - status_code_(status_code), - message_(std::move(message)), - time_(time) {} - -DataSourceStatus::DataSourceState DataSourceStatus::State() const { - return state_; -} - -DataSourceStatus::DateTime DataSourceStatus::StateSince() const { - return state_since_; -} - -std::optional DataSourceStatus::LastError() const { - return last_error_; -} - -DataSourceStatus::DataSourceStatus(DataSourceStatus const& status) - : state_(status.State()), - state_since_(status.StateSince()), - last_error_(status.LastError()) {} - -DataSourceStatus::DataSourceStatus(DataSourceState state, - DataSourceStatus::DateTime state_since, - std::optional last_error) - : state_(state), - state_since_(state_since), - last_error_(std::move(last_error)) {} - std::ostream& operator<<(std::ostream& out, DataSourceStatus::DataSourceState const& state) { switch (state) { @@ -77,28 +27,6 @@ std::ostream& operator<<(std::ostream& out, return out; } -std::ostream& operator<<(std::ostream& out, - DataSourceStatus::ErrorInfo::ErrorKind const& kind) { - switch (kind) { - case DataSourceStatus::ErrorInfo::ErrorKind::kUnknown: - out << "UNKNOWN"; - break; - case DataSourceStatus::ErrorInfo::ErrorKind::kNetworkError: - out << "NETWORK_ERROR"; - break; - case DataSourceStatus::ErrorInfo::ErrorKind::kErrorResponse: - out << "ERROR_RESPONSE"; - break; - case DataSourceStatus::ErrorInfo::ErrorKind::kInvalidData: - out << "INVALID_DATA"; - break; - case DataSourceStatus::ErrorInfo::ErrorKind::kStoreError: - out << "STORE_ERROR"; - break; - } - return out; -} - std::ostream& operator<<(std::ostream& out, DataSourceStatus const& status) { std::time_t as_time_t = std::chrono::system_clock::to_time_t(status.StateSince()); @@ -111,13 +39,4 @@ std::ostream& operator<<(std::ostream& out, DataSourceStatus const& status) { return out; } -std::ostream& operator<<(std::ostream& out, - DataSourceStatus::ErrorInfo const& error) { - std::time_t as_time_t = std::chrono::system_clock::to_time_t(error.Time()); - out << "Error(" << error.Kind() << ", " << error.Message() - << ", StatusCode(" << error.StatusCode() << "), Since(" - << std::put_time(std::gmtime(&as_time_t), "%Y-%m-%d %H:%M:%S") << "))"; - return out; -} - } // namespace launchdarkly::client_side::data_sources diff --git a/libs/client-sdk/src/data_sources/data_source_status_manager.cpp b/libs/client-sdk/src/data_sources/data_source_status_manager.cpp deleted file mode 100644 index c7f196868..000000000 --- a/libs/client-sdk/src/data_sources/data_source_status_manager.cpp +++ /dev/null @@ -1,128 +0,0 @@ -#include -#include -#include -#include - -#include - -#include "../boost_signal_connection.hpp" -#include "data_source_status_manager.hpp" - -namespace launchdarkly::client_side::data_sources { - -void DataSourceStatusManager::SetState( - DataSourceStatus::DataSourceState state) { - bool changed = UpdateState(state); - if (changed) { - data_source_status_signal_(std::move(Status())); - } -} - -void DataSourceStatusManager::SetState( - DataSourceStatus::DataSourceState state, - DataSourceStatus::ErrorInfo::StatusCodeType code, - std::string message) { - { - std::lock_guard lock(status_mutex_); - - UpdateState(state); - - last_error_ = DataSourceStatus::ErrorInfo( - DataSourceStatus::ErrorInfo::ErrorKind::kErrorResponse, code, - message, std::chrono::system_clock::now()); - } - - data_source_status_signal_(std::move(Status())); -} -bool DataSourceStatusManager::UpdateState( - DataSourceStatus::DataSourceState const& requested_state) { - std::lock_guard lock(status_mutex_); - - // If initializing, then interruptions remain initializing. - auto new_state = - (requested_state == DataSourceStatus::DataSourceState::kInterrupted && - state_ == DataSourceStatus::DataSourceState::kInitializing) - ? DataSourceStatus::DataSourceState:: - kInitializing // see comment on - // IDataSourceUpdateSink.UpdateStatus - : requested_state; - auto changed = state_ != new_state; - if (changed) { - state_ = new_state; - state_since_ = std::chrono::system_clock::now(); - } - return changed; -} - -void DataSourceStatusManager::SetState( - DataSourceStatus::DataSourceState state, - DataSourceStatus::ErrorInfo::ErrorKind kind, - std::string message) { - { - std::lock_guard lock(status_mutex_); - - UpdateState(state); - - last_error_ = DataSourceStatus::ErrorInfo( - kind, 0, std::move(message), std::chrono::system_clock::now()); - } - - data_source_status_signal_(Status()); -} - -void DataSourceStatusManager::SetError( - DataSourceStatus::ErrorInfo::ErrorKind kind, - std::string message) { - { - std::lock_guard lock(status_mutex_); - last_error_ = DataSourceStatus::ErrorInfo( - kind, 0, std::move(message), std::chrono::system_clock::now()); - state_since_ = std::chrono::system_clock::now(); - } - - data_source_status_signal_(Status()); -} - -void DataSourceStatusManager::SetError( - DataSourceStatus::ErrorInfo::StatusCodeType code, - std::string message) { - { - std::lock_guard lock(status_mutex_); - last_error_ = DataSourceStatus::ErrorInfo( - DataSourceStatus::ErrorInfo::ErrorKind::kErrorResponse, code, - message, std::chrono::system_clock::now()); - state_since_ = std::chrono::system_clock::now(); - } - data_source_status_signal_(Status()); -} - -DataSourceStatus DataSourceStatusManager::Status() const { - std::lock_guard lock(status_mutex_); - return {state_, state_since_, last_error_}; -} - -std::unique_ptr DataSourceStatusManager::OnDataSourceStatusChange( - std::function handler) { - std::lock_guard lock{status_mutex_}; - return std::make_unique< ::launchdarkly::client_side::SignalConnection>( - data_source_status_signal_.connect(handler)); -} - -std::unique_ptr -DataSourceStatusManager::OnDataSourceStatusChangeEx( - std::function handler) { - std::lock_guard lock{status_mutex_}; - return std::make_unique< ::launchdarkly::client_side::SignalConnection>( - data_source_status_signal_.connect_extended( - [handler](boost::signals2::connection const& conn, - data_sources::DataSourceStatus status) { - if (handler(status)) { - conn.disconnect(); - } - })); -} -DataSourceStatusManager::DataSourceStatusManager() - : state_(DataSourceStatus::DataSourceState::kInitializing), - state_since_(std::chrono::system_clock::now()) {} - -} // namespace launchdarkly::client_side::data_sources diff --git a/libs/client-sdk/src/data_sources/data_source_status_manager.hpp b/libs/client-sdk/src/data_sources/data_source_status_manager.hpp index 62bab885c..b2b52eb4e 100644 --- a/libs/client-sdk/src/data_sources/data_source_status_manager.hpp +++ b/libs/client-sdk/src/data_sources/data_source_status_manager.hpp @@ -7,89 +7,22 @@ #include #include +#include namespace launchdarkly::client_side::data_sources { -/** - * Class that manages updates to the data source status and implements an - * interface to get the current status and listen to status changes. - */ -class DataSourceStatusManager : public IDataSourceStatusProvider { +class DataSourceStatusManager + : public internal::data_sources::DataSourceStatusManagerBase< + DataSourceStatus, + IDataSourceStatusProvider> { public: - using DataSourceStatusHandler = - std::function; + DataSourceStatusManager() = default; - /** - * Set the state. - * - * @param state The new state. - */ - void SetState(DataSourceStatus::DataSourceState state); - - /** - * If an error and state change happen simultaneously, then they should - * be updated simultaneously. - * - * @param state The new state. - * @param code Status code for an http error. - * @param message The message to associate with the error. - */ - void SetState(DataSourceStatus::DataSourceState state, - DataSourceStatus::ErrorInfo::StatusCodeType code, - std::string message); - - /** - * If an error and state change happen simultaneously, then they should - * be updated simultaneously. - * - * @param state The new state. - * @param kind The error kind. - * @param message The message to associate with the error. - */ - void SetState(DataSourceStatus::DataSourceState state, - DataSourceStatus::ErrorInfo::ErrorKind kind, - std::string message); - - /** - * Set an error with the given kind and message. - * - * For ErrorInfo::ErrorKind::kErrorResponse use the - * SetError(ErrorInfo::StatusCodeType) method. - * @param kind The kind of the error. - * @param message A message for the error. - */ - void SetError(DataSourceStatus::ErrorInfo::ErrorKind kind, - std::string message); - - /** - * Set an error based on the given status code. - * @param code The status code of the error. - */ - void SetError(DataSourceStatus::ErrorInfo::StatusCodeType code, - std::string message); - // TODO: Handle error codes once the EventSource supports it. sc-204392 - - DataSourceStatus Status() const override; - - std::unique_ptr OnDataSourceStatusChange( - std::function handler) - override; - - std::unique_ptr OnDataSourceStatusChangeEx( - std::function handler) - override; - - DataSourceStatusManager(); - - private: - DataSourceStatus::DataSourceState state_; - DataSourceStatus::DateTime state_since_; - std::optional last_error_; - - boost::signals2::signal - data_source_status_signal_; - mutable std::recursive_mutex status_mutex_; - bool UpdateState(DataSourceStatus::DataSourceState const& requested_state); + ~DataSourceStatusManager() override = default; + DataSourceStatusManager(DataSourceStatusManager const& item) = delete; + DataSourceStatusManager(DataSourceStatusManager&& item) = delete; + DataSourceStatusManager& operator=(DataSourceStatusManager const&) = delete; + DataSourceStatusManager& operator=(DataSourceStatusManager&&) = delete; }; } // namespace launchdarkly::client_side::data_sources diff --git a/libs/client-sdk/src/data_sources/data_source_update_sink.cpp b/libs/client-sdk/src/data_sources/data_source_update_sink.cpp deleted file mode 100644 index cff337160..000000000 --- a/libs/client-sdk/src/data_sources/data_source_update_sink.cpp +++ /dev/null @@ -1,23 +0,0 @@ -#include "data_source_update_sink.hpp" - -namespace launchdarkly::client_side { - -bool operator==(ItemDescriptor const& lhs, ItemDescriptor const& rhs) { - return lhs.version == rhs.version && lhs.flag == rhs.flag; -} - -std::ostream& operator<<(std::ostream& out, ItemDescriptor const& descriptor) { - out << "{"; - out << " version: " << descriptor.version; - if (descriptor.flag.has_value()) { - out << " flag: " << descriptor.flag.value(); - } else { - out << " flag: "; - } - return out; -} -ItemDescriptor::ItemDescriptor(uint64_t version) : version(version) {} - -ItemDescriptor::ItemDescriptor(EvaluationResult flag) - : version(flag.Version()), flag(std::move(flag)) {} -} // namespace launchdarkly::client_side diff --git a/libs/client-sdk/src/data_sources/data_source_update_sink.hpp b/libs/client-sdk/src/data_sources/data_source_update_sink.hpp index b08d9daca..1149618c9 100644 --- a/libs/client-sdk/src/data_sources/data_source_update_sink.hpp +++ b/libs/client-sdk/src/data_sources/data_source_update_sink.hpp @@ -9,37 +9,11 @@ #include #include #include +#include namespace launchdarkly::client_side { -/** - * An item descriptor is an abstraction that allows for Flag data to be - * handled using the same type in both a put or a patch. - */ -struct ItemDescriptor { - /** - * The version number of this data, provided by the SDK. - */ - uint64_t version; - - /** - * The data item, or nullopt if this is a deleted item placeholder. - */ - std::optional flag; - - explicit ItemDescriptor(uint64_t version); - - explicit ItemDescriptor(EvaluationResult flag); - - ItemDescriptor(ItemDescriptor const& item) = default; - ItemDescriptor(ItemDescriptor&& item) = default; - ItemDescriptor& operator=(ItemDescriptor const&) = default; - ItemDescriptor& operator=(ItemDescriptor&&) = default; - ~ItemDescriptor() = default; - - friend std::ostream& operator<<(std::ostream& out, - ItemDescriptor const& descriptor); -}; +using ItemDescriptor = data_model::ItemDescriptor; /** * Interface for handling updates from LaunchDarkly. @@ -62,6 +36,4 @@ class IDataSourceUpdateSink { IDataSourceUpdateSink() = default; }; -bool operator==(ItemDescriptor const& lhs, ItemDescriptor const& rhs); - } // namespace launchdarkly::client_side diff --git a/libs/client-sdk/src/data_sources/null_data_source.hpp b/libs/client-sdk/src/data_sources/null_data_source.hpp index b99ec07f0..e2fa7cc22 100644 --- a/libs/client-sdk/src/data_sources/null_data_source.hpp +++ b/libs/client-sdk/src/data_sources/null_data_source.hpp @@ -2,12 +2,13 @@ #include -#include "data_source.hpp" #include "data_source_status_manager.hpp" +#include + namespace launchdarkly::client_side::data_sources { -class NullDataSource : public IDataSource { +class NullDataSource : public ::launchdarkly::data_sources::IDataSource { public: explicit NullDataSource(boost::asio::any_io_executor exec, DataSourceStatusManager& status_manager); diff --git a/libs/client-sdk/src/data_sources/polling_data_source.hpp b/libs/client-sdk/src/data_sources/polling_data_source.hpp index 53e7656c1..62874b1f5 100644 --- a/libs/client-sdk/src/data_sources/polling_data_source.hpp +++ b/libs/client-sdk/src/data_sources/polling_data_source.hpp @@ -4,7 +4,6 @@ #include -#include "data_source.hpp" #include "data_source_event_handler.hpp" #include "data_source_status_manager.hpp" #include "data_source_update_sink.hpp" @@ -12,13 +11,14 @@ #include #include #include +#include #include #include namespace launchdarkly::client_side::data_sources { class PollingDataSource - : public IDataSource, + : public ::launchdarkly::data_sources::IDataSource, public std::enable_shared_from_this { public: PollingDataSource( diff --git a/libs/client-sdk/src/data_sources/streaming_data_source.hpp b/libs/client-sdk/src/data_sources/streaming_data_source.hpp index a8eec90af..430e77888 100644 --- a/libs/client-sdk/src/data_sources/streaming_data_source.hpp +++ b/libs/client-sdk/src/data_sources/streaming_data_source.hpp @@ -5,7 +5,6 @@ using namespace std::chrono_literals; #include -#include "data_source.hpp" #include "data_source_event_handler.hpp" #include "data_source_status_manager.hpp" #include "data_source_update_sink.hpp" @@ -16,13 +15,14 @@ using namespace std::chrono_literals; #include #include #include +#include #include #include namespace launchdarkly::client_side::data_sources { class StreamingDataSource final - : public IDataSource, + : public ::launchdarkly::data_sources::IDataSource, public std::enable_shared_from_this { public: StreamingDataSource( diff --git a/libs/client-sdk/src/event_processor/event_processor.cpp b/libs/client-sdk/src/event_processor/event_processor.cpp deleted file mode 100644 index ac78fa1c6..000000000 --- a/libs/client-sdk/src/event_processor/event_processor.cpp +++ /dev/null @@ -1,25 +0,0 @@ -#include "event_processor.hpp" - -namespace launchdarkly::client_side { - -EventProcessor::EventProcessor( - boost::asio::any_io_executor const& io, - config::shared::built::ServiceEndpoints const& endpoints, - config::shared::built::Events const& events_config, - config::shared::built::HttpProperties const& http_properties, - Logger& logger) - : impl_(io, endpoints, events_config, http_properties, logger) {} - -void EventProcessor::SendAsync(events::InputEvent event) { - impl_.AsyncSend(std::move(event)); -} - -void EventProcessor::FlushAsync() { - impl_.AsyncFlush(); -} - -void EventProcessor::ShutdownAsync() { - impl_.AsyncClose(); -} - -} // namespace launchdarkly::client_side diff --git a/libs/client-sdk/src/event_processor/event_processor.hpp b/libs/client-sdk/src/event_processor/event_processor.hpp deleted file mode 100644 index dd8d7b06d..000000000 --- a/libs/client-sdk/src/event_processor/event_processor.hpp +++ /dev/null @@ -1,29 +0,0 @@ -#pragma once - -#include - -#include -#include -#include -#include - -#include "../event_processor.hpp" - -namespace launchdarkly::client_side { - -class EventProcessor : public IEventProcessor { - public: - EventProcessor(boost::asio::any_io_executor const& io, - config::shared::built::ServiceEndpoints const& endpoints, - config::shared::built::Events const& events_config, - config::shared::built::HttpProperties const& http_properties, - Logger& logger); - void SendAsync(events::InputEvent event) override; - void FlushAsync() override; - void ShutdownAsync() override; - - private: - events::AsioEventProcessor impl_; -}; - -} // namespace launchdarkly::client_side diff --git a/libs/client-sdk/src/flag_manager/flag_persistence.cpp b/libs/client-sdk/src/flag_manager/flag_persistence.cpp index 2ad273435..5423c7028 100644 --- a/libs/client-sdk/src/flag_manager/flag_persistence.cpp +++ b/libs/client-sdk/src/flag_manager/flag_persistence.cpp @@ -1,9 +1,12 @@ #include "flag_persistence.hpp" -#include "../serialization/json_all_flags.hpp" #include #include +#include +#include +#include + #include namespace launchdarkly::client_side::flag_manager { @@ -72,8 +75,7 @@ void FlagPersistence::LoadCached(Context const& context) { } auto res = boost::json::value_to, + std::optional>, JsonError>>(parsed); if (!res) { LD_LOG(logger_, LogLevel::kError) @@ -81,7 +83,12 @@ void FlagPersistence::LoadCached(Context const& context) { << error_code.message(); return; } - sink_.Init(context, *res); + + // If the map was null or omitted, treat it like an empty data set. + auto map = + res.value().value_or(std::unordered_map{}); + + sink_.Init(context, std::move(map)); } void FlagPersistence::StoreCache(std::string const& context_id) { @@ -99,9 +106,10 @@ void FlagPersistence::StoreCache(std::string const& context_id) { persistence_->Set(environment_namespace_, index_key_, boost::json::serialize(boost::json::value_from(index))); - persistence_->Set( - environment_namespace_, context_id, - boost::json::serialize(boost::json::value_from(flag_store_.GetAll()))); + boost::json::value v = boost::json::value_from(flag_store_.GetAll()); + + persistence_->Set(environment_namespace_, context_id, + boost::json::serialize(v)); } ContextIndex FlagPersistence::GetIndex() { diff --git a/libs/client-sdk/src/flag_manager/flag_store.cpp b/libs/client-sdk/src/flag_manager/flag_store.cpp index acd7a43e8..dfcdb8da2 100644 --- a/libs/client-sdk/src/flag_manager/flag_store.cpp +++ b/libs/client-sdk/src/flag_manager/flag_store.cpp @@ -1,6 +1,5 @@ #include -#include "../serialization/json_all_flags.hpp" #include "flag_store.hpp" #include diff --git a/libs/client-sdk/src/flag_manager/flag_updater.cpp b/libs/client-sdk/src/flag_manager/flag_updater.cpp index 9e98bd27f..4a316f8d8 100644 --- a/libs/client-sdk/src/flag_manager/flag_updater.cpp +++ b/libs/client-sdk/src/flag_manager/flag_updater.cpp @@ -1,6 +1,7 @@ #include -#include "../boost_signal_connection.hpp" +#include + #include "flag_updater.hpp" namespace launchdarkly::client_side::flag_manager { @@ -8,10 +9,10 @@ namespace launchdarkly::client_side::flag_manager { FlagUpdater::FlagUpdater(FlagStore& flag_store) : flag_store_(flag_store) {} Value GetValue(ItemDescriptor& descriptor) { - if (descriptor.flag) { + if (descriptor.item) { // `flag->` unwraps the first optional we know is present. // The second `value()` is not an optional. - return descriptor.flag->Detail().Value(); + return descriptor.item->Detail().Value(); } return {}; } @@ -31,7 +32,7 @@ void FlagUpdater::Init(Context const& context, auto existing = old_flags.find(new_pair.first); if (existing != old_flags.end()) { // The flag changed. - auto& evaluation_result = new_pair.second.flag; + auto& evaluation_result = new_pair.second.item; if (evaluation_result) { auto new_value = GetValue(new_pair.second); auto old_value = GetValue(*existing->second); @@ -74,7 +75,7 @@ void FlagUpdater::DispatchEvent(FlagValueChangeEvent event) { auto handler = signals_.find(event.FlagName()); if (handler != signals_.end()) { if (handler->second.empty()) { - // Empty, remove it from the map so it doesn't count toward + // Empty, remove it from the map, so it doesn't count toward // future calculations. signals_.erase(event.FlagName()); } else { @@ -86,24 +87,24 @@ void FlagUpdater::DispatchEvent(FlagValueChangeEvent event) { void FlagUpdater::Upsert(Context const& context, std::string key, - ItemDescriptor item) { + ItemDescriptor descriptor) { // Check the version. auto existing = flag_store_.Get(key); - if (existing && (existing->version >= item.version)) { + if (existing && (existing->version >= descriptor.version)) { // Out of order update, ignore it. return; } if (HasListeners()) { // Existed and updated. - if (existing && item.flag) { - DispatchEvent( - FlagValueChangeEvent(key, GetValue(item), GetValue(*existing))); - } else if (item.flag) { + if (existing && descriptor.item) { + DispatchEvent(FlagValueChangeEvent(key, GetValue(descriptor), + GetValue(*existing))); + } else if (descriptor.item) { DispatchEvent(FlagValueChangeEvent( - key, item.flag.value().Detail().Value(), Value())); + key, descriptor.item.value().Detail().Value(), Value())); // new flag - } else if (existing && existing->flag.has_value()) { + } else if (existing && existing->item.has_value()) { // Existed and deleted. DispatchEvent(FlagValueChangeEvent(key, GetValue(*existing))); } else { @@ -111,7 +112,7 @@ void FlagUpdater::Upsert(Context const& context, // Do nothing. } } - flag_store_.Upsert(key, item); + flag_store_.Upsert(key, descriptor); } bool FlagUpdater::HasListeners() const { @@ -123,7 +124,7 @@ std::unique_ptr FlagUpdater::OnFlagChange( std::string const& key, std::function)> handler) { std::lock_guard lock{signal_mutex_}; - return std::make_unique< ::launchdarkly::client_side::SignalConnection>( + return std::make_unique( signals_[key].connect(handler)); } diff --git a/libs/client-sdk/src/serialization/json_all_flags.cpp b/libs/client-sdk/src/serialization/json_all_flags.cpp deleted file mode 100644 index 663b05374..000000000 --- a/libs/client-sdk/src/serialization/json_all_flags.cpp +++ /dev/null @@ -1,54 +0,0 @@ -#include - -#include "json_all_flags.hpp" - -#include - -namespace launchdarkly::client_side { -// This tag_invoke needs to be in the same namespace as the -// ItemDescriptor. - -tl::expected, JsonError> -tag_invoke(boost::json::value_to_tag< - tl::expected, - JsonError>> const& unused, - boost::json::value const& json_value) { - boost::ignore_unused(unused); - - if (!json_value.is_object()) { - return tl::unexpected(JsonError::kSchemaFailure); - } - auto const& obj = json_value.as_object(); - std::unordered_map descriptors; - for (auto const& pair : obj) { - auto eval_result = - boost::json::value_to>( - pair.value()); - if (!eval_result.has_value()) { - return tl::unexpected(JsonError::kSchemaFailure); - } - descriptors.emplace(pair.key(), - ItemDescriptor(std::move(eval_result.value()))); - } - return descriptors; -} - -void tag_invoke( - boost::json::value_from_tag const& unused, - boost::json::value& json_value, - std::unordered_map> const& - all_flags) { - boost::ignore_unused(unused); - - auto& obj = json_value.emplace_object(); - for (auto descriptor : all_flags) { - // Only serialize non-deleted flags. - if (descriptor.second->flag) { - auto eval_result_json = - boost::json::value_from(*descriptor.second->flag); - obj.emplace(descriptor.first, eval_result_json); - } - } -} - -} // namespace launchdarkly::client_side diff --git a/libs/client-sdk/src/serialization/json_all_flags.hpp b/libs/client-sdk/src/serialization/json_all_flags.hpp deleted file mode 100644 index 18150002e..000000000 --- a/libs/client-sdk/src/serialization/json_all_flags.hpp +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once - -#include - -#include - -#include - -#include "../data_sources/data_source_update_sink.hpp" - -#include - -namespace launchdarkly::client_side { - -tl::expected, JsonError> -tag_invoke(boost::json::value_to_tag< - tl::expected, - JsonError>> const& unused, - boost::json::value const& json_value); - -void tag_invoke( - boost::json::value_from_tag const& unused, - boost::json::value& json_value, - std::unordered_map> const& - evaluation_result); - -} // namespace launchdarkly::client_side diff --git a/libs/client-sdk/tests/data_source_status_test.cpp b/libs/client-sdk/tests/data_source_status_test.cpp new file mode 100644 index 000000000..109778287 --- /dev/null +++ b/libs/client-sdk/tests/data_source_status_test.cpp @@ -0,0 +1,36 @@ +#include + +#include + +using launchdarkly::client_side::data_sources::DataSourceStatus; + +TEST(DataSourceStatusTest, OstreamBasicStatus) { + auto time = + std::chrono::system_clock::time_point{std::chrono::milliseconds{0}}; + + DataSourceStatus status(DataSourceStatus::DataSourceState::kInitializing, + time, std::nullopt); + + std::stringstream ss; + ss << status; + EXPECT_EQ("Status(INITIALIZING, Since(1970-01-01 00:00:00))", ss.str()); +} + +TEST(DataSourceStatusTest, OStreamErrorInfo) { + auto time = + std::chrono::system_clock::time_point{std::chrono::milliseconds{0}}; + + DataSourceStatus status( + DataSourceStatus::DataSourceState::kInterrupted, time, + launchdarkly::common::data_sources::DataSourceStatusErrorInfo( + launchdarkly::common::data_sources::DataSourceStatusErrorKind:: + kInvalidData, + 404, "Bad times", time)); + + std::stringstream ss; + ss << status; + EXPECT_EQ( + "Status(INTERRUPTED, Since(1970-01-01 00:00:00), Error(INVALID_DATA, " + "Bad times, StatusCode(404), Since(1970-01-01 00:00:00)))", + ss.str()); +} diff --git a/libs/client-sdk/tests/flag_persistence_test.cpp b/libs/client-sdk/tests/flag_persistence_test.cpp index 4d61b843e..0ff1edb17 100644 --- a/libs/client-sdk/tests/flag_persistence_test.cpp +++ b/libs/client-sdk/tests/flag_persistence_test.cpp @@ -108,7 +108,7 @@ TEST(FlagPersistenceTests, CanLoadCache) { flag_persistence.LoadCached(context); // The store contains the flag loaded from the persistence. - EXPECT_EQ("test", store.Get("flagA")->flag->Detail().Value().AsString()); + EXPECT_EQ("test", store.Get("flagA")->item->Detail().Value().AsString()); } TEST(FlagPersistenceTests, EvictsContextsBeyondMax) { diff --git a/libs/client-sdk/tests/flag_store_test.cpp b/libs/client-sdk/tests/flag_store_test.cpp index 26096fe8c..818c06fe8 100644 --- a/libs/client-sdk/tests/flag_store_test.cpp +++ b/libs/client-sdk/tests/flag_store_test.cpp @@ -32,7 +32,7 @@ TEST(FlagstoreTests, HandlesInitWithData) { std::nullopt}}}}}}); EXPECT_FALSE(store.GetAll().empty()); - EXPECT_EQ("test", store.Get("flagA")->flag->Detail().Value()); + EXPECT_EQ("test", store.Get("flagA")->item->Detail().Value()); } TEST(FlagstoreTests, HandlesSecondInit) { @@ -53,7 +53,7 @@ TEST(FlagstoreTests, HandlesSecondInit) { std::nullopt}}}}}}); EXPECT_FALSE(store.GetAll().empty()); - EXPECT_EQ("test", store.Get("flagB")->flag->Detail().Value()); + EXPECT_EQ("test", store.Get("flagB")->item->Detail().Value()); EXPECT_FALSE(store.Get("flagA")); } @@ -74,8 +74,8 @@ TEST(FlagstoreTests, HandlePatchNewFlag) { std::nullopt}}}); EXPECT_FALSE(store.GetAll().empty()); - EXPECT_EQ("test", store.Get("flagA")->flag->Detail().Value()); - EXPECT_EQ("second", store.Get("flagB")->flag->Detail().Value()); + EXPECT_EQ("test", store.Get("flagA")->item->Detail().Value()); + EXPECT_EQ("second", store.Get("flagB")->item->Detail().Value()); } TEST(FlagstoreTests, HandlePatchUpdateFlag) { @@ -95,7 +95,7 @@ TEST(FlagstoreTests, HandlePatchUpdateFlag) { std::nullopt}}}); EXPECT_FALSE(store.GetAll().empty()); - EXPECT_EQ("second", store.Get("flagA")->flag->Detail().Value()); + EXPECT_EQ("second", store.Get("flagA")->item->Detail().Value()); } TEST(FlagstoreTests, HandleDelete) { @@ -111,7 +111,7 @@ TEST(FlagstoreTests, HandleDelete) { store.Upsert("flagA", ItemDescriptor{2}); EXPECT_FALSE(store.GetAll().empty()); - EXPECT_FALSE(store.Get("flagA")->flag.has_value()); + EXPECT_FALSE(store.Get("flagA")->item.has_value()); } TEST(FlagstoreTests, GetItemWhichDoesNotExist) { diff --git a/libs/client-sdk/tests/flag_updater_test.cpp b/libs/client-sdk/tests/flag_updater_test.cpp index eb58ad36a..50b1067c4 100644 --- a/libs/client-sdk/tests/flag_updater_test.cpp +++ b/libs/client-sdk/tests/flag_updater_test.cpp @@ -44,7 +44,7 @@ TEST(FlagUpdaterDataTests, HandlesInitWithData) { std::nullopt}}}}}}); EXPECT_FALSE(manager.GetAll().empty()); - EXPECT_EQ("test", manager.Get("flagA")->flag.value().Detail().Value()); + EXPECT_EQ("test", manager.Get("flagA")->item.value().Detail().Value()); } TEST(FlagUpdaterDataTests, HandlesSecondInit) { @@ -70,7 +70,7 @@ TEST(FlagUpdaterDataTests, HandlesSecondInit) { std::nullopt}}}}}}); EXPECT_FALSE(manager.GetAll().empty()); - EXPECT_EQ("test", manager.Get("flagB")->flag.value().Detail().Value()); + EXPECT_EQ("test", manager.Get("flagB")->item.value().Detail().Value()); EXPECT_FALSE(manager.Get("flagA")); } @@ -94,8 +94,8 @@ TEST(FlagUpdaterDataTests, HandlePatchNewFlag) { std::nullopt}}}); EXPECT_FALSE(manager.GetAll().empty()); - EXPECT_EQ("test", manager.Get("flagA")->flag.value().Detail().Value()); - EXPECT_EQ("second", manager.Get("flagB")->flag.value().Detail().Value()); + EXPECT_EQ("test", manager.Get("flagA")->item.value().Detail().Value()); + EXPECT_EQ("second", manager.Get("flagB")->item.value().Detail().Value()); } TEST(FlagUpdaterDataTests, HandlePatchUpdateFlag) { @@ -118,7 +118,7 @@ TEST(FlagUpdaterDataTests, HandlePatchUpdateFlag) { std::nullopt}}}); EXPECT_FALSE(manager.GetAll().empty()); - EXPECT_EQ("second", manager.Get("flagA")->flag.value().Detail().Value()); + EXPECT_EQ("second", manager.Get("flagA")->item.value().Detail().Value()); } TEST(FlagUpdaterDataTests, HandlePatchOutOfOrder) { @@ -141,7 +141,7 @@ TEST(FlagUpdaterDataTests, HandlePatchOutOfOrder) { std::nullopt}}}); EXPECT_FALSE(manager.GetAll().empty()); - EXPECT_EQ("test", manager.Get("flagA")->flag.value().Detail().Value()); + EXPECT_EQ("test", manager.Get("flagA")->item.value().Detail().Value()); } TEST(FlagUpdaterDataTests, HandleDelete) { @@ -161,7 +161,7 @@ TEST(FlagUpdaterDataTests, HandleDelete) { ItemDescriptor{2}); EXPECT_FALSE(manager.GetAll().empty()); - EXPECT_FALSE(manager.Get("flagA")->flag.has_value()); + EXPECT_FALSE(manager.Get("flagA")->item.has_value()); } TEST(FlagUpdaterDataTests, HandleDeleteOutOfOrder) { @@ -181,7 +181,7 @@ TEST(FlagUpdaterDataTests, HandleDeleteOutOfOrder) { ItemDescriptor{0}); EXPECT_FALSE(manager.GetAll().empty()); - EXPECT_EQ("test", manager.Get("flagA")->flag.value().Detail().Value()); + EXPECT_EQ("test", manager.Get("flagA")->item.value().Detail().Value()); } TEST(FlagUpdaterEventTests, InitialInitProducesNoEvents) { diff --git a/libs/common/include/launchdarkly/attribute_reference.hpp b/libs/common/include/launchdarkly/attribute_reference.hpp index 6f252f9e9..13ff74043 100644 --- a/libs/common/include/launchdarkly/attribute_reference.hpp +++ b/libs/common/include/launchdarkly/attribute_reference.hpp @@ -15,7 +15,7 @@ namespace launchdarkly { * launchdarkly::Context::Get, or to identify an attribute or nested value that * should be considered private * with launchdarkly::AttributesBuilder::SetPrivate or - * launchdarkly::AttributesBuilder::AddPrivateAttribute + * launchdarkly::AttributesBuilder::AddPrivateAttribute * (the SDK configuration can also have a list of private attribute references). * * This is represented as a separate type, rather than just a string, so that @@ -123,6 +123,11 @@ class AttributeReference { */ AttributeReference(char const* ref_str); + /** + * Default constructs an invalid attribute reference. + */ + AttributeReference(); + bool operator==(AttributeReference const& other) const { return components_ == other.components_; } diff --git a/libs/common/include/launchdarkly/bindings/c/data_source/error_info.h b/libs/common/include/launchdarkly/bindings/c/data_source/error_info.h new file mode 100644 index 000000000..acc51f12d --- /dev/null +++ b/libs/common/include/launchdarkly/bindings/c/data_source/error_info.h @@ -0,0 +1,59 @@ +/** @file error_info.h + * @brief LaunchDarkly Server-side C Bindings for Data Source Error Info. + */ +// NOLINTBEGIN modernize-use-using +#pragma once + +#include +#include + +#include +#include + +#ifdef __cplusplus +extern "C" { // only need to export C interface if +// used by C++ source code +#endif + +typedef struct _LDDataSourceStatus_ErrorInfo* LDDataSourceStatus_ErrorInfo; + +/** + * Get an enumerated value representing the general category of the error. + */ +LD_EXPORT(enum LDDataSourceStatus_ErrorKind) +LDDataSourceStatus_ErrorInfo_GetKind(LDDataSourceStatus_ErrorInfo info); + +/** + * The HTTP status code if the error was + * LD_DATASOURCESTATUS_ERRORKIND_ERROR_RESPONSE. + */ +LD_EXPORT(uint64_t) +LDDataSourceStatus_ErrorInfo_StatusCode(LDDataSourceStatus_ErrorInfo info); + +/** + * Any additional human-readable information relevant to the error. + * + * The format is subject to change and should not be relied on + * programmatically. + */ +LD_EXPORT(char const*) +LDDataSourceStatus_ErrorInfo_Message(LDDataSourceStatus_ErrorInfo info); + +/** + * The date/time that the error occurred, in seconds since epoch. + */ +LD_EXPORT(time_t) +LDDataSourceStatus_ErrorInfo_Time(LDDataSourceStatus_ErrorInfo info); + +/** + * Frees the data source status error information. + * @param status The error information to free. + */ +LD_EXPORT(void) +LDDataSourceStatus_ErrorInfo_Free(LDDataSourceStatus_ErrorInfo info); + +#ifdef __cplusplus +} +#endif + +// NOLINTEND modernize-use-using diff --git a/libs/common/include/launchdarkly/bindings/c/data_source/error_kind.h b/libs/common/include/launchdarkly/bindings/c/data_source/error_kind.h new file mode 100644 index 000000000..29524b862 --- /dev/null +++ b/libs/common/include/launchdarkly/bindings/c/data_source/error_kind.h @@ -0,0 +1,52 @@ +/** @file error_kind.h + * @brief LaunchDarkly Server-side C Bindings for Data Source Error Kinds. + */ +// NOLINTBEGIN modernize-use-using +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { // only need to export C interface if +// used by C++ source code +#endif + +/** + * A description of an error condition that the data source encountered. + */ +enum LDDataSourceStatus_ErrorKind { + /** + * An unexpected error, such as an uncaught exception, further + * described by the error message. + */ + LD_DATASOURCESTATUS_ERRORKIND_UNKNOWN = 0, + + /** + * An I/O error such as a dropped connection. + */ + LD_DATASOURCESTATUS_ERRORKIND_NETWORK_ERROR = 1, + + /** + * The LaunchDarkly service returned an HTTP response with an error + * status, available in the status code. + */ + LD_DATASOURCESTATUS_ERRORKIND_ERROR_RESPONSE = 2, + + /** + * The SDK received malformed data from the LaunchDarkly service. + */ + LD_DATASOURCESTATUS_ERRORKIND_INVALID_DATA = 3, + + /** + * The data source itself is working, but when it tried to put an + * update into the data store, the data store failed (so the SDK may + * not have the latest data). + */ + LD_DATASOURCESTATUS_ERRORKIND_STORE_ERROR = 4, +}; + +#ifdef __cplusplus +} +#endif + +// NOLINTEND modernize-use-using diff --git a/libs/common/include/launchdarkly/config/shared/built/events.hpp b/libs/common/include/launchdarkly/config/shared/built/events.hpp index 0db3adf6b..2b3e76c35 100644 --- a/libs/common/include/launchdarkly/config/shared/built/events.hpp +++ b/libs/common/include/launchdarkly/config/shared/built/events.hpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -36,6 +37,9 @@ class Events final { * should be made. * @param flush_workers How many workers to use for concurrent event * delivery. + * @param context_keys_cache_capacity Max number of unique context keys to + * hold in LRU cache used for context deduplication when generating index + * events. */ Events(bool enabled, std::size_t capacity, @@ -44,7 +48,8 @@ class Events final { bool all_attributes_private, AttributeReference::SetType private_attrs, std::chrono::milliseconds delivery_retry_delay, - std::size_t flush_workers); + std::size_t flush_workers, + std::optional context_keys_cache_capacity); /** * Returns true if event-sending is enabled. @@ -87,6 +92,13 @@ class Events final { */ [[nodiscard]] std::size_t FlushWorkers() const; + /** + * Max number of unique context keys to hold in LRU cache used for context + * deduplication when generating index events. + * @return Max, or std::nullopt if not applicable. + */ + [[nodiscard]] std::optional ContextKeysCacheCapacity() const; + private: bool enabled_; std::size_t capacity_; @@ -96,6 +108,7 @@ class Events final { AttributeReference::SetType private_attributes_; std::chrono::milliseconds delivery_retry_delay_; std::size_t flush_workers_; + std::optional context_keys_cache_capacity_; }; bool operator==(Events const& lhs, Events const& rhs); diff --git a/libs/common/include/launchdarkly/config/shared/defaults.hpp b/libs/common/include/launchdarkly/config/shared/defaults.hpp index 8ab68e73e..bd62ae2c4 100644 --- a/libs/common/include/launchdarkly/config/shared/defaults.hpp +++ b/libs/common/include/launchdarkly/config/shared/defaults.hpp @@ -44,7 +44,8 @@ struct Defaults { false, AttributeReference::SetType(), std::chrono::seconds(1), - 5}; + 5, + std::nullopt}; } static auto HttpProperties() -> shared::built::HttpProperties { @@ -88,7 +89,8 @@ struct Defaults { false, AttributeReference::SetType(), std::chrono::seconds(1), - 5}; + 5, + 1000}; } static auto HttpProperties() -> shared::built::HttpProperties { diff --git a/libs/common/include/launchdarkly/context.hpp b/libs/common/include/launchdarkly/context.hpp index 9c63229e8..81a3cad88 100644 --- a/libs/common/include/launchdarkly/context.hpp +++ b/libs/common/include/launchdarkly/context.hpp @@ -57,8 +57,9 @@ class Context final { * @param ref The reference to the desired attribute. * @return The attribute Value or a Value representing null. */ - Value const& Get(std::string const& kind, - launchdarkly::AttributeReference const& ref); + [[nodiscard]] Value const& Get( + std::string const& kind, + launchdarkly::AttributeReference const& ref) const; /** * Check if a context is valid. diff --git a/libs/common/include/launchdarkly/data/evaluation_detail.hpp b/libs/common/include/launchdarkly/data/evaluation_detail.hpp index 7263f17d9..d32428d05 100644 --- a/libs/common/include/launchdarkly/data/evaluation_detail.hpp +++ b/libs/common/include/launchdarkly/data/evaluation_detail.hpp @@ -33,7 +33,15 @@ class EvaluationDetail { * @param error_kind Kind of the error. * @param default_value Default value. */ - EvaluationDetail(enum EvaluationReason::ErrorKind error_kind, T default_value); + EvaluationDetail(enum EvaluationReason::ErrorKind error_kind, + T default_value); + + /** + * Constructs an EvaluationDetail consisting of a reason but no value. + * This is used when a flag has no appropriate fallback value. + * @param reason The reason. + */ + EvaluationDetail(EvaluationReason reason); /** * @return A reference to the variation value. For convenience, the * @@ -52,11 +60,32 @@ class EvaluationDetail { */ [[nodiscard]] std::optional const& Reason() const; + /** + * Check if an evaluation reason exists, and if so, if it is of a particular + * kind. + * @param kind Kind to check. + * @return True if a reason exists and matches the given kind. + */ + [[nodiscard]] bool ReasonKindIs(enum EvaluationReason::Kind kind) const; + + /** + * @return True if the evaluation resulted in an error. + * TODO(sc209960) + */ + [[nodiscard]] bool IsError() const; + /** * @return A reference to the variation value. */ T const& operator*() const; + /** + * @return True if the evaluation was successful (i.e. IsError returns + * false.) + * TODO(sc209960) + */ + explicit operator bool() const; + private: T value_; std::optional variation_index_; @@ -64,9 +93,10 @@ class EvaluationDetail { }; /* - * Holds details for the C bindings, omitting the generic type parameter that is - * needed for EvaluationDetail. Instead, the bindings will directly return - * the evaluation result, and fill in a detail structure using an out parameter. + * Holds details for the C bindings, omitting the generic type parameter + * that is needed for EvaluationDetail. Instead, the bindings will + * directly return the evaluation result, and fill in a detail structure + * using an out parameter. */ struct CEvaluationDetail { template diff --git a/libs/common/include/launchdarkly/data/evaluation_reason.hpp b/libs/common/include/launchdarkly/data/evaluation_reason.hpp index 5238af9a8..75a6fa282 100644 --- a/libs/common/include/launchdarkly/data/evaluation_reason.hpp +++ b/libs/common/include/launchdarkly/data/evaluation_reason.hpp @@ -119,6 +119,42 @@ class EvaluationReason { explicit EvaluationReason(enum ErrorKind error_kind); + /** + * The flag was off. + */ + static EvaluationReason Off(); + + /** + * The flag didn't return a variation due to a prerequisite failing. + */ + static EvaluationReason PrerequisiteFailed(std::string prerequisite_key); + + /** + * The flag evaluated to a particular variation due to a target match. + */ + static EvaluationReason TargetMatch(); + + /** + * The flag evaluated to its fallthrough value. + * @param in_experiment Whether the flag is part of an experiment. + */ + static EvaluationReason Fallthrough(bool in_experiment); + + /** + * The flag evaluated to a particular variation because it matched a rule. + * @param rule_index Index of the rule. + * @param rule_id ID of the rule. + * @param in_experiment Whether the flag is part of an experiment. + */ + static EvaluationReason RuleMatch(std::size_t rule_index, + std::optional rule_id, + bool in_experiment); + + /** + * The flag data was malformed. + */ + static EvaluationReason MalformedFlag(); + friend std::ostream& operator<<(std::ostream& out, EvaluationReason const& reason); diff --git a/libs/common/include/launchdarkly/data/evaluation_result.hpp b/libs/common/include/launchdarkly/data/evaluation_result.hpp index 127589c54..52aa598ca 100644 --- a/libs/common/include/launchdarkly/data/evaluation_result.hpp +++ b/libs/common/include/launchdarkly/data/evaluation_result.hpp @@ -57,9 +57,6 @@ class EvaluationResult { debug_events_until_date, EvaluationDetailInternal detail); - friend std::ostream& operator<<(std::ostream& out, - EvaluationResult const& result); - private: uint64_t version_; std::optional flag_version_; @@ -70,6 +67,8 @@ class EvaluationResult { EvaluationDetailInternal detail_; }; +std::ostream& operator<<(std::ostream& out, EvaluationResult const& result); + bool operator==(EvaluationResult const& lhs, EvaluationResult const& rhs); bool operator!=(EvaluationResult const& lhs, EvaluationResult const& rhs); diff --git a/libs/common/include/launchdarkly/data_sources/data_source_status_base.hpp b/libs/common/include/launchdarkly/data_sources/data_source_status_base.hpp new file mode 100644 index 000000000..3f758f2e0 --- /dev/null +++ b/libs/common/include/launchdarkly/data_sources/data_source_status_base.hpp @@ -0,0 +1,80 @@ +#include +#include + +#include +#include + +// Common is included in the namespace to disambiguate from client/server +// for backward compatibility. +namespace launchdarkly::common::data_sources { + +template +class DataSourceStatusBase { + public: + using ErrorKind = DataSourceStatusErrorKind; + using ErrorInfo = DataSourceStatusErrorInfo; + using DateTime = std::chrono::time_point; + using DataSourceState = TDataSourceState; + + /** + * An enumerated value representing the overall current state of the data + * source. + */ + [[nodiscard]] DataSourceState State() const { return state_; } + + /** + * The date/time that the value of State most recently changed. + * + * The meaning of this depends on the current state: + * - For DataSourceState::kInitializing, it is the time that the SDK started + * initializing. + * - For DataSourceState::kValid, it is the time that the data + * source most recently entered a valid state, after previously having been + * DataSourceState::kInitializing or an invalid state such as + * DataSourceState::kInterrupted. + * - For DataSourceState::kInterrupted, it is the time that the data source + * most recently entered an error state, after previously having been + * DataSourceState::kValid. + * - For DataSourceState::kShutdown (client-side) or DataSourceState::kOff + * (server-side), it is the time that the data source encountered an + * unrecoverable error or that the SDK was explicitly shut down. + */ + [[nodiscard]] DateTime StateSince() const { return state_since_; } + + /** + * Information about the last error that the data source encountered, if + * any. + * + * This property should be updated whenever the data source encounters a + * problem, even if it does not cause the state to change. For instance, if + * a stream connection fails and the state changes to + * DataSourceState::kInterrupted, and then subsequent attempts to restart + * the connection also fail, the state will remain + * DataSourceState::kInterrupted but the error information will be updated + * each time-- and the last error will still be reported in this property + * even if the state later becomes DataSourceState::kValid. + */ + [[nodiscard]] std::optional LastError() const { + return last_error_; + } + + DataSourceStatusBase(DataSourceState state, + DateTime state_since, + std::optional last_error) + : state_(state), + state_since_(state_since), + last_error_(std::move(last_error)) {} + + ~DataSourceStatusBase() = default; + DataSourceStatusBase(DataSourceStatusBase const& item) = default; + DataSourceStatusBase(DataSourceStatusBase&& item) noexcept = default; + DataSourceStatusBase& operator=(DataSourceStatusBase const&) = delete; + DataSourceStatusBase& operator=(DataSourceStatusBase&&) = delete; + + private: + DataSourceState state_; + DateTime state_since_; + std::optional last_error_; +}; + +} // namespace launchdarkly::common::data_sources diff --git a/libs/common/include/launchdarkly/data_sources/data_source_status_error_info.hpp b/libs/common/include/launchdarkly/data_sources/data_source_status_error_info.hpp new file mode 100644 index 000000000..c1d6813c7 --- /dev/null +++ b/libs/common/include/launchdarkly/data_sources/data_source_status_error_info.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include + +#include +#include +#include + +namespace launchdarkly::common::data_sources { + +/** + * A description of an error condition that the data source encountered. + */ +class DataSourceStatusErrorInfo { + public: + using StatusCodeType = std::uint64_t; + using ErrorKind = DataSourceStatusErrorKind; + using DateTime = std::chrono::time_point; + + /** + * An enumerated value representing the general category of the error. + */ + [[nodiscard]] ErrorKind Kind() const { return kind_; } + + /** + * The HTTP status code if the error was ErrorKind::kErrorResponse. + */ + [[nodiscard]] StatusCodeType StatusCode() const { return status_code_; } + + /** + * Any additional human-readable information relevant to the error. + * + * The format is subject to change and should not be relied on + * programmatically. + */ + [[nodiscard]] std::string const& Message() const { return message_; } + + /** + * The date/time that the error occurred. + */ + [[nodiscard]] DateTime Time() const { return time_; } + + DataSourceStatusErrorInfo(ErrorKind kind, + StatusCodeType status_code, + std::string message, + DateTime time) + : kind_(kind), + status_code_(status_code), + message_(std::move(message)), + time_(time) {} + + private: + ErrorKind kind_; + StatusCodeType status_code_; + std::string message_; + DateTime time_; +}; + +std::ostream& operator<<(std::ostream& out, + DataSourceStatusErrorInfo const& error); + +} // namespace launchdarkly::common::data_sources diff --git a/libs/common/include/launchdarkly/data_sources/data_source_status_error_kind.hpp b/libs/common/include/launchdarkly/data_sources/data_source_status_error_kind.hpp new file mode 100644 index 000000000..6adc7d87e --- /dev/null +++ b/libs/common/include/launchdarkly/data_sources/data_source_status_error_kind.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include + +namespace launchdarkly::common::data_sources { + +/** + * An enumeration describing the general type of an error. + */ +enum class DataSourceStatusErrorKind { + /** + * An unexpected error, such as an uncaught exception, further + * described by the error message. + */ + kUnknown = 0, + + /** + * An I/O error such as a dropped connection. + */ + kNetworkError = 1, + + /** + * The LaunchDarkly service returned an HTTP response with an error + * status, available in the status code. + */ + kErrorResponse = 2, + + /** + * The SDK received malformed data from the LaunchDarkly service. + */ + kInvalidData = 3, + + /** + * The data source itself is working, but when it tried to put an + * update into the data store, the data store failed (so the SDK may + * not have the latest data). + */ + kStoreError = 4 +}; + +std::ostream& operator<<(std::ostream& out, + DataSourceStatusErrorKind const& kind); + +} // namespace launchdarkly::common::data_sources diff --git a/libs/common/include/launchdarkly/detail/c_binding_helpers.hpp b/libs/common/include/launchdarkly/detail/c_binding_helpers.hpp index d9cb21d05..99772b9b6 100644 --- a/libs/common/include/launchdarkly/detail/c_binding_helpers.hpp +++ b/libs/common/include/launchdarkly/detail/c_binding_helpers.hpp @@ -1,11 +1,12 @@ #include -#include -#include #include -#include #include +#include +#include +#include + namespace launchdarkly { template struct has_result_type : std::false_type {}; diff --git a/libs/common/include/launchdarkly/logging/log_level.hpp b/libs/common/include/launchdarkly/logging/log_level.hpp index 46977cbfc..5338b2908 100644 --- a/libs/common/include/launchdarkly/logging/log_level.hpp +++ b/libs/common/include/launchdarkly/logging/log_level.hpp @@ -1,5 +1,7 @@ #pragma once +#include + namespace launchdarkly { /** * Log levels with kDebug being lowest severity and kError being highest @@ -28,4 +30,6 @@ char const* GetLogLevelName(LogLevel level, char const* default_); */ LogLevel GetLogLevelEnum(char const* name, LogLevel default_); +std::ostream& operator<<(std::ostream& out, LogLevel const& level); + } // namespace launchdarkly diff --git a/libs/common/include/launchdarkly/value.hpp b/libs/common/include/launchdarkly/value.hpp index 9d3a82019..92a026d1c 100644 --- a/libs/common/include/launchdarkly/value.hpp +++ b/libs/common/include/launchdarkly/value.hpp @@ -430,4 +430,24 @@ bool operator!=(Value::Array const& lhs, Value::Array const& rhs); bool operator==(Value::Object const& lhs, Value::Object const& rhs); bool operator!=(Value::Object const& lhs, Value::Object const& rhs); +/* Returns true if both values are numbers and lhs < rhs. Returns false if + * either value is not a number. + */ +bool operator<(Value const& lhs, Value const& rhs); + +/* Returns true if both values are numbers and lhs > rhs. Returns false if + * either value is not a number. + */ +bool operator>(Value const& lhs, Value const& rhs); + +/* Returns true if both values are numbers and lhs <= rhs. Returns false if + * either value is not a number. + */ +bool operator<=(Value const& lhs, Value const& rhs); + +/* Returns true if both values are numbers and lhs >= rhs. Returns false if + * either value is not a number. + */ +bool operator>=(Value const& lhs, Value const& rhs); + } // namespace launchdarkly diff --git a/libs/common/src/CMakeLists.txt b/libs/common/src/CMakeLists.txt index 790f86516..0130ec962 100644 --- a/libs/common/src/CMakeLists.txt +++ b/libs/common/src/CMakeLists.txt @@ -11,6 +11,7 @@ file(GLOB HEADER_LIST CONFIGURE_DEPENDS "${LaunchDarklyCommonSdk_SOURCE_DIR}/include/launchdarkly/config/shared/built/*.hpp" "${LaunchDarklyCommonSdk_SOURCE_DIR}/include/launchdarkly/data/*.hpp" "${LaunchDarklyCommonSdk_SOURCE_DIR}/include/launchdarkly/logging/*.hpp" + "${LaunchDarklyCommonSdk_SOURCE_DIR}/include/launchdarkly/data_sources/*.hpp" ) # Automatic library: static or dynamic based on user config. @@ -48,9 +49,12 @@ add_library(${LIBNAME} OBJECT bindings/c/listener_connection.cpp bindings/c/flag_listener.cpp bindings/c/memory_routines.cpp + bindings/c/data_source/error_info.cpp log_level.cpp config/persistence_builder.cpp config/logging_builder.cpp + data_sources/data_source_status_error_kind.cpp + data_sources/data_source_status_error_info.cpp ) add_library(launchdarkly::common ALIAS ${LIBNAME}) diff --git a/libs/common/src/attribute_reference.cpp b/libs/common/src/attribute_reference.cpp index a0dfabef2..c0594a5e3 100644 --- a/libs/common/src/attribute_reference.cpp +++ b/libs/common/src/attribute_reference.cpp @@ -170,6 +170,10 @@ std::string EscapeLiteral(std::string const& literal, } AttributeReference::AttributeReference(std::string str, bool literal) { + if (str.empty()) { + valid_ = false; + return; + } if (literal) { components_.push_back(str); // Literal starting with a '/' needs to be converted to an attribute @@ -226,6 +230,8 @@ AttributeReference::AttributeReference(std::string ref_str) AttributeReference::AttributeReference(char const* ref_str) : AttributeReference(std::string(ref_str)) {} +AttributeReference::AttributeReference() : AttributeReference("") {} + std::string AttributeReference::PathToStringReference( std::vector path) { // Approximate size to reduce resizes. diff --git a/libs/common/src/bindings/c/data_source/error_info.cpp b/libs/common/src/bindings/c/data_source/error_info.cpp new file mode 100644 index 000000000..9776de89b --- /dev/null +++ b/libs/common/src/bindings/c/data_source/error_info.cpp @@ -0,0 +1,46 @@ +#include + +#include +#include + +using namespace launchdarkly::common; + +#define TO_DATASOURCESTATUS_ERRORINFO(ptr) \ + (reinterpret_cast< \ + launchdarkly::common::data_sources::DataSourceStatusErrorInfo*>(ptr)) + +LD_EXPORT(LDDataSourceStatus_ErrorKind) +LDDataSourceStatus_ErrorInfo_GetKind(LDDataSourceStatus_ErrorInfo info) { + LD_ASSERT_NOT_NULL(info); + + return static_cast( + TO_DATASOURCESTATUS_ERRORINFO(info)->Kind()); +} + +LD_EXPORT(uint64_t) +LDDataSourceStatus_ErrorInfo_StatusCode(LDDataSourceStatus_ErrorInfo info) { + LD_ASSERT_NOT_NULL(info); + + return TO_DATASOURCESTATUS_ERRORINFO(info)->StatusCode(); +} + +LD_EXPORT(char const*) +LDDataSourceStatus_ErrorInfo_Message(LDDataSourceStatus_ErrorInfo info) { + LD_ASSERT_NOT_NULL(info); + + return TO_DATASOURCESTATUS_ERRORINFO(info)->Message().c_str(); +} + +LD_EXPORT(time_t) +LDDataSourceStatus_ErrorInfo_Time(LDDataSourceStatus_ErrorInfo info) { + LD_ASSERT_NOT_NULL(info); + + return std::chrono::duration_cast( + TO_DATASOURCESTATUS_ERRORINFO(info)->Time().time_since_epoch()) + .count(); +} + +LD_EXPORT(void) +LDDataSourceStatus_ErrorInfo_Free(LDDataSourceStatus_ErrorInfo info) { + delete TO_DATASOURCESTATUS_ERRORINFO(info); +} diff --git a/libs/common/src/config/events.cpp b/libs/common/src/config/events.cpp index 4147639ed..7f24b7c59 100644 --- a/libs/common/src/config/events.cpp +++ b/libs/common/src/config/events.cpp @@ -9,7 +9,8 @@ Events::Events(bool enabled, bool all_attributes_private, AttributeReference::SetType private_attrs, std::chrono::milliseconds delivery_retry_delay, - std::size_t flush_workers) + std::size_t flush_workers, + std::optional context_keys_cache_capacity) : enabled_(enabled), capacity_(capacity), flush_interval_(flush_interval), @@ -17,7 +18,8 @@ Events::Events(bool enabled, all_attributes_private_(all_attributes_private), private_attributes_(std::move(private_attrs)), delivery_retry_delay_(delivery_retry_delay), - flush_workers_(flush_workers) {} + flush_workers_(flush_workers), + context_keys_cache_capacity_(context_keys_cache_capacity) {} bool Events::Enabled() const { return enabled_; @@ -51,6 +53,10 @@ std::size_t Events::FlushWorkers() const { return flush_workers_; } +std::optional Events::ContextKeysCacheCapacity() const { + return context_keys_cache_capacity_; +} + bool operator==(Events const& lhs, Events const& rhs) { return lhs.Path() == rhs.Path() && lhs.FlushInterval() == rhs.FlushInterval() && @@ -58,6 +64,7 @@ bool operator==(Events const& lhs, Events const& rhs) { lhs.AllAttributesPrivate() == rhs.AllAttributesPrivate() && lhs.PrivateAttributes() == rhs.PrivateAttributes() && lhs.DeliveryRetryDelay() == rhs.DeliveryRetryDelay() && - lhs.FlushWorkers() == rhs.FlushWorkers(); + lhs.FlushWorkers() == rhs.FlushWorkers() && + lhs.ContextKeysCacheCapacity() == rhs.ContextKeysCacheCapacity(); } } // namespace launchdarkly::config::shared::built diff --git a/libs/common/src/context.cpp b/libs/common/src/context.cpp index 42d734be6..ad301a0e5 100644 --- a/libs/common/src/context.cpp +++ b/libs/common/src/context.cpp @@ -40,7 +40,7 @@ Context::Context(std::map attributes) } Value const& Context::Get(std::string const& kind, - AttributeReference const& ref) { + AttributeReference const& ref) const { auto found = attributes_.find(kind); if (found != attributes_.end()) { return found->second.Get(ref); @@ -64,7 +64,7 @@ std::string Context::make_canonical_key() { if (kinds_to_keys_.size() == 1) { if (auto iterator = kinds_to_keys_.find("user"); iterator != kinds_to_keys_.end()) { - return std::string(iterator->second); + return iterator->second; } } std::stringstream stream; diff --git a/libs/common/src/data/evaluation_detail.cpp b/libs/common/src/data/evaluation_detail.cpp index 72093b267..c6415848d 100644 --- a/libs/common/src/data/evaluation_detail.cpp +++ b/libs/common/src/data/evaluation_detail.cpp @@ -14,12 +14,17 @@ EvaluationDetail::EvaluationDetail( reason_(std::move(reason)) {} template -EvaluationDetail::EvaluationDetail(enum EvaluationReason::ErrorKind error_kind, - T default_value) +EvaluationDetail::EvaluationDetail( + enum EvaluationReason::ErrorKind error_kind, + T default_value) : value_(std::move(default_value)), variation_index_(std::nullopt), reason_(error_kind) {} +template +EvaluationDetail::EvaluationDetail(EvaluationReason reason) + : value_(), variation_index_(std::nullopt), reason_(std::move(reason)) {} + template T const& EvaluationDetail::Value() const { return value_; @@ -30,6 +35,11 @@ std::optional const& EvaluationDetail::Reason() const { return reason_; } +template +bool EvaluationDetail::ReasonKindIs(enum EvaluationReason::Kind kind) const { + return reason_.has_value() && reason_->Kind() == kind; +} + template std::optional EvaluationDetail::VariationIndex() const { return variation_index_; @@ -39,6 +49,16 @@ T const& EvaluationDetail::operator*() const { return value_; } +template +[[nodiscard]] bool EvaluationDetail::IsError() const { + return reason_.has_value() && reason_->ErrorKind().has_value(); +} + +template +EvaluationDetail::operator bool() const { + return !IsError(); +} + template class EvaluationDetail; template class EvaluationDetail; template class EvaluationDetail; diff --git a/libs/common/src/data/evaluation_reason.cpp b/libs/common/src/data/evaluation_reason.cpp index a626ace5e..7fb433007 100644 --- a/libs/common/src/data/evaluation_reason.cpp +++ b/libs/common/src/data/evaluation_reason.cpp @@ -56,6 +56,39 @@ EvaluationReason::EvaluationReason(enum ErrorKind error_kind) false, std::nullopt) {} +EvaluationReason EvaluationReason::Off() { + return {Kind::kOff, std::nullopt, std::nullopt, std::nullopt, + std::nullopt, false, std::nullopt}; +} + +EvaluationReason EvaluationReason::PrerequisiteFailed( + std::string prerequisite_key) { + return { + Kind::kPrerequisiteFailed, std::nullopt, std::nullopt, std::nullopt, + std::move(prerequisite_key), false, std::nullopt}; +} + +EvaluationReason EvaluationReason::TargetMatch() { + return {Kind::kTargetMatch, std::nullopt, std::nullopt, std::nullopt, + std::nullopt, false, std::nullopt}; +} + +EvaluationReason EvaluationReason::Fallthrough(bool in_experiment) { + return {Kind::kFallthrough, std::nullopt, std::nullopt, std::nullopt, + std::nullopt, in_experiment, std::nullopt}; +} + +EvaluationReason EvaluationReason::RuleMatch(std::size_t rule_index, + std::optional rule_id, + bool in_experiment) { + return {Kind::kRuleMatch, std::nullopt, rule_index, std::move(rule_id), + std::nullopt, in_experiment, std::nullopt}; +} + +EvaluationReason EvaluationReason::MalformedFlag() { + return EvaluationReason{ErrorKind::kMalformedFlag}; +} + std::ostream& operator<<(std::ostream& out, EvaluationReason const& reason) { out << "{"; out << " kind: " << reason.kind_; diff --git a/libs/common/src/data/evaluation_result.cpp b/libs/common/src/data/evaluation_result.cpp index a6b1bdbbb..298b49b0b 100644 --- a/libs/common/src/data/evaluation_result.cpp +++ b/libs/common/src/data/evaluation_result.cpp @@ -48,17 +48,17 @@ EvaluationResult::EvaluationResult( std::ostream& operator<<(std::ostream& out, EvaluationResult const& result) { out << "{"; - out << " version: " << result.version_; - out << " trackEvents: " << result.track_events_; - out << " trackReason: " << result.track_reason_; + out << " version: " << result.Version(); + out << " trackEvents: " << result.TrackEvents(); + out << " trackReason: " << result.TrackReason(); - if (result.debug_events_until_date_.has_value()) { + if (result.DebugEventsUntilDate().has_value()) { std::time_t as_time_t = std::chrono::system_clock::to_time_t( - result.debug_events_until_date_.value()); + result.DebugEventsUntilDate().value()); out << " debugEventsUntilDate: " << std::put_time(std::gmtime(&as_time_t), "%Y-%m-%d %H:%M:%S"); } - out << " detail: " << result.detail_; + out << " detail: " << result.Detail(); out << "}"; return out; } diff --git a/libs/common/src/data_sources/data_source_status_error_info.cpp b/libs/common/src/data_sources/data_source_status_error_info.cpp new file mode 100644 index 000000000..b0299af5f --- /dev/null +++ b/libs/common/src/data_sources/data_source_status_error_info.cpp @@ -0,0 +1,19 @@ +#include +#include +#include +#include + +#include + +namespace launchdarkly::common::data_sources { + +std::ostream& operator<<(std::ostream& out, + DataSourceStatusErrorInfo const& error) { + std::time_t as_time_t = std::chrono::system_clock::to_time_t(error.Time()); + out << "Error(" << error.Kind() << ", " << error.Message() + << ", StatusCode(" << error.StatusCode() << "), Since(" + << std::put_time(std::gmtime(&as_time_t), "%Y-%m-%d %H:%M:%S") << "))"; + return out; +} + +} // namespace launchdarkly::common::data_sources diff --git a/libs/common/src/data_sources/data_source_status_error_kind.cpp b/libs/common/src/data_sources/data_source_status_error_kind.cpp new file mode 100644 index 000000000..69545eb16 --- /dev/null +++ b/libs/common/src/data_sources/data_source_status_error_kind.cpp @@ -0,0 +1,30 @@ +#include +#include + +#include + +namespace launchdarkly::common::data_sources { + +std::ostream& operator<<(std::ostream& out, + DataSourceStatusErrorKind const& kind) { + switch (kind) { + case DataSourceStatusErrorKind::kUnknown: + out << "UNKNOWN"; + break; + case DataSourceStatusErrorKind::kNetworkError: + out << "NETWORK_ERROR"; + break; + case DataSourceStatusErrorKind::kErrorResponse: + out << "ERROR_RESPONSE"; + break; + case DataSourceStatusErrorKind::kInvalidData: + out << "INVALID_DATA"; + break; + case DataSourceStatusErrorKind::kStoreError: + out << "STORE_ERROR"; + break; + } + return out; +} + +} // namespace launchdarkly::common::data_sources diff --git a/libs/common/src/log_level.cpp b/libs/common/src/log_level.cpp index e4c6b29b0..d708d056f 100644 --- a/libs/common/src/log_level.cpp +++ b/libs/common/src/log_level.cpp @@ -44,4 +44,9 @@ LogLevel GetLogLevelEnum(char const* level, LogLevel default_) { return default_; } +std::ostream& operator<<(std::ostream& out, LogLevel const& level) { + out << GetLogLevelName(level, "unknown"); + return out; +} + } // namespace launchdarkly diff --git a/libs/common/src/value.cpp b/libs/common/src/value.cpp index 1db2b19a9..46b8b85c0 100644 --- a/libs/common/src/value.cpp +++ b/libs/common/src/value.cpp @@ -267,4 +267,23 @@ bool operator!=(Value::Object const& lhs, Value::Object const& rhs) { return !(lhs == rhs); } +inline bool BothNumbers(Value const& lhs, Value const& rhs) { + return lhs.IsNumber() && rhs.IsNumber(); +} + +bool operator<(Value const& lhs, Value const& rhs) { + return BothNumbers(lhs, rhs) && lhs.AsDouble() < rhs.AsDouble(); +} + +bool operator>(Value const& lhs, Value const& rhs) { + return BothNumbers(lhs, rhs) && rhs < lhs; +} + +bool operator<=(Value const& lhs, Value const& rhs) { + return BothNumbers(lhs, rhs) && !(lhs > rhs); +} + +bool operator>=(Value const& lhs, Value const& rhs) { + return BothNumbers(lhs, rhs) && !(lhs < rhs); +} } // namespace launchdarkly diff --git a/libs/common/tests/data_source_status_test.cpp b/libs/common/tests/data_source_status_test.cpp new file mode 100644 index 000000000..c3b165803 --- /dev/null +++ b/libs/common/tests/data_source_status_test.cpp @@ -0,0 +1,71 @@ +#include + +#include + +namespace test_things { +enum class TestDataSourceStates { kStateA = 0, kStateB = 1, kStateC = 2 }; + +using DataSourceStatus = + launchdarkly::common::data_sources::DataSourceStatusBase< + TestDataSourceStates>; + +std::ostream& operator<<(std::ostream& out, TestDataSourceStates const& state) { + switch (state) { + case TestDataSourceStates::kStateA: + out << "kStateA"; + break; + case TestDataSourceStates::kStateB: + out << "kStateB"; + break; + case TestDataSourceStates::kStateC: + out << "kStateC"; + break; + } + + return out; +} + +std::ostream& operator<<(std::ostream& out, DataSourceStatus const& status) { + std::time_t as_time_t = + std::chrono::system_clock::to_time_t(status.StateSince()); + out << "Status(" << status.State() << ", Since(" + << std::put_time(std::gmtime(&as_time_t), "%Y-%m-%d %H:%M:%S") << ")"; + if (status.LastError()) { + out << ", " << status.LastError().value(); + } + out << ")"; + return out; +} +} // namespace test_things + +TEST(DataSourceStatusTest, OstreamBasicStatus) { + auto time = + std::chrono::system_clock::time_point{std::chrono::milliseconds{0}}; + ; + test_things::DataSourceStatus status( + test_things::DataSourceStatus::DataSourceState::kStateA, time, + std::nullopt); + + std::stringstream ss; + ss << status; + EXPECT_EQ("Status(kStateA, Since(1970-01-01 00:00:00))", ss.str()); +} + +TEST(DataSourceStatusTest, OStreamErrorInfo) { + auto time = + std::chrono::system_clock::time_point{std::chrono::milliseconds{0}}; + ; + test_things::DataSourceStatus status( + test_things::DataSourceStatus::DataSourceState::kStateC, time, + launchdarkly::common::data_sources::DataSourceStatusErrorInfo( + launchdarkly::common::data_sources::DataSourceStatusErrorKind:: + kInvalidData, + 404, "Bad times", time)); + + std::stringstream ss; + ss << status; + EXPECT_EQ( + "Status(kStateC, Since(1970-01-01 00:00:00), Error(INVALID_DATA, Bad " + "times, StatusCode(404), Since(1970-01-01 00:00:00)))", + ss.str()); +} diff --git a/libs/common/tests/value_test.cpp b/libs/common/tests/value_test.cpp index c4f3255d4..f9ad35fb9 100644 --- a/libs/common/tests/value_test.cpp +++ b/libs/common/tests/value_test.cpp @@ -1,14 +1,14 @@ #include -#include -#include -#include -#include #include -#include #include +#include +#include +#include +#include + // NOLINTBEGIN cppcoreguidelines-avoid-magic-numbers using BoostValue = boost::json::value; diff --git a/libs/internal/include/launchdarkly/context_filter.hpp b/libs/internal/include/launchdarkly/context_filter.hpp index 104e15cce..a4bbddb9a 100644 --- a/libs/internal/include/launchdarkly/context_filter.hpp +++ b/libs/internal/include/launchdarkly/context_filter.hpp @@ -1,13 +1,13 @@ #pragma once -#include -#include -#include +#include +#include #include -#include -#include +#include +#include +#include namespace launchdarkly { diff --git a/libs/internal/include/launchdarkly/data_model/context_aware_reference.hpp b/libs/internal/include/launchdarkly/data_model/context_aware_reference.hpp new file mode 100644 index 000000000..f176cf403 --- /dev/null +++ b/libs/internal/include/launchdarkly/data_model/context_aware_reference.hpp @@ -0,0 +1,65 @@ +#pragma once + +#include +#include + +#include + +namespace launchdarkly::data_model { + +/** + * The JSON data conditionally contains Attribute References (which are capable + * of addressing arbitrarily nested attributes in contexts) or Attribute Names, + * which are names of top-level attributes in contexts. + * + * In order to distinguish these two cases, inspection of a context kind field + * is necessary. The presence or absence of that field determines whether the + * data is an Attribute Reference or Attribute Name. + * + * Because this logic is needed in (3) places, it is factored out into this + * type. To use it, call + * boost::json::value_from, + * JsonError>>(json_value), where T is any type that defines the following: + * - kContextFieldName: name of the field containing the context kind + * - kReferenceFieldName: name of the field containing the attribute reference + * or attribute name' + * + * To ensure the field names don't go out of sync with the declared member + * variables, use the two macros defined below. + * @tparam Fields + */ +template +struct ContextAwareReference { + static_assert( + std::is_same::value && + std::is_same::value, + "T must define kContextFieldName and kReferenceFieldName as constexpr " + "static const char*"); +}; + +template +struct ContextAwareReference< + FieldNames, + typename std::enable_if< + std::is_same::value && + std::is_same::value>::type> { + using fields = FieldNames; + ContextKind contextKind; + AttributeReference reference; +}; + +// NOLINTBEGIN cppcoreguidelines-macro-usage +#define DEFINE_CONTEXT_KIND_FIELD(name) \ + ContextKind name; \ + constexpr static const char* kContextFieldName = #name; + +#define DEFINE_ATTRIBUTE_REFERENCE_FIELD(name) \ + AttributeReference name; \ + constexpr static const char* kReferenceFieldName = #name; +// NOLINTEND cppcoreguidelines-macro-usage + +} // namespace launchdarkly::data_model diff --git a/libs/internal/include/launchdarkly/data_model/context_kind.hpp b/libs/internal/include/launchdarkly/data_model/context_kind.hpp new file mode 100644 index 000000000..6d4690311 --- /dev/null +++ b/libs/internal/include/launchdarkly/data_model/context_kind.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include + +#include + +namespace launchdarkly::data_model { + +BOOST_STRONG_TYPEDEF(std::string, ContextKind); + +inline bool IsUser(ContextKind const& kind) noexcept { + return kind.t == "user"; +} + +} // namespace launchdarkly::data_model diff --git a/libs/internal/include/launchdarkly/data_model/flag.hpp b/libs/internal/include/launchdarkly/data_model/flag.hpp new file mode 100644 index 000000000..ebf9b24a9 --- /dev/null +++ b/libs/internal/include/launchdarkly/data_model/flag.hpp @@ -0,0 +1,112 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +namespace launchdarkly::data_model { + +struct Flag { + using Variation = std::int64_t; + using Weight = std::int64_t; + using FlagVersion = std::uint64_t; + using Date = std::uint64_t; + + struct Rollout { + enum class Kind { + kUnrecognized = 0, + kExperiment = 1, + kRollout = 2, + }; + + struct WeightedVariation { + Variation variation; + Weight weight; + bool untracked; + + WeightedVariation(); + + WeightedVariation(Variation index, Weight weight); + static WeightedVariation Untracked(Variation index, Weight weight); + + private: + WeightedVariation(Variation index, Weight weight, bool untracked); + }; + + std::vector variations; + + Kind kind; + std::optional seed; + + DEFINE_ATTRIBUTE_REFERENCE_FIELD(bucketBy) + DEFINE_CONTEXT_KIND_FIELD(contextKind) + + Rollout() = default; + explicit Rollout(std::vector); + }; + + using VariationOrRollout = std::variant, Rollout>; + + struct Prerequisite { + std::string key; + Variation variation; + }; + + struct Target { + std::vector values; + Variation variation; + ContextKind contextKind; + }; + + struct Rule { + std::vector clauses; + VariationOrRollout variationOrRollout; + + bool trackEvents; + std::optional id; + }; + + struct ClientSideAvailability { + bool usingMobileKey; + bool usingEnvironmentId; + }; + + std::string key; + FlagVersion version; + bool on; + VariationOrRollout fallthrough; + std::vector variations; + + std::vector prerequisites; + std::vector targets; + std::vector contextTargets; + std::vector rules; + std::optional offVariation; + bool clientSide; + ClientSideAvailability clientSideAvailability; + std::optional salt; + bool trackEvents; + bool trackEventsFallthrough; + std::optional debugEventsUntilDate; + + /** + * Returns the flag's version. Satisfies ItemDescriptor template + * constraints. + * @return Version of this flag. + */ + [[nodiscard]] inline std::uint64_t Version() const { return version; } + + [[nodiscard]] bool IsExperimentationEnabled( + std::optional const& reason) const; +}; +} // namespace launchdarkly::data_model diff --git a/libs/internal/include/launchdarkly/data_model/item_descriptor.hpp b/libs/internal/include/launchdarkly/data_model/item_descriptor.hpp new file mode 100644 index 000000000..db6f443bf --- /dev/null +++ b/libs/internal/include/launchdarkly/data_model/item_descriptor.hpp @@ -0,0 +1,66 @@ +#pragma once + +#include + +#include +#include + +#include +#include +#include +#include + +namespace launchdarkly::data_model { +/** + * An item descriptor is an abstraction that allows for Flag data to be + * handled using the same type in both a put or a patch. + */ +template +struct ItemDescriptor { + /** + * The version number of this data, provided by the SDK. + */ + uint64_t version; + + /** + * The data item, or nullopt if this is a deleted item placeholder. + */ + std::optional item; + + explicit ItemDescriptor(uint64_t version); + + explicit ItemDescriptor(T item); + + ItemDescriptor(ItemDescriptor const&) = default; + ItemDescriptor(ItemDescriptor&&) = default; + ItemDescriptor& operator=(ItemDescriptor const&) = default; + ItemDescriptor& operator=(ItemDescriptor&&) = default; + ~ItemDescriptor() = default; +}; + +template +bool operator==(ItemDescriptor const& lhs, ItemDescriptor const& rhs) { + return lhs.version == rhs.version && lhs.item == rhs.item; +} + +template +std::ostream& operator<<(std::ostream& out, + ItemDescriptor const& descriptor) { + out << "{"; + out << " version: " << descriptor.version; + if (descriptor.item.has_value()) { + out << " item: " << descriptor.item.value(); + } else { + out << " item: "; + } + return out; +} + +template +ItemDescriptor::ItemDescriptor(uint64_t version) : version(version) {} + +template +ItemDescriptor::ItemDescriptor(T item) + : version(item.Version()), item(std::move(item)) {} + +} // namespace launchdarkly::data_model diff --git a/libs/internal/include/launchdarkly/data_model/rule_clause.hpp b/libs/internal/include/launchdarkly/data_model/rule_clause.hpp new file mode 100644 index 000000000..fd11916cf --- /dev/null +++ b/libs/internal/include/launchdarkly/data_model/rule_clause.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include +#include + +#include "context_aware_reference.hpp" + +#include +#include +#include + +namespace launchdarkly::data_model { +struct Clause { + enum class Op { + kUnrecognized, /* didn't match any known operators */ + kIn, + kStartsWith, + kEndsWith, + kMatches, + kContains, + kLessThan, + kLessThanOrEqual, + kGreaterThan, + kGreaterThanOrEqual, + kBefore, + kAfter, + kSemVerEqual, + kSemVerLessThan, + kSemVerGreaterThan, + kSegmentMatch + }; + + Op op; + std::vector values; + bool negate; + + DEFINE_CONTEXT_KIND_FIELD(contextKind) + DEFINE_ATTRIBUTE_REFERENCE_FIELD(attribute) +}; + +std::ostream& operator<<(std::ostream& os, Clause::Op operator_); + +} // namespace launchdarkly::data_model diff --git a/libs/internal/include/launchdarkly/data_model/sdk_data_set.hpp b/libs/internal/include/launchdarkly/data_model/sdk_data_set.hpp new file mode 100644 index 000000000..6fb24a228 --- /dev/null +++ b/libs/internal/include/launchdarkly/data_model/sdk_data_set.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include +#include +#include + +#include +#include + +#include +#include + +namespace launchdarkly::data_model { + +struct SDKDataSet { + template + using Collection = std::unordered_map>; + using FlagKey = std::string; + using SegmentKey = std::string; + using Flags = Collection; + using Segments = Collection; + + Flags flags; + Segments segments; +}; + +} // namespace launchdarkly::data_model diff --git a/libs/internal/include/launchdarkly/data_model/segment.hpp b/libs/internal/include/launchdarkly/data_model/segment.hpp new file mode 100644 index 000000000..559eb0098 --- /dev/null +++ b/libs/internal/include/launchdarkly/data_model/segment.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +namespace launchdarkly::data_model { + +struct Segment { + using Kind = std::string; + struct Target { + Kind contextKind; + std::vector values; + }; + + struct Rule { + using ReferenceType = ContextAwareReference; + + std::vector clauses; + std::optional id; + std::optional weight; + + DEFINE_CONTEXT_KIND_FIELD(rolloutContextKind) + DEFINE_ATTRIBUTE_REFERENCE_FIELD(bucketBy) + }; + + std::string key; + std::uint64_t version; + + std::vector included; + std::vector excluded; + std::vector includedContexts; + std::vector excludedContexts; + std::vector rules; + std::optional salt; + bool unbounded; + std::optional unboundedContextKind; + std::optional generation; + + // TODO(sc209882): in data model, ensure empty Kind string is error + // condition. + + /** + * Returns the segment's version. Satisfies ItemDescriptor template + * constraints. + * @return Version of this segment. + */ + [[nodiscard]] inline std::uint64_t Version() const { return version; } +}; +} // namespace launchdarkly::data_model diff --git a/libs/client-sdk/src/data_sources/data_source.hpp b/libs/internal/include/launchdarkly/data_sources/data_source.hpp similarity index 84% rename from libs/client-sdk/src/data_sources/data_source.hpp rename to libs/internal/include/launchdarkly/data_sources/data_source.hpp index 368f684ed..6ec7fe06f 100644 --- a/libs/client-sdk/src/data_sources/data_source.hpp +++ b/libs/internal/include/launchdarkly/data_sources/data_source.hpp @@ -1,6 +1,6 @@ #pragma once #include -namespace launchdarkly::client_side { +namespace launchdarkly::data_sources { class IDataSource { public: @@ -16,4 +16,4 @@ class IDataSource { IDataSource() = default; }; -} // namespace launchdarkly::client_side +} // namespace launchdarkly::data_sources diff --git a/libs/internal/include/launchdarkly/data_sources/data_source_status_manager_base.hpp b/libs/internal/include/launchdarkly/data_sources/data_source_status_manager_base.hpp new file mode 100644 index 000000000..b791d4e0c --- /dev/null +++ b/libs/internal/include/launchdarkly/data_sources/data_source_status_manager_base.hpp @@ -0,0 +1,189 @@ +#pragma once + +#include +#include +#include + +#include + +#include +#include + +namespace launchdarkly::internal::data_sources { + +/** + * Class that manages updates to the data source status and implements an + * interface to get the current status and listen to status changes. + */ +template +class DataSourceStatusManagerBase : public TInterface { + public: + /** + * Set the state. + * + * @param state The new state. + */ + void SetState(typename TDataSourceStatus::DataSourceState state) { + bool changed = UpdateState(state); + if (changed) { + data_source_status_signal_(Status()); + } + } + + /** + * If an error and state change happen simultaneously, then they should + * be updated simultaneously. + * + * @param state The new state. + * @param code Status code for an http error. + * @param message The message to associate with the error. + */ + void SetState(typename TDataSourceStatus::DataSourceState state, + typename TDataSourceStatus::ErrorInfo::StatusCodeType code, + std::string message) { + { + std::lock_guard lock(status_mutex_); + + UpdateState(state); + + last_error_ = typename TDataSourceStatus::ErrorInfo( + TDataSourceStatus::ErrorInfo::ErrorKind::kErrorResponse, code, + message, std::chrono::system_clock::now()); + } + + data_source_status_signal_(Status()); + } + + /** + * If an error and state change happen simultaneously, then they should + * be updated simultaneously. + * + * @param state The new state. + * @param kind The error kind. + * @param message The message to associate with the error. + */ + void SetState(typename TDataSourceStatus::DataSourceState state, + typename TDataSourceStatus::ErrorInfo::ErrorKind kind, + std::string message) { + { + std::lock_guard lock(status_mutex_); + + UpdateState(state); + + last_error_ = typename TDataSourceStatus::ErrorInfo( + kind, 0, std::move(message), std::chrono::system_clock::now()); + } + + data_source_status_signal_(Status()); + } + + /** + * Set an error with the given kind and message. + * + * For ErrorInfo::ErrorKind::kErrorResponse use the + * SetError(ErrorInfo::StatusCodeType) method. + * @param kind The kind of the error. + * @param message A message for the error. + */ + void SetError(typename TDataSourceStatus::ErrorInfo::ErrorKind kind, + std::string message) { + { + std::lock_guard lock(status_mutex_); + last_error_ = typename TDataSourceStatus::ErrorInfo( + kind, 0, std::move(message), std::chrono::system_clock::now()); + state_since_ = std::chrono::system_clock::now(); + } + + data_source_status_signal_(Status()); + } + + /** + * Set an error based on the given status code. + * @param code The status code of the error. + */ + void SetError(typename TDataSourceStatus::ErrorInfo::StatusCodeType code, + std::string message) { + { + std::lock_guard lock(status_mutex_); + last_error_ = typename TDataSourceStatus::ErrorInfo( + TDataSourceStatus::ErrorInfo::ErrorKind::kErrorResponse, code, + message, std::chrono::system_clock::now()); + state_since_ = std::chrono::system_clock::now(); + } + data_source_status_signal_(Status()); + } + + TDataSourceStatus Status() const override { + return {state_, state_since_, last_error_}; + } + + std::unique_ptr OnDataSourceStatusChange( + std::function handler) override { + std::lock_guard lock{status_mutex_}; + return std::make_unique< + ::launchdarkly::internal::signals::SignalConnection>( + data_source_status_signal_.connect(handler)); + } + + std::unique_ptr OnDataSourceStatusChangeEx( + std::function handler) override { + return std::make_unique< + launchdarkly::internal::signals::SignalConnection>( + data_source_status_signal_.connect_extended( + [handler](boost::signals2::connection const& conn, + TDataSourceStatus status) { + if (handler(status)) { + conn.disconnect(); + } + })); + } + DataSourceStatusManagerBase() + : state_(TDataSourceStatus::DataSourceState::kInitializing), + state_since_(std::chrono::system_clock::now()) {} + + virtual ~DataSourceStatusManagerBase() = default; + DataSourceStatusManagerBase(DataSourceStatusManagerBase const& item) = + delete; + DataSourceStatusManagerBase(DataSourceStatusManagerBase&& item) = delete; + DataSourceStatusManagerBase& operator=(DataSourceStatusManagerBase const&) = + delete; + DataSourceStatusManagerBase& operator=(DataSourceStatusManagerBase&&) = + delete; + + private: + typename TDataSourceStatus::DataSourceState state_; + typename TDataSourceStatus::DateTime state_since_; + std::optional last_error_; + + boost::signals2::signal + data_source_status_signal_; + mutable std::recursive_mutex status_mutex_; + + bool UpdateState( + typename TDataSourceStatus::DataSourceState const& requested_state) { + std::lock_guard lock(status_mutex_); + + // Interrupted and initializing are common to server and client. + // If logic specific to client or server states was needed, then + // the implementation would need to be re-organized to allow overriding + // the method. + + // If initializing, then interruptions remain initializing. + auto new_state = + (requested_state == + TDataSourceStatus::DataSourceState::kInterrupted && + state_ == TDataSourceStatus::DataSourceState::kInitializing) + ? TDataSourceStatus::DataSourceState:: + kInitializing // see comment on + // IDataSourceUpdateSink.UpdateStatus + : requested_state; + auto changed = state_ != new_state; + if (changed) { + state_ = new_state; + state_since_ = std::chrono::system_clock::now(); + } + return changed; + } +}; + +} // namespace launchdarkly::internal::data_sources diff --git a/libs/internal/include/launchdarkly/encoding/base_16.hpp b/libs/internal/include/launchdarkly/encoding/base_16.hpp new file mode 100644 index 000000000..0ffae71b4 --- /dev/null +++ b/libs/internal/include/launchdarkly/encoding/base_16.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include +#include +#include +#include + +namespace launchdarkly::encoding { + +template +std::string Base16Encode(std::array arr) { + std::stringstream output_stream; + output_stream << std::hex << std::noshowbase; + for (unsigned char byte : arr) { + output_stream << std::setw(2) << std::setfill('0') + << static_cast(byte); + } + return output_stream.str(); +} + +} // namespace launchdarkly::encoding diff --git a/libs/internal/include/launchdarkly/encoding/sha_1.hpp b/libs/internal/include/launchdarkly/encoding/sha_1.hpp new file mode 100644 index 000000000..e233757c3 --- /dev/null +++ b/libs/internal/include/launchdarkly/encoding/sha_1.hpp @@ -0,0 +1,10 @@ +#pragma once +#include +#include + +#include + +namespace launchdarkly::encoding { +std::array Sha1String( + std::string const& input); +} diff --git a/libs/internal/include/launchdarkly/encoding/sha_256.hpp b/libs/internal/include/launchdarkly/encoding/sha_256.hpp index f4b354346..d9d71d5b4 100644 --- a/libs/internal/include/launchdarkly/encoding/sha_256.hpp +++ b/libs/internal/include/launchdarkly/encoding/sha_256.hpp @@ -3,7 +3,7 @@ #include #include -#include "openssl/sha.h" +#include namespace launchdarkly::encoding { diff --git a/libs/internal/include/launchdarkly/events/asio_event_processor.hpp b/libs/internal/include/launchdarkly/events/asio_event_processor.hpp index 6e8cc23f2..a531877e8 100644 --- a/libs/internal/include/launchdarkly/events/asio_event_processor.hpp +++ b/libs/internal/include/launchdarkly/events/asio_event_processor.hpp @@ -1,5 +1,19 @@ #pragma once +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + #include #include #include @@ -10,22 +24,10 @@ #include #include -#include -#include -#include -#include -#include - -#include "event_batch.hpp" -#include "events.hpp" -#include "outbox.hpp" -#include "summarizer.hpp" -#include "worker_pool.hpp" - namespace launchdarkly::events { template -class AsioEventProcessor { +class AsioEventProcessor : public IEventProcessor { public: AsioEventProcessor( boost::asio::any_io_executor const& io, @@ -34,11 +36,11 @@ class AsioEventProcessor { config::shared::built::HttpProperties const& http_properties, Logger& logger); - void AsyncFlush(); + virtual void FlushAsync() override; - void AsyncSend(InputEvent event); + virtual void SendAsync(events::InputEvent event) override; - void AsyncClose(); + virtual void ShutdownAsync() override; private: using Clock = std::chrono::system_clock; @@ -48,8 +50,8 @@ class AsioEventProcessor { }; boost::asio::any_io_executor io_; - Outbox outbox_; - Summarizer summarizer_; + detail::Outbox outbox_; + detail::Summarizer summarizer_; std::chrono::milliseconds flush_interval_; boost::asio::steady_timer timer_; @@ -60,7 +62,7 @@ class AsioEventProcessor { boost::uuids::random_generator uuids_; - WorkerPool workers_; + detail::WorkerPool workers_; std::size_t inbox_capacity_; std::size_t inbox_size_; @@ -74,11 +76,13 @@ class AsioEventProcessor { launchdarkly::ContextFilter filter_; + detail::LRUCache context_key_cache_; + Logger& logger_; void HandleSend(InputEvent event); - std::optional CreateBatch(); + std::optional CreateBatch(); void Flush(FlushTrigger flush_type); @@ -90,7 +94,7 @@ class AsioEventProcessor { void InboxDecrement(); void OnEventDeliveryResult(std::size_t count, - RequestWorker::DeliveryResult); + detail::RequestWorker::DeliveryResult); }; } // namespace launchdarkly::events diff --git a/libs/internal/include/launchdarkly/events/client_events.hpp b/libs/internal/include/launchdarkly/events/client_events.hpp deleted file mode 100644 index 595a6ae0d..000000000 --- a/libs/internal/include/launchdarkly/events/client_events.hpp +++ /dev/null @@ -1,50 +0,0 @@ -#pragma once - -#include "common_events.hpp" - -namespace launchdarkly::events::client { - -struct IdentifyEventParams { - Date creation_date; - Context context; -}; - -struct IdentifyEvent { - Date creation_date; - EventContext context; -}; - -struct FeatureEventParams { - Date creation_date; - std::string key; - Context context; - Value value; - Value default_; - std::optional version; - std::optional variation; - std::optional reason; - bool require_full_event; - std::optional debug_events_until_date; -}; - -struct FeatureEventBase { - Date creation_date; - std::string key; - std::optional version; - std::optional variation; - Value value; - std::optional reason; - Value default_; - - explicit FeatureEventBase(FeatureEventParams const& params); -}; - -struct FeatureEvent : public FeatureEventBase { - ContextKeys context_keys; -}; - -struct DebugEvent : public FeatureEventBase { - EventContext context; -}; - -} // namespace launchdarkly::events::client diff --git a/libs/internal/include/launchdarkly/events/common_events.hpp b/libs/internal/include/launchdarkly/events/common_events.hpp deleted file mode 100644 index a2f95c8be..000000000 --- a/libs/internal/include/launchdarkly/events/common_events.hpp +++ /dev/null @@ -1,39 +0,0 @@ -#pragma once - -#include -#include -#include - -#include - -#include -#include -#include - -namespace launchdarkly::events { - -using Value = launchdarkly::Value; -using VariationIndex = size_t; -using Reason = EvaluationReason; -using Result = EvaluationResult; -using Context = launchdarkly::Context; -using EventContext = boost::json::value; -using Version = std::uint64_t; -using ContextKeys = std::map; - -struct Date { - std::chrono::system_clock::time_point t; -}; - -struct TrackEventParams { - Date creation_date; - std::string key; - ContextKeys context_keys; - std::optional data; - std::optional metric_value; -}; - -// Track (custom) events are directly serialized from their parameters. -using TrackEvent = TrackEventParams; - -} // namespace launchdarkly::events diff --git a/libs/internal/include/launchdarkly/events/data/common_events.hpp b/libs/internal/include/launchdarkly/events/data/common_events.hpp new file mode 100644 index 000000000..b8740319d --- /dev/null +++ b/libs/internal/include/launchdarkly/events/data/common_events.hpp @@ -0,0 +1,89 @@ +#pragma once + +#include +#include +#include + +#include + +#include +#include +#include + +namespace launchdarkly::events { + +using Value = launchdarkly::Value; +using VariationIndex = size_t; +using Reason = EvaluationReason; +using Result = EvaluationResult; +using Context = launchdarkly::Context; +using EventContext = boost::json::value; +using Version = std::uint64_t; +using ContextKeys = std::map; + +struct Date { + std::chrono::system_clock::time_point t; +}; + +struct TrackEventParams { + Date creation_date; + std::string key; + ContextKeys context_keys; + std::optional data; + std::optional metric_value; +}; + +struct ServerTrackEventParams : public TrackEventParams { + Context context; +}; + +using ClientTrackEventParams = TrackEventParams; + +using TrackEvent = TrackEventParams; + +struct IdentifyEventParams { + Date creation_date; + Context context; +}; + +struct IdentifyEvent { + Date creation_date; + EventContext context; +}; + +struct FeatureEventParams { + Date creation_date; + std::string key; + Context context; + Value value; + Value default_; + std::optional version; + std::optional variation; + std::optional reason; + bool require_full_event; + std::optional debug_events_until_date; + std::optional prereq_of; +}; + +struct FeatureEventBase { + Date creation_date; + std::string key; + std::optional version; + std::optional variation; + Value value; + std::optional reason; + Value default_; + std::optional prereq_of; + + explicit FeatureEventBase(FeatureEventParams const& params); +}; + +struct FeatureEvent : public FeatureEventBase { + ContextKeys context_keys; +}; + +struct DebugEvent : public FeatureEventBase { + EventContext context; +}; + +} // namespace launchdarkly::events diff --git a/libs/internal/include/launchdarkly/events/data/events.hpp b/libs/internal/include/launchdarkly/events/data/events.hpp new file mode 100644 index 000000000..a0e00eae6 --- /dev/null +++ b/libs/internal/include/launchdarkly/events/data/events.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include +#include + +namespace launchdarkly::events { + +using InputEvent = std::variant; + +using OutputEvent = std::variant; + +} // namespace launchdarkly::events diff --git a/libs/internal/include/launchdarkly/events/data/server_events.hpp b/libs/internal/include/launchdarkly/events/data/server_events.hpp new file mode 100644 index 000000000..393f0ff0f --- /dev/null +++ b/libs/internal/include/launchdarkly/events/data/server_events.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include + +namespace launchdarkly::events::server_side { + +struct IndexEvent { + Date creation_date; + EventContext context; +}; + +} // namespace launchdarkly::events::server_side diff --git a/libs/internal/include/launchdarkly/events/event_batch.hpp b/libs/internal/include/launchdarkly/events/detail/event_batch.hpp similarity index 92% rename from libs/internal/include/launchdarkly/events/event_batch.hpp rename to libs/internal/include/launchdarkly/events/detail/event_batch.hpp index 10af02fce..71a866cdf 100644 --- a/libs/internal/include/launchdarkly/events/event_batch.hpp +++ b/libs/internal/include/launchdarkly/events/detail/event_batch.hpp @@ -1,11 +1,13 @@ #pragma once -#include #include #include + +#include + #include -namespace launchdarkly::events { +namespace launchdarkly::events::detail { /** * EventBatch represents a batch of events being sent to LaunchDarkly as @@ -43,4 +45,4 @@ class EventBatch { network::HttpRequest request_; }; -} // namespace launchdarkly::events +} // namespace launchdarkly::events::detail diff --git a/libs/internal/include/launchdarkly/events/detail/lru_cache.hpp b/libs/internal/include/launchdarkly/events/detail/lru_cache.hpp new file mode 100644 index 000000000..423831dfe --- /dev/null +++ b/libs/internal/include/launchdarkly/events/detail/lru_cache.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include + +namespace launchdarkly::events::detail { + +class LRUCache { + public: + /** + * Constructs a new cache with a given capacity. When capacity is exceeded, + * entries are evicted from the cache in LRU order. + * @param capacity + */ + explicit LRUCache(std::size_t capacity); + + /** + * Adds a value to the cache; returns true if it was already there. + * @param value Value to add. + * @return True if the value was already in the cache. + */ + bool Notice(std::string const& value); + + /** + * Returns the current size of the cache. + * @return Number of unique entries in cache. + */ + std::size_t Size() const; + + /** + * Clears all cache entries. + */ + void Clear(); + + private: + using KeyList = std::list; + std::size_t capacity_; + std::unordered_map map_; + KeyList list_; +}; + +} // namespace launchdarkly::events::detail diff --git a/libs/internal/include/launchdarkly/events/outbox.hpp b/libs/internal/include/launchdarkly/events/detail/outbox.hpp similarity index 79% rename from libs/internal/include/launchdarkly/events/outbox.hpp rename to libs/internal/include/launchdarkly/events/detail/outbox.hpp index b51dc0fcf..50e09422a 100644 --- a/libs/internal/include/launchdarkly/events/outbox.hpp +++ b/libs/internal/include/launchdarkly/events/detail/outbox.hpp @@ -1,11 +1,12 @@ #pragma once +#include + #include #include #include -#include "events.hpp" -namespace launchdarkly::events { +namespace launchdarkly::events::detail { /** * Represents a fixed-size queue for holding output events, which are events @@ -27,18 +28,18 @@ class Outbox { * @return True if all events were accepted; false if >= 1 events were * dropped. */ - bool PushDiscardingOverflow(std::vector events); + [[nodiscard]] bool PushDiscardingOverflow(std::vector events); /** * Consumes all events in the outbox. * @return All events in the outbox, in the order they were pushed. */ - std::vector Consume(); + [[nodiscard]] std::vector Consume(); /** * True if the outbox is empty. */ - bool Empty(); + [[nodiscard]] bool Empty() const; private: std::queue items_; @@ -49,4 +50,4 @@ class Outbox { bool Push(OutputEvent item); }; -} // namespace launchdarkly::events +} // namespace launchdarkly::events::detail diff --git a/libs/internal/include/launchdarkly/events/parse_date_header.hpp b/libs/internal/include/launchdarkly/events/detail/parse_date_header.hpp similarity index 93% rename from libs/internal/include/launchdarkly/events/parse_date_header.hpp rename to libs/internal/include/launchdarkly/events/detail/parse_date_header.hpp index 2d78ab43b..972270c0b 100644 --- a/libs/internal/include/launchdarkly/events/parse_date_header.hpp +++ b/libs/internal/include/launchdarkly/events/detail/parse_date_header.hpp @@ -5,7 +5,7 @@ #include #include -namespace launchdarkly::events { +namespace launchdarkly::events::detail { template static std::optional ParseDateHeader( @@ -40,4 +40,4 @@ static std::optional ParseDateHeader( return Clock::from_time_t(real_gm_t); } -} // namespace launchdarkly::events +} // namespace launchdarkly::events::detail diff --git a/libs/internal/include/launchdarkly/events/request_worker.hpp b/libs/internal/include/launchdarkly/events/detail/request_worker.hpp similarity index 97% rename from libs/internal/include/launchdarkly/events/request_worker.hpp rename to libs/internal/include/launchdarkly/events/detail/request_worker.hpp index 22f846469..d4fac42f3 100644 --- a/libs/internal/include/launchdarkly/events/request_worker.hpp +++ b/libs/internal/include/launchdarkly/events/detail/request_worker.hpp @@ -9,9 +9,9 @@ #include #include -#include "event_batch.hpp" +#include -namespace launchdarkly::events { +namespace launchdarkly::events::detail { enum class State { /* Worker is ready for a new job. */ @@ -168,4 +168,4 @@ class RequestWorker { void OnDeliveryAttempt(network::HttpResult request, ResultCallback cb); }; -} // namespace launchdarkly::events +} // namespace launchdarkly::events::detail diff --git a/libs/internal/include/launchdarkly/events/summarizer.hpp b/libs/internal/include/launchdarkly/events/detail/summarizer.hpp similarity index 89% rename from libs/internal/include/launchdarkly/events/summarizer.hpp rename to libs/internal/include/launchdarkly/events/detail/summarizer.hpp index ce6a553fd..e87a6055c 100644 --- a/libs/internal/include/launchdarkly/events/summarizer.hpp +++ b/libs/internal/include/launchdarkly/events/detail/summarizer.hpp @@ -7,11 +7,11 @@ #include #include -#include +#include "launchdarkly/value.hpp" -#include "events.hpp" +#include "launchdarkly/events/data/events.hpp" -namespace launchdarkly::events { +namespace launchdarkly::events::detail { /** * Summarizer is responsible for accepting FeatureEventParams (the context @@ -39,7 +39,7 @@ class Summarizer { * Updates the summary with a feature event. * @param event Feature event. */ - void Update(client::FeatureEventParams const& event); + void Update(events::FeatureEventParams const& event); /** * Marks the summary as finished at a given timestamp. @@ -55,12 +55,12 @@ class Summarizer { /** * Returns the summary's start time as given in the constructor. */ - [[nodiscard]] Time start_time() const; + [[nodiscard]] Time StartTime() const; /** * Returns the summary's end time as specified using Finish. */ - [[nodiscard]] Time end_time() const; + [[nodiscard]] Time EndTime() const; struct VariationSummary { public: @@ -111,4 +111,4 @@ class Summarizer { std::unordered_map features_; }; -} // namespace launchdarkly::events +} // namespace launchdarkly::events::detail diff --git a/libs/internal/include/launchdarkly/events/worker_pool.hpp b/libs/internal/include/launchdarkly/events/detail/worker_pool.hpp similarity index 93% rename from libs/internal/include/launchdarkly/events/worker_pool.hpp rename to libs/internal/include/launchdarkly/events/detail/worker_pool.hpp index bb66b2ff2..9b29e32df 100644 --- a/libs/internal/include/launchdarkly/events/worker_pool.hpp +++ b/libs/internal/include/launchdarkly/events/detail/worker_pool.hpp @@ -1,5 +1,10 @@ #pragma once +#include +#include +#include +#include + #include #include @@ -7,13 +12,7 @@ #include #include -#include -#include -#include - -#include "request_worker.hpp" - -namespace launchdarkly::events { +namespace launchdarkly::events::detail { /** * WorkerPool represents a pool of workers capable of delivering event payloads @@ -73,4 +72,4 @@ class WorkerPool { std::vector> workers_; }; -} // namespace launchdarkly::events +} // namespace launchdarkly::events::detail diff --git a/libs/client-sdk/src/event_processor.hpp b/libs/internal/include/launchdarkly/events/event_processor_interface.hpp similarity index 89% rename from libs/client-sdk/src/event_processor.hpp rename to libs/internal/include/launchdarkly/events/event_processor_interface.hpp index 9b6b25d0c..4e1a9e631 100644 --- a/libs/client-sdk/src/event_processor.hpp +++ b/libs/internal/include/launchdarkly/events/event_processor_interface.hpp @@ -1,8 +1,8 @@ #pragma once -#include +#include -namespace launchdarkly::client_side { +namespace launchdarkly::events { class IEventProcessor { public: @@ -34,4 +34,4 @@ class IEventProcessor { IEventProcessor() = default; }; -} // namespace launchdarkly::client_side +} // namespace launchdarkly::events diff --git a/libs/internal/include/launchdarkly/events/events.hpp b/libs/internal/include/launchdarkly/events/events.hpp deleted file mode 100644 index e3770c13d..000000000 --- a/libs/internal/include/launchdarkly/events/events.hpp +++ /dev/null @@ -1,15 +0,0 @@ -#pragma once - -#include "client_events.hpp" -namespace launchdarkly::events { - -using InputEvent = std::variant; - -using OutputEvent = std::variant; - -} // namespace launchdarkly::events diff --git a/libs/client-sdk/src/event_processor/null_event_processor.hpp b/libs/internal/include/launchdarkly/events/null_event_processor.hpp similarity index 64% rename from libs/client-sdk/src/event_processor/null_event_processor.hpp rename to libs/internal/include/launchdarkly/events/null_event_processor.hpp index 1cb615302..8f228fb54 100644 --- a/libs/client-sdk/src/event_processor/null_event_processor.hpp +++ b/libs/internal/include/launchdarkly/events/null_event_processor.hpp @@ -1,8 +1,8 @@ #pragma once -#include "../event_processor.hpp" +#include -namespace launchdarkly::client_side { +namespace launchdarkly::events { class NullEventProcessor : public IEventProcessor { public: @@ -11,4 +11,4 @@ class NullEventProcessor : public IEventProcessor { void FlushAsync() override; void ShutdownAsync() override; }; -} // namespace launchdarkly::client_side +} // namespace launchdarkly::events diff --git a/libs/internal/include/launchdarkly/serialization/events/json_events.hpp b/libs/internal/include/launchdarkly/serialization/events/json_events.hpp index cdb6acf2a..1578eed07 100644 --- a/libs/internal/include/launchdarkly/serialization/events/json_events.hpp +++ b/libs/internal/include/launchdarkly/serialization/events/json_events.hpp @@ -1,11 +1,19 @@ #pragma once +#include +#include + #include -#include -#include +namespace launchdarkly::events::server_side { + +void tag_invoke(boost::json::value_from_tag const&, + boost::json::value& json_value, + IndexEvent const& event); +} // namespace launchdarkly::events::server_side + +namespace launchdarkly::events { -namespace launchdarkly::events::client { void tag_invoke(boost::json::value_from_tag const&, boost::json::value& json_value, FeatureEvent const& event); @@ -21,9 +29,6 @@ void tag_invoke(boost::json::value_from_tag const&, void tag_invoke(boost::json::value_from_tag const&, boost::json::value& json_value, DebugEvent const& event); -} // namespace launchdarkly::events::client - -namespace launchdarkly::events { void tag_invoke(boost::json::value_from_tag const&, boost::json::value& json_value, @@ -39,7 +44,7 @@ void tag_invoke(boost::json::value_from_tag const&, } // namespace launchdarkly::events -namespace launchdarkly::events { +namespace launchdarkly::events::detail { void tag_invoke(boost::json::value_from_tag const&, boost::json::value& json_value, @@ -47,4 +52,4 @@ void tag_invoke(boost::json::value_from_tag const&, void tag_invoke(boost::json::value_from_tag const&, boost::json::value& json_value, Summarizer const& summary); -} // namespace launchdarkly::events +} // namespace launchdarkly::events::detail diff --git a/libs/internal/include/launchdarkly/serialization/json_attributes.hpp b/libs/internal/include/launchdarkly/serialization/json_attributes.hpp index 5dbd20c36..c7be6612b 100644 --- a/libs/internal/include/launchdarkly/serialization/json_attributes.hpp +++ b/libs/internal/include/launchdarkly/serialization/json_attributes.hpp @@ -1,9 +1,9 @@ #pragma once -#include - #include +#include + namespace launchdarkly { /** * Method used by boost::json for converting launchdarkly::Attributes into a diff --git a/libs/internal/include/launchdarkly/serialization/json_context.hpp b/libs/internal/include/launchdarkly/serialization/json_context.hpp index 491b7ebde..28c03b51c 100644 --- a/libs/internal/include/launchdarkly/serialization/json_context.hpp +++ b/libs/internal/include/launchdarkly/serialization/json_context.hpp @@ -1,12 +1,11 @@ #pragma once -#include - -#include - #include #include +#include +#include + namespace launchdarkly { /** * Method used by boost::json for converting a launchdarkly::Context into a diff --git a/libs/internal/include/launchdarkly/serialization/json_context_aware_reference.hpp b/libs/internal/include/launchdarkly/serialization/json_context_aware_reference.hpp new file mode 100644 index 000000000..c9eac9038 --- /dev/null +++ b/libs/internal/include/launchdarkly/serialization/json_context_aware_reference.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +namespace launchdarkly { + +template +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, + JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + + using Type = data_model::ContextAwareReference; + + auto const& obj = json_value.as_object(); + + std::optional kind; + + PARSE_CONDITIONAL_FIELD(kind, obj, Type::fields::kContextFieldName); + + std::string attr_ref_or_name; + PARSE_FIELD_DEFAULT(attr_ref_or_name, obj, + Type::fields::kReferenceFieldName, "key"); + + if (kind) { + return Type{*kind, + AttributeReference::FromReferenceStr(attr_ref_or_name)}; + } + + return Type{data_model::ContextKind("user"), + AttributeReference::FromLiteralStr(attr_ref_or_name)}; +} + +} // namespace launchdarkly diff --git a/libs/internal/include/launchdarkly/serialization/json_context_kind.hpp b/libs/internal/include/launchdarkly/serialization/json_context_kind.hpp new file mode 100644 index 000000000..c7046a00b --- /dev/null +++ b/libs/internal/include/launchdarkly/serialization/json_context_kind.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include +#include + +#include +#include + +#include + +namespace launchdarkly { + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& + unused, + boost::json::value const& json_value); + +} // namespace launchdarkly diff --git a/libs/internal/include/launchdarkly/serialization/json_evaluation_reason.hpp b/libs/internal/include/launchdarkly/serialization/json_evaluation_reason.hpp index cdb1734a4..deb3d9dfe 100644 --- a/libs/internal/include/launchdarkly/serialization/json_evaluation_reason.hpp +++ b/libs/internal/include/launchdarkly/serialization/json_evaluation_reason.hpp @@ -1,11 +1,10 @@ #pragma once -#include "tl/expected.hpp" - -#include - #include -#include "json_errors.hpp" +#include + +#include +#include namespace launchdarkly { /** @@ -24,8 +23,8 @@ tl::expected tag_invoke( boost::json::value const& json_value); tl::expected tag_invoke( - boost::json::value_to_tag< - tl::expected> const& unused, + boost::json::value_to_tag> const& unused, boost::json::value const& json_value); void tag_invoke(boost::json::value_from_tag const& unused, diff --git a/libs/internal/include/launchdarkly/serialization/json_evaluation_result.hpp b/libs/internal/include/launchdarkly/serialization/json_evaluation_result.hpp index d03617cfa..71f3f37c2 100644 --- a/libs/internal/include/launchdarkly/serialization/json_evaluation_result.hpp +++ b/libs/internal/include/launchdarkly/serialization/json_evaluation_result.hpp @@ -1,21 +1,16 @@ #pragma once -#include "tl/expected.hpp" - -#include - #include -#include "json_errors.hpp" +#include + +#include +#include namespace launchdarkly { -/** - * Method used by boost::json for converting a boost::json::value into a - * launchdarkly::EvaluationResult. - * @return A EvaluationResult representation of the boost::json::value. - */ -tl::expected tag_invoke( - boost::json::value_to_tag> const& - unused, + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& unused, boost::json::value const& json_value); void tag_invoke(boost::json::value_from_tag const& unused, diff --git a/libs/internal/include/launchdarkly/serialization/json_flag.hpp b/libs/internal/include/launchdarkly/serialization/json_flag.hpp new file mode 100644 index 000000000..775e2cb15 --- /dev/null +++ b/libs/internal/include/launchdarkly/serialization/json_flag.hpp @@ -0,0 +1,107 @@ +#pragma once + +#include +#include + +#include + +namespace launchdarkly { + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, + JsonError>> const& unused, + boost::json::value const& json_value); + +tl::expected, JsonError> +tag_invoke(boost::json::value_to_tag< + tl::expected, + JsonError>> const& unused, + boost::json::value const& json_value); + +tl::expected, + JsonError> +tag_invoke(boost::json::value_to_tag, + JsonError>> const& unused, + boost::json::value const& json_value); + +tl::expected, JsonError> +tag_invoke(boost::json::value_to_tag< + tl::expected, + JsonError>> const& unused, + boost::json::value const& json_value); + +tl::expected, JsonError> +tag_invoke(boost::json::value_to_tag< + tl::expected, + JsonError>> const& unused, + boost::json::value const& json_value); + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& + unused, + boost::json::value const& json_value); + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& + unused, + boost::json::value const& json_value); + +tl::expected, JsonError> +tag_invoke( + boost::json::value_to_tag< + tl::expected, + JsonError>> const& unused, + boost::json::value const& json_value); + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& unused, + boost::json::value const& json_value); + +// Serializers need to be in launchdarkly::data_model for ADL. +namespace data_model { + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Flag::Rollout const& rollout); + +void tag_invoke( + boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Flag::VariationOrRollout const& variation_or_rollout); + +void tag_invoke( + boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Flag::Rollout::WeightedVariation const& weighted_variation); + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Flag::Rollout::Kind const& kind); + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Flag::Prerequisite const& prerequisite); + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Flag::Target const& target); + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Flag::Rule const& rule); + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Flag::ClientSideAvailability const& availability); + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Flag const& flag); + +} // namespace data_model +} // namespace launchdarkly diff --git a/libs/internal/include/launchdarkly/serialization/json_item_descriptor.hpp b/libs/internal/include/launchdarkly/serialization/json_item_descriptor.hpp new file mode 100644 index 000000000..a04ffe378 --- /dev/null +++ b/libs/internal/include/launchdarkly/serialization/json_item_descriptor.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +namespace launchdarkly { + +template +tl::expected>, JsonError> +tag_invoke(boost::json::value_to_tag< + tl::expected>, + JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + + auto maybe_item = + boost::json::value_to, JsonError>>( + json_value); + + if (!maybe_item) { + return tl::unexpected(maybe_item.error()); + } + + auto const& item = maybe_item.value(); + + if (!item) { + return std::nullopt; + } + + return data_model::ItemDescriptor(std::move(item.value())); +} + +template +void tag_invoke( + boost::json::value_from_tag const& unused, + boost::json::value& json_value, + std::unordered_map>> const& + all_flags) { + boost::ignore_unused(unused); + + auto& obj = json_value.emplace_object(); + for (auto descriptor : all_flags) { + // Only serialize non-deleted items.. + if (descriptor.second->item) { + auto eval_result_json = + boost::json::value_from(*descriptor.second->item); + obj.emplace(descriptor.first, eval_result_json); + } + } +} +} // namespace launchdarkly diff --git a/libs/internal/include/launchdarkly/serialization/json_primitives.hpp b/libs/internal/include/launchdarkly/serialization/json_primitives.hpp new file mode 100644 index 000000000..268b9596a --- /dev/null +++ b/libs/internal/include/launchdarkly/serialization/json_primitives.hpp @@ -0,0 +1,121 @@ +#pragma once + +#include +#include + +#include +#include + +#include +#include + +namespace launchdarkly { + +template +tl::expected>, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected>, JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + + if (json_value.is_null()) { + return std::nullopt; + } + + if (!json_value.is_array()) { + return tl::unexpected(JsonError::kSchemaFailure); + } + + auto const& arr = json_value.as_array(); + std::vector items; + items.reserve(arr.size()); + for (auto const& item : arr) { + auto eval_result = + boost::json::value_to, JsonError>>( + item); + if (!eval_result.has_value()) { + return tl::unexpected(eval_result.error()); + } + auto maybe_val = eval_result.value(); + if (maybe_val) { + items.emplace_back(std::move(maybe_val.value())); + } + } + return items; +} + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& unused, + boost::json::value const& json_value); + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& unused, + boost::json::value const& json_value); + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& unused, + boost::json::value const& json_value); + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& unused, + boost::json::value const& json_value); + +template +tl::expected>, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected>, JsonError>> const& + unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + + if (json_value.is_null()) { + return std::nullopt; + } + if (!json_value.is_object()) { + return tl::unexpected(JsonError::kSchemaFailure); + } + auto const& obj = json_value.as_object(); + std::unordered_map descriptors; + for (auto const& pair : obj) { + auto eval_result = + boost::json::value_to, JsonError>>( + pair.value()); + if (!eval_result) { + return tl::unexpected(eval_result.error()); + } + auto const& maybe_val = eval_result.value(); + if (maybe_val) { + descriptors.emplace(pair.key(), std::move(maybe_val.value())); + } + } + return descriptors; +} + +/** + * Convenience implementation that deserializes a T via the tag_invoke overload + * for std::optional. + * + * If that overload returns std::nullopt, this returns + * a default-constructed T. + * + * Json errors are propagated. + */ +template +tl::expected tag_invoke( + boost::json::value_to_tag> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + auto maybe_val = + boost::json::value_to, JsonError>>( + json_value); + if (!maybe_val.has_value()) { + return tl::unexpected(maybe_val.error()); + } + return maybe_val.value().value_or(T()); +} + +} // namespace launchdarkly diff --git a/libs/internal/include/launchdarkly/serialization/json_rule_clause.hpp b/libs/internal/include/launchdarkly/serialization/json_rule_clause.hpp new file mode 100644 index 000000000..a66d85606 --- /dev/null +++ b/libs/internal/include/launchdarkly/serialization/json_rule_clause.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include +#include + +#include +#include + +namespace launchdarkly { +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag, + JsonError>> const& unused, + boost::json::value const& json_value); + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& + unused, + boost::json::value const& json_value); + +tl::expected tag_invoke( + boost::json::value_to_tag< + tl::expected> const& unused, + boost::json::value const& json_value); + +// Serialization needs to be in launchdarkly::data_model for ADL. +namespace data_model { + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Clause const& clause); + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Clause::Op const& op); + +} // namespace data_model + +} // namespace launchdarkly diff --git a/libs/internal/include/launchdarkly/serialization/json_sdk_data_set.hpp b/libs/internal/include/launchdarkly/serialization/json_sdk_data_set.hpp new file mode 100644 index 000000000..f2501bfa3 --- /dev/null +++ b/libs/internal/include/launchdarkly/serialization/json_sdk_data_set.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include +#include + +#include + +namespace launchdarkly { +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& + unused, + boost::json::value const& json_value); + +} // namespace launchdarkly diff --git a/libs/internal/include/launchdarkly/serialization/json_segment.hpp b/libs/internal/include/launchdarkly/serialization/json_segment.hpp new file mode 100644 index 000000000..83987903e --- /dev/null +++ b/libs/internal/include/launchdarkly/serialization/json_segment.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include +#include + +#include + +namespace launchdarkly { +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag, + JsonError>> const& unused, + boost::json::value const& json_value); + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, + JsonError>> const& unused, + boost::json::value const& json_value); + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, + JsonError>> const& unused, + boost::json::value const& json_value); + +// Serializers need to be in launchdarkly::data_model for ADL. +namespace data_model { + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Segment const& segment); + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Segment::Target const& target); + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Segment::Rule const& rule); + +} // namespace data_model + +} // namespace launchdarkly diff --git a/libs/internal/include/launchdarkly/serialization/json_value.hpp b/libs/internal/include/launchdarkly/serialization/json_value.hpp index 5c02047d9..c99b1f5bb 100644 --- a/libs/internal/include/launchdarkly/serialization/json_value.hpp +++ b/libs/internal/include/launchdarkly/serialization/json_value.hpp @@ -1,17 +1,24 @@ #pragma once -#include - +#include #include +#include +#include + namespace launchdarkly { /** * Method used by boost::json for converting a boost::json::value into a * launchdarkly::Value. * @return A Value representation of the boost::json::value. */ +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const&, + boost::json::value const&); + Value tag_invoke(boost::json::value_to_tag const&, - boost::json::value const&); + boost::json::value const& json_value); /** * Method used by boost::json for converting a launchdarkly::Value into a diff --git a/libs/internal/include/launchdarkly/serialization/value_mapping.hpp b/libs/internal/include/launchdarkly/serialization/value_mapping.hpp index b7cc5aa3d..753146d1a 100644 --- a/libs/internal/include/launchdarkly/serialization/value_mapping.hpp +++ b/libs/internal/include/launchdarkly/serialization/value_mapping.hpp @@ -1,10 +1,108 @@ #pragma once +#include +#include + #include #include +#include -namespace launchdarkly { +#include +#include + +// Parses a field, propagating an error if the field's value is of the wrong +// type. If the field was null or omitted in the data, it is set to +// default_value. +#define PARSE_FIELD_DEFAULT(field, obj, key, default_value) \ + do { \ + std::optional maybe_val; \ + PARSE_CONDITIONAL_FIELD(maybe_val, obj, key); \ + field = maybe_val.value_or(default_value); \ + } while (0) + +// Parses a field, propagating an error if the field's value is of the wrong +// type. Intended for fields where the "zero value" of that field is a valid +// member of the domain of that field. If the "zero value" of the field is meant +// to denote absence of that field, rather than a valid member of the domain, +// then use PARSE_CONDITIONAL_FIELD in order to avoid discarding the information +// of whether that field was present or not. +#define PARSE_FIELD(field, obj, key) \ + do { \ + static_assert(std::is_default_constructible_v && \ + "field must be default-constructible"); \ + PARSE_FIELD_DEFAULT(field, obj, key, decltype(field){}); \ + } while (0) + +// Parses a field that is conditional and/or has no valid default value. +// This would be the case for fields that depend on the existence of some other +// field. Another scenario would be a string field representing enum values, +// where there's no default defined/empty string is meaningless. It will +// propagate an error if the field's value is of the wrong type. Intended to be +// called on fields of type std::optional. +#define PARSE_CONDITIONAL_FIELD(field, obj, key) \ + do { \ + auto const& it = obj.find(key); \ + if (it != obj.end()) { \ + if (auto result = boost::json::value_to< \ + tl::expected>(it->value())) { \ + field = result.value(); \ + } else { \ + /* Field was of wrong type. */ \ + return tl::make_unexpected(result.error()); \ + } \ + } \ + } while (0) + +// Parses a field, propagating an error if it is omitted/null or if the field's +// value is of the wrong type. Use only if the field *must* be present in the +// JSON document. Think twice; this is unlikely - most fields have a +// well-defined default value that can be used if not present. +#define PARSE_REQUIRED_FIELD(field, obj, key) \ + do { \ + auto const& it = obj.find(key); \ + if (it == obj.end()) { \ + /* Ideally report that field is missing, instead of generic \ + * failure */ \ + return tl::make_unexpected(JsonError::kSchemaFailure); \ + } \ + auto result = boost::json::value_to< \ + tl::expected, JsonError>>( \ + it->value()); \ + if (!result) { \ + /* The field's value is of the wrong type. */ \ + return tl::make_unexpected(result.error()); \ + } \ + /* We have the field, but its value might be null. */ \ + auto const& maybe_val = result.value(); \ + if (!maybe_val) { \ + /* Ideally report that the field was null, instead of generic \ + * failure. */ \ + return tl::make_unexpected(JsonError::kSchemaFailure); \ + } \ + field = std::move(*maybe_val); \ + } while (0) + +#define REQUIRE_OBJECT(value) \ + do { \ + if (json_value.is_null()) { \ + return std::nullopt; \ + } \ + if (!json_value.is_object()) { \ + return tl::make_unexpected(JsonError::kSchemaFailure); \ + } \ + } while (0) +#define REQUIRE_STRING(value) \ + do { \ + if (json_value.is_null()) { \ + return std::nullopt; \ + } \ + if (!json_value.is_string()) { \ + return tl::make_unexpected(JsonError::kSchemaFailure); \ + } \ + } while (0) + +namespace launchdarkly { template std::optional ValueAsOpt(boost::json::object::const_iterator iterator, boost::json::object::const_iterator end) { @@ -58,4 +156,18 @@ template <> std::string ValueOrDefault(boost::json::object::const_iterator iterator, boost::json::object::const_iterator end, std::string default_value); + +template +void WriteMinimal(boost::json::object& obj, + std::string const& key, // No copy when not used. + std::optional val) { + if (val.has_value()) { + obj.emplace(key, val.value()); + } +} + +void WriteMinimal(boost::json::object& obj, + std::string const& key, // No copy when not used. + bool val); + } // namespace launchdarkly diff --git a/libs/client-sdk/src/boost_signal_connection.hpp b/libs/internal/include/launchdarkly/signals/boost_signal_connection.hpp similarity index 78% rename from libs/client-sdk/src/boost_signal_connection.hpp rename to libs/internal/include/launchdarkly/signals/boost_signal_connection.hpp index 16eeda9f9..14e92348a 100644 --- a/libs/client-sdk/src/boost_signal_connection.hpp +++ b/libs/internal/include/launchdarkly/signals/boost_signal_connection.hpp @@ -4,7 +4,7 @@ #include -namespace launchdarkly::client_side { +namespace launchdarkly::internal::signals { class SignalConnection : public IConnection { public: @@ -16,4 +16,4 @@ class SignalConnection : public IConnection { boost::signals2::connection connection_; }; -} // namespace launchdarkly::client_side +} // namespace launchdarkly::internal::signals diff --git a/libs/internal/src/CMakeLists.txt b/libs/internal/src/CMakeLists.txt index d8768e0b7..e156a8e72 100644 --- a/libs/internal/src/CMakeLists.txt +++ b/libs/internal/src/CMakeLists.txt @@ -5,6 +5,8 @@ file(GLOB HEADER_LIST CONFIGURE_DEPENDS "${LaunchDarklyInternalSdk_SOURCE_DIR}/include/launchdarkly/network/*.hpp" "${LaunchDarklyInternalSdk_SOURCE_DIR}/include/launchdarkly/serialization/*.hpp" "${LaunchDarklyInternalSdk_SOURCE_DIR}/include/launchdarkly/serialization/events/*.hpp" + "${LaunchDarklyInternalSdk_SOURCE_DIR}/include/launchdarkly/signals/*.hpp" + "${LaunchDarklyInternalSdk_SOURCE_DIR}/include/launchdarkly/data_sources/*.hpp" ) # Automatic library: static or dynamic based on user config. @@ -12,12 +14,14 @@ add_library(${LIBNAME} OBJECT ${HEADER_LIST} context_filter.cpp events/asio_event_processor.cpp - events/client_events.cpp + events/null_event_processor.cpp + events/common_events.cpp events/event_batch.cpp events/outbox.cpp events/request_worker.cpp events/summarizer.cpp events/worker_pool.cpp + events/lru_cache.cpp logging/console_backend.cpp logging/null_logger.cpp logging/logger.cpp @@ -31,8 +35,18 @@ add_library(${LIBNAME} OBJECT serialization/json_value.cpp serialization/value_mapping.cpp serialization/json_evaluation_result.cpp + serialization/json_sdk_data_set.cpp + serialization/json_segment.cpp + serialization/json_primitives.cpp + serialization/json_rule_clause.cpp + serialization/json_flag.cpp + serialization/json_context_kind.cpp + data_model/rule_clause.cpp + data_model/flag.cpp encoding/base_64.cpp - encoding/sha_256.cpp) + encoding/sha_256.cpp + encoding/sha_1.cpp + signals/boost_signal_connection.cpp) add_library(launchdarkly::internal ALIAS ${LIBNAME}) diff --git a/libs/internal/src/data_model/flag.cpp b/libs/internal/src/data_model/flag.cpp new file mode 100644 index 000000000..2a03074a7 --- /dev/null +++ b/libs/internal/src/data_model/flag.cpp @@ -0,0 +1,51 @@ +#include + +namespace launchdarkly::data_model { + +Flag::Rollout::WeightedVariation::WeightedVariation(Flag::Variation variation_, + Flag::Weight weight_) + : WeightedVariation(variation_, weight_, false) {} + +Flag::Rollout::WeightedVariation::WeightedVariation(Flag::Variation variation_, + Flag::Weight weight_, + bool untracked_) + : variation(variation_), weight(weight_), untracked(untracked_) {} + +Flag::Rollout::WeightedVariation Flag::Rollout::WeightedVariation::Untracked( + Flag::Variation variation_, + Flag::Weight weight_) { + return {variation_, weight_, true}; +} +Flag::Rollout::WeightedVariation::WeightedVariation() + : variation(0), weight(0), untracked(false) {} + +Flag::Rollout::Rollout(std::vector variations_) + : variations(std::move(variations_)), + kind(Kind::kRollout), + seed(std::nullopt), + bucketBy("key"), + contextKind("user") {} + +bool Flag::IsExperimentationEnabled( + std::optional const& reason) const { + if (!reason) { + return false; + } + if (reason->InExperiment()) { + return true; + } + switch (reason->Kind()) { + case EvaluationReason::Kind::kFallthrough: + return this->trackEventsFallthrough; + case EvaluationReason::Kind::kRuleMatch: + if (!reason->RuleIndex() || + reason->RuleIndex() >= this->rules.size()) { + return false; + } + return this->rules.at(*reason->RuleIndex()).trackEvents; + default: + return false; + } +} + +} // namespace launchdarkly::data_model diff --git a/libs/internal/src/data_model/rule_clause.cpp b/libs/internal/src/data_model/rule_clause.cpp new file mode 100644 index 000000000..664fa14aa --- /dev/null +++ b/libs/internal/src/data_model/rule_clause.cpp @@ -0,0 +1,62 @@ +#include + +namespace launchdarkly::data_model { + +std::ostream& operator<<(std::ostream& os, Clause::Op operator_) { + switch (operator_) { + case Clause::Op::kUnrecognized: + os << "unrecognized"; + break; + case Clause::Op::kIn: + os << "in"; + break; + case Clause::Op::kStartsWith: + os << "startsWith"; + break; + case Clause::Op::kEndsWith: + os << "endsWith"; + break; + case Clause::Op::kMatches: + os << "matches"; + break; + case Clause::Op::kContains: + os << "contains"; + break; + case Clause::Op::kLessThan: + os << "lessThan"; + break; + case Clause::Op::kLessThanOrEqual: + os << "lessThanOrEqual"; + break; + case Clause::Op::kGreaterThan: + os << "greaterThan"; + break; + case Clause::Op::kGreaterThanOrEqual: + os << "greaterThanOrEqual"; + break; + case Clause::Op::kBefore: + os << "before"; + break; + case Clause::Op::kAfter: + os << "after"; + break; + case Clause::Op::kSemVerEqual: + os << "semVerEqual"; + break; + case Clause::Op::kSemVerLessThan: + os << "semVerLessThan"; + break; + case Clause::Op::kSemVerGreaterThan: + os << "semVerGreaterThan"; + break; + case Clause::Op::kSegmentMatch: + os << "segmentMatch"; + break; + default: + os << "unknown"; + break; + } + return os; +} + +} // namespace launchdarkly::data_model diff --git a/libs/internal/src/encoding/sha_1.cpp b/libs/internal/src/encoding/sha_1.cpp new file mode 100644 index 000000000..c516995bb --- /dev/null +++ b/libs/internal/src/encoding/sha_1.cpp @@ -0,0 +1,18 @@ +#include + +#include + +namespace launchdarkly::encoding { + +std::array Sha1String( + std::string const& input) { + std::array hash{}; + + SHA_CTX sha; + SHA1_Init(&sha); + SHA1_Update(&sha, input.c_str(), input.size()); + SHA1_Final(hash.data(), &sha); + + return hash; +} +} // namespace launchdarkly::encoding diff --git a/libs/internal/src/encoding/sha_256.cpp b/libs/internal/src/encoding/sha_256.cpp index f968a8a98..a9c208979 100644 --- a/libs/internal/src/encoding/sha_256.cpp +++ b/libs/internal/src/encoding/sha_256.cpp @@ -1,24 +1,19 @@ -#include "openssl/sha.h" +#include #include -#include -#include -#include - namespace launchdarkly::encoding { std::array Sha256String( std::string const& input) { - unsigned char hash[SHA256_DIGEST_LENGTH]; + std::array hash{}; + SHA256_CTX sha256; SHA256_Init(&sha256); SHA256_Update(&sha256, input.c_str(), input.size()); - SHA256_Final(hash, &sha256); + SHA256_Final(hash.data(), &sha256); - std::array out; - std::copy(std::begin(hash), std::end(hash), out.begin()); - return out; + return hash; } } // namespace launchdarkly::encoding diff --git a/libs/internal/src/events/asio_event_processor.cpp b/libs/internal/src/events/asio_event_processor.cpp index 547f79638..c9bfd8cc4 100644 --- a/libs/internal/src/events/asio_event_processor.cpp +++ b/libs/internal/src/events/asio_event_processor.cpp @@ -11,6 +11,8 @@ #include #include +#include + namespace http = boost::beast::http; namespace launchdarkly::events { @@ -54,6 +56,7 @@ AsioEventProcessor::AsioEventProcessor( last_known_past_time_(std::nullopt), filter_(events_config.AllAttributesPrivate(), events_config.PrivateAttributes()), + context_key_cache_(events_config.ContextKeysCacheCapacity().value_or(0)), logger_(logger) { ScheduleFlush(); } @@ -87,7 +90,7 @@ void AsioEventProcessor::InboxDecrement() { } template -void AsioEventProcessor::AsyncSend(InputEvent input_event) { +void AsioEventProcessor::SendAsync(InputEvent input_event) { if (!InboxIncrement()) { return; } @@ -112,7 +115,7 @@ void AsioEventProcessor::HandleSend(InputEvent event) { template void AsioEventProcessor::Flush(FlushTrigger flush_type) { - workers_.Get([this](RequestWorker* worker) { + workers_.Get([this](detail::RequestWorker* worker) { if (worker == nullptr) { LD_LOG(logger_, LogLevel::kDebug) << "event-processor: no flush workers available; skipping " @@ -127,10 +130,11 @@ void AsioEventProcessor::Flush(FlushTrigger flush_type) { } worker->AsyncDeliver( std::move(*batch), - [this](std::size_t count, RequestWorker::DeliveryResult result) { + [this](std::size_t count, + detail::RequestWorker::DeliveryResult result) { OnEventDeliveryResult(count, result); }); - summarizer_ = Summarizer(Clock::now()); + summarizer_ = detail::Summarizer(Clock::now()); }); if (flush_type == FlushTrigger::Automatic) { @@ -141,7 +145,7 @@ void AsioEventProcessor::Flush(FlushTrigger flush_type) { template void AsioEventProcessor::OnEventDeliveryResult( std::size_t event_count, - RequestWorker::DeliveryResult result) { + detail::RequestWorker::DeliveryResult result) { boost::ignore_unused(event_count); std::visit( @@ -176,17 +180,17 @@ void AsioEventProcessor::ScheduleFlush() { } template -void AsioEventProcessor::AsyncFlush() { +void AsioEventProcessor::FlushAsync() { boost::asio::post(io_, [=] { Flush(FlushTrigger::Manual); }); } template -void AsioEventProcessor::AsyncClose() { +void AsioEventProcessor::ShutdownAsync() { timer_.cancel(); } template -std::optional AsioEventProcessor::CreateBatch() { +std::optional AsioEventProcessor::CreateBatch() { auto events = boost::json::value_from(outbox_.Consume()).as_array(); bool has_summary = @@ -205,7 +209,7 @@ std::optional AsioEventProcessor::CreateBatch() { props.Header(kPayloadIdHeader, boost::lexical_cast(uuids_())); props.Header(to_string(http::field::content_type), "application/json"); - return EventBatch(url_, props.Build(), events); + return detail::EventBatch(url_, props.Build(), events); } template @@ -213,52 +217,85 @@ std::vector AsioEventProcessor::Process( InputEvent input_event) { std::vector out; std::visit( - overloaded{[&](client::FeatureEventParams&& event) { - summarizer_.Update(event); - - client::FeatureEventBase base{event}; - - auto debug_until_date = event.debug_events_until_date; - - // To be conservative, use as the current time the - // maximum of the actual current time and the server's - // time. This way if the local host is running behind, we - // won't accidentally keep emitting events. - - auto conservative_now = std::max( - std::chrono::system_clock::now(), - last_known_past_time_.value_or( - std::chrono::system_clock::from_time_t(0))); - - bool emit_debug_event = - debug_until_date && - conservative_now < debug_until_date->t; - - if (emit_debug_event) { - out.emplace_back(client::DebugEvent{ - base, filter_.filter(event.context)}); - } - - if (event.require_full_event) { - out.emplace_back(client::FeatureEvent{ - std::move(base), event.context.KindsToKeys()}); - } - }, - [&](client::IdentifyEventParams&& event) { - // Contexts should already have been checked for - // validity by this point. - assert(event.context.Valid()); - out.emplace_back(client::IdentifyEvent{ - event.creation_date, filter_.filter(event.context)}); - }, - [&](TrackEventParams&& event) { - out.emplace_back(std::move(event)); - }}, + overloaded{ + [&](FeatureEventParams&& event) { + summarizer_.Update(event); + + if constexpr (std::is_same::value) { + if (!context_key_cache_.Notice( + event.context.CanonicalKey())) { + out.emplace_back(server_side::IndexEvent{ + event.creation_date, + filter_.filter(event.context)}); + } + } + + FeatureEventBase base{event}; + + auto debug_until_date = event.debug_events_until_date; + + // To be conservative, use as the current time the + // maximum of the actual current time and the server's + // time. This way if the local host is running behind, we + // won't accidentally keep emitting events. + + auto conservative_now = + std::max(std::chrono::system_clock::now(), + last_known_past_time_.value_or( + std::chrono::system_clock::from_time_t(0))); + + bool emit_debug_event = + debug_until_date && conservative_now < debug_until_date->t; + + if (emit_debug_event) { + out.emplace_back( + DebugEvent{base, filter_.filter(event.context)}); + } + + if (event.require_full_event) { + out.emplace_back(FeatureEvent{std::move(base), + event.context.KindsToKeys()}); + } + }, + [&](IdentifyEventParams&& event) { + // Contexts should already have been checked for + // validity by this point. + assert(event.context.Valid()); + + if constexpr (std::is_same::value) { + context_key_cache_.Notice(event.context.CanonicalKey()); + } + + out.emplace_back(IdentifyEvent{event.creation_date, + filter_.filter(event.context)}); + }, + [&](ClientTrackEventParams&& event) { + out.emplace_back(std::move(event)); + }, + [&](ServerTrackEventParams&& event) { + if constexpr (std::is_same::value) { + if (!context_key_cache_.Notice( + event.context.CanonicalKey())) { + out.emplace_back(server_side::IndexEvent{ + event.creation_date, + filter_.filter(event.context)}); + } + } + + // Object slicing on purpose; the context will be stripped out + // of the ServerTrackEventParams when converted to a + // TrackEventParams. + out.emplace_back(std::move(event)); + }}, std::move(input_event)); return out; } template class AsioEventProcessor; +template class AsioEventProcessor; } // namespace launchdarkly::events diff --git a/libs/internal/src/events/client_events.cpp b/libs/internal/src/events/common_events.cpp similarity index 57% rename from libs/internal/src/events/client_events.cpp rename to libs/internal/src/events/common_events.cpp index 33c4dcce4..e6fdbc096 100644 --- a/libs/internal/src/events/client_events.cpp +++ b/libs/internal/src/events/common_events.cpp @@ -1,6 +1,6 @@ -#include +#include -namespace launchdarkly::events::client { +namespace launchdarkly::events { FeatureEventBase::FeatureEventBase(FeatureEventParams const& params) : creation_date(params.creation_date), key(params.key), @@ -8,6 +8,6 @@ FeatureEventBase::FeatureEventBase(FeatureEventParams const& params) variation(params.variation), value(params.value), reason(params.reason), - default_(params.default_) {} - -} // namespace launchdarkly::events::client + default_(params.default_), + prereq_of(params.prereq_of) {} +} // namespace launchdarkly::events diff --git a/libs/internal/src/events/event_batch.cpp b/libs/internal/src/events/event_batch.cpp index ade9ad4ae..710affba7 100644 --- a/libs/internal/src/events/event_batch.cpp +++ b/libs/internal/src/events/event_batch.cpp @@ -1,8 +1,8 @@ -#include +#include #include -namespace launchdarkly::events { +namespace launchdarkly::events::detail { EventBatch::EventBatch(std::string url, config::shared::built::HttpProperties http_props, boost::json::value const& events) @@ -24,4 +24,4 @@ std::string EventBatch::Target() const { return request_.Url(); } -} // namespace launchdarkly::events +} // namespace launchdarkly::events::detail diff --git a/libs/internal/src/events/lru_cache.cpp b/libs/internal/src/events/lru_cache.cpp new file mode 100644 index 000000000..4b92375e9 --- /dev/null +++ b/libs/internal/src/events/lru_cache.cpp @@ -0,0 +1,32 @@ +#include + +namespace launchdarkly::events::detail { +LRUCache::LRUCache(std::size_t capacity) + : capacity_(capacity), map_(), list_() {} + +bool LRUCache::Notice(std::string const& value) { + auto it = map_.find(value); + if (it != map_.end()) { + list_.remove(value); + list_.push_front(value); + return true; + } + while (map_.size() >= capacity_) { + map_.erase(list_.back()); + list_.pop_back(); + } + list_.push_front(value); + map_.emplace(value, list_.front()); + return false; +} + +void LRUCache::Clear() { + map_.clear(); + list_.clear(); +} + +std::size_t LRUCache::Size() const { + return list_.size(); +} + +} // namespace launchdarkly::events::detail diff --git a/libs/client-sdk/src/event_processor/null_event_processor.cpp b/libs/internal/src/events/null_event_processor.cpp similarity index 54% rename from libs/client-sdk/src/event_processor/null_event_processor.cpp rename to libs/internal/src/events/null_event_processor.cpp index b12e710ca..3f6352515 100644 --- a/libs/client-sdk/src/event_processor/null_event_processor.cpp +++ b/libs/internal/src/events/null_event_processor.cpp @@ -1,10 +1,10 @@ -#include "null_event_processor.hpp" +#include -namespace launchdarkly::client_side { +namespace launchdarkly::events { void NullEventProcessor::SendAsync(events::InputEvent event) {} void NullEventProcessor::FlushAsync() {} void NullEventProcessor::ShutdownAsync() {} -} // namespace launchdarkly::client_side +} // namespace launchdarkly::events diff --git a/libs/internal/src/events/outbox.cpp b/libs/internal/src/events/outbox.cpp index 233e1e0f9..33e95d008 100644 --- a/libs/internal/src/events/outbox.cpp +++ b/libs/internal/src/events/outbox.cpp @@ -1,6 +1,6 @@ -#include +#include -namespace launchdarkly::events { +namespace launchdarkly::events::detail { Outbox::Outbox(std::size_t capacity) : items_(), capacity_(capacity) {} @@ -34,8 +34,8 @@ std::vector Outbox::Consume() { return out; } -bool Outbox::Empty() { +bool Outbox::Empty() const { return items_.empty(); } -} // namespace launchdarkly::events +} // namespace launchdarkly::events::detail diff --git a/libs/internal/src/events/request_worker.cpp b/libs/internal/src/events/request_worker.cpp index a18ed8675..ac8c32d79 100644 --- a/libs/internal/src/events/request_worker.cpp +++ b/libs/internal/src/events/request_worker.cpp @@ -1,7 +1,7 @@ -#include -#include +#include +#include -namespace launchdarkly::events { +namespace launchdarkly::events::detail { RequestWorker::RequestWorker(boost::asio::any_io_executor io, std::chrono::milliseconds retry_after, @@ -207,4 +207,4 @@ std::ostream& operator<<(std::ostream& out, Action const& s) { return out; } -} // namespace launchdarkly::events +} // namespace launchdarkly::events::detail diff --git a/libs/internal/src/events/summarizer.cpp b/libs/internal/src/events/summarizer.cpp index a196b5473..3ca0ff236 100644 --- a/libs/internal/src/events/summarizer.cpp +++ b/libs/internal/src/events/summarizer.cpp @@ -1,6 +1,6 @@ -#include +#include -namespace launchdarkly::events { +namespace launchdarkly::events::detail { Summarizer::Summarizer(std::chrono::system_clock::time_point start) : start_time_(start) {} @@ -14,7 +14,7 @@ Summarizer::Features() const { return features_; } -void Summarizer::Update(client::FeatureEventParams const& event) { +void Summarizer::Update(events::FeatureEventParams const& event) { auto const& kinds = event.context.Kinds(); auto feature_state_iterator = @@ -37,11 +37,11 @@ Summarizer& Summarizer::Finish(Time end_time) { return *this; } -Summarizer::Time Summarizer::start_time() const { +Summarizer::Time Summarizer::StartTime() const { return start_time_; } -Summarizer::Time Summarizer::end_time() const { +Summarizer::Time Summarizer::EndTime() const { return end_time_; } @@ -69,4 +69,4 @@ std::int32_t Summarizer::VariationSummary::Count() const { Summarizer::State::State(Value default_value) : default_(std::move(default_value)) {} -} // namespace launchdarkly::events +} // namespace launchdarkly::events::detail diff --git a/libs/internal/src/events/worker_pool.cpp b/libs/internal/src/events/worker_pool.cpp index 19ff94b2d..105495310 100644 --- a/libs/internal/src/events/worker_pool.cpp +++ b/libs/internal/src/events/worker_pool.cpp @@ -1,9 +1,9 @@ #include -#include -#include +#include +#include -namespace launchdarkly::events { +namespace launchdarkly::events::detail { WorkerPool::WorkerPool(boost::asio::any_io_executor io, std::size_t pool_size, @@ -16,4 +16,4 @@ WorkerPool::WorkerPool(boost::asio::any_io_executor io, } } -} // namespace launchdarkly::events +} // namespace launchdarkly::events::detail diff --git a/libs/internal/src/serialization/events/json_events.cpp b/libs/internal/src/serialization/events/json_events.cpp index c3d821e15..36a9b4553 100644 --- a/libs/internal/src/serialization/events/json_events.cpp +++ b/libs/internal/src/serialization/events/json_events.cpp @@ -2,7 +2,7 @@ #include #include -namespace launchdarkly::events::client { +namespace launchdarkly::events { void tag_invoke(boost::json::value_from_tag const& tag, boost::json::value& json_value, FeatureEvent const& event) { @@ -39,6 +39,9 @@ void tag_invoke(boost::json::value_from_tag const& tag, obj.emplace("reason", boost::json::value_from(*event.reason)); } obj.emplace("default", boost::json::value_from(event.default_)); + if (event.prereq_of) { + obj.emplace("prereqOf", *event.prereq_of); + } } void tag_invoke(boost::json::value_from_tag const& tag, @@ -49,7 +52,19 @@ void tag_invoke(boost::json::value_from_tag const& tag, obj.emplace("creationDate", boost::json::value_from(event.creation_date)); obj.emplace("context", event.context); } -} // namespace launchdarkly::events::client +} // namespace launchdarkly::events + +namespace launchdarkly::events::server_side { + +void tag_invoke(boost::json::value_from_tag const&, + boost::json::value& json_value, + IndexEvent const& event) { + auto& obj = json_value.emplace_object(); + obj.emplace("kind", "index"); + obj.emplace("creationDate", boost::json::value_from(event.creation_date)); + obj.emplace("context", event.context); +} +} // namespace launchdarkly::events::server_side namespace launchdarkly::events { @@ -88,7 +103,7 @@ void tag_invoke(boost::json::value_from_tag const& tag, } // namespace launchdarkly::events -namespace launchdarkly::events { +namespace launchdarkly::events::detail { void tag_invoke(boost::json::value_from_tag const& tag, boost::json::value& json_value, @@ -119,8 +134,8 @@ void tag_invoke(boost::json::value_from_tag const& tag, auto& obj = json_value.emplace_object(); obj.emplace("kind", "summary"); obj.emplace("startDate", - boost::json::value_from(Date{summary.start_time()})); - obj.emplace("endDate", boost::json::value_from(Date{summary.end_time()})); + boost::json::value_from(Date{summary.StartTime()})); + obj.emplace("endDate", boost::json::value_from(Date{summary.EndTime()})); obj.emplace("features", boost::json::value_from(summary.Features())); } -} // namespace launchdarkly::events +} // namespace launchdarkly::events::detail diff --git a/libs/internal/src/serialization/json_attributes.cpp b/libs/internal/src/serialization/json_attributes.cpp index 763433889..f9ba610a3 100644 --- a/libs/internal/src/serialization/json_attributes.cpp +++ b/libs/internal/src/serialization/json_attributes.cpp @@ -2,6 +2,7 @@ #include #include +#include namespace launchdarkly { void tag_invoke(boost::json::value_from_tag const& unused, diff --git a/libs/internal/src/serialization/json_context.cpp b/libs/internal/src/serialization/json_context.cpp index 3c667ff8a..f61810196 100644 --- a/libs/internal/src/serialization/json_context.cpp +++ b/libs/internal/src/serialization/json_context.cpp @@ -1,11 +1,13 @@ #include #include #include +#include #include -#include - #include +#include + +#include namespace launchdarkly { void tag_invoke(boost::json::value_from_tag const&, @@ -103,7 +105,12 @@ std::optional ParseSingle(ContextBuilder& builder, attr == meta_iter || attr->value().is_null()) { continue; } - attrs.Set(attr->key(), boost::json::value_to(attr->value())); + auto maybe_unmarshalled_attr = + boost::json::value_to>( + attr->value()); + if (maybe_unmarshalled_attr) { + attrs.Set(attr->key(), maybe_unmarshalled_attr.value()); + } } return std::nullopt; diff --git a/libs/internal/src/serialization/json_context_kind.cpp b/libs/internal/src/serialization/json_context_kind.cpp new file mode 100644 index 000000000..96f8e6114 --- /dev/null +++ b/libs/internal/src/serialization/json_context_kind.cpp @@ -0,0 +1,24 @@ +#include +#include + +#include + +namespace launchdarkly { +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& + unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + + REQUIRE_STRING(json_value); + auto const& str = json_value.as_string(); + + if (str.empty()) { + /* Empty string is not a valid context kind. */ + return tl::make_unexpected(JsonError::kSchemaFailure); + } + + return data_model::ContextKind(str.c_str()); +} +} // namespace launchdarkly diff --git a/libs/internal/src/serialization/json_evaluation_reason.cpp b/libs/internal/src/serialization/json_evaluation_reason.cpp index 517281dd4..1ae824144 100644 --- a/libs/internal/src/serialization/json_evaluation_reason.cpp +++ b/libs/internal/src/serialization/json_evaluation_reason.cpp @@ -1,7 +1,9 @@ +#include #include #include #include +#include #include @@ -11,6 +13,7 @@ tl::expected tag_invoke( boost::json::value_to_tag< tl::expected> const& unused, boost::json::value const& json_value) { + boost::ignore_unused(unused); if (!json_value.is_string()) { return tl::unexpected(JsonError::kSchemaFailure); } @@ -39,6 +42,7 @@ tl::expected tag_invoke( void tag_invoke(boost::json::value_from_tag const& unused, boost::json::value& json_value, enum EvaluationReason::Kind const& kind) { + boost::ignore_unused(unused); auto& str = json_value.emplace_string(); switch (kind) { case EvaluationReason::Kind::kOff: @@ -66,6 +70,8 @@ tl::expected tag_invoke( boost::json::value_to_tag> const& unused, boost::json::value const& json_value) { + boost::ignore_unused(unused); + if (!json_value.is_string()) { return tl::unexpected(JsonError::kSchemaFailure); } @@ -94,6 +100,8 @@ tl::expected tag_invoke( void tag_invoke(boost::json::value_from_tag const& unused, boost::json::value& json_value, enum EvaluationReason::ErrorKind const& kind) { + boost::ignore_unused(unused); + auto& str = json_value.emplace_string(); switch (kind) { case EvaluationReason::ErrorKind::kClientNotReady: @@ -182,6 +190,8 @@ tl::expected tag_invoke( void tag_invoke(boost::json::value_from_tag const& unused, boost::json::value& json_value, EvaluationReason const& reason) { + boost::ignore_unused(unused); + auto& obj = json_value.emplace_object(); obj.emplace("kind", boost::json::value_from(reason.Kind())); if (auto error_kind = reason.ErrorKind()) { diff --git a/libs/internal/src/serialization/json_evaluation_result.cpp b/libs/internal/src/serialization/json_evaluation_result.cpp index 00a9e2ed7..7b7b8a335 100644 --- a/libs/internal/src/serialization/json_evaluation_result.cpp +++ b/libs/internal/src/serialization/json_evaluation_result.cpp @@ -1,97 +1,105 @@ +#include #include #include #include #include #include +#include namespace launchdarkly { -tl::expected tag_invoke( - boost::json::value_to_tag> const& - unused, + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& unused, boost::json::value const& json_value) { boost::ignore_unused(unused); - if (json_value.is_object()) { - auto& json_obj = json_value.as_object(); - auto* version_iter = json_obj.find("version"); - auto version_opt = ValueAsOpt(version_iter, json_obj.end()); - if (!version_opt.has_value()) { - return tl::unexpected(JsonError::kSchemaFailure); - } - auto version = version_opt.value(); - - auto* flag_version_iter = json_obj.find("flagVersion"); - auto flag_version = - ValueAsOpt(flag_version_iter, json_obj.end()); - - auto* track_events_iter = json_obj.find("trackEvents"); - auto track_events = - ValueOrDefault(track_events_iter, json_obj.end(), false); - - auto* track_reason_iter = json_obj.find("trackReason"); - auto track_reason = - ValueOrDefault(track_reason_iter, json_obj.end(), false); - - auto* debug_events_until_date_iter = - json_obj.find("debugEventsUntilDate"); - - auto debug_events_until_date = - MapOpt, - uint64_t>(ValueAsOpt(debug_events_until_date_iter, - json_obj.end()), - [](auto value) { - return std::chrono::system_clock::time_point{ - std::chrono::milliseconds{value}}; - }); - - // Evaluation detail is directly de-serialized inline here. - // This is because the shape of the evaluation detail is different - // when deserializing FlagMeta. Primarily `variation` not - // `variationIndex`. - - auto* value_iter = json_obj.find("value"); - if (value_iter == json_obj.end()) { - return tl::unexpected(JsonError::kSchemaFailure); - } - auto value = boost::json::value_to(value_iter->value()); + if (json_value.is_null()) { + return std::nullopt; + } + if (!json_value.is_object()) { + return tl::unexpected(JsonError::kSchemaFailure); + } + auto const& json_obj = json_value.as_object(); + + auto* version_iter = json_obj.find("version"); + auto version_opt = ValueAsOpt(version_iter, json_obj.end()); + if (!version_opt.has_value()) { + return tl::unexpected(JsonError::kSchemaFailure); + } + auto version = version_opt.value(); + + auto* flag_version_iter = json_obj.find("flagVersion"); + auto flag_version = ValueAsOpt(flag_version_iter, json_obj.end()); + + auto* track_events_iter = json_obj.find("trackEvents"); + auto track_events = + ValueOrDefault(track_events_iter, json_obj.end(), false); - auto* variation_iter = json_obj.find("variation"); - auto variation = ValueAsOpt(variation_iter, json_obj.end()); + auto* track_reason_iter = json_obj.find("trackReason"); + auto track_reason = + ValueOrDefault(track_reason_iter, json_obj.end(), false); - auto* reason_iter = json_obj.find("reason"); + auto* debug_events_until_date_iter = json_obj.find("debugEventsUntilDate"); - // There is a reason. - if (reason_iter != json_obj.end() && !reason_iter->value().is_null()) { - auto reason = boost::json::value_to< - tl::expected>( + auto debug_events_until_date = + MapOpt, uint64_t>( + ValueAsOpt(debug_events_until_date_iter, json_obj.end()), + [](auto value) { + return std::chrono::system_clock::time_point{ + std::chrono::milliseconds{value}}; + }); + + // Evaluation detail is directly de-serialized inline here. + // This is because the shape of the evaluation detail is different + // when deserializing FlagMeta. Primarily `variation` not + // `variationIndex`. + + auto* value_iter = json_obj.find("value"); + if (value_iter == json_obj.end()) { + return tl::unexpected(JsonError::kSchemaFailure); + } + + auto maybe_value = boost::json::value_to>( + value_iter->value()); + if (!maybe_value) { + return tl::unexpected(maybe_value.error()); + } + + auto* variation_iter = json_obj.find("variation"); + auto variation = ValueAsOpt(variation_iter, json_obj.end()); + + auto* reason_iter = json_obj.find("reason"); + + // There is a reason. + if (reason_iter != json_obj.end() && !reason_iter->value().is_null()) { + auto reason = + boost::json::value_to>( reason_iter->value()); - if (reason.has_value()) { - return EvaluationResult{ - version, - flag_version, - track_events, - track_reason, - debug_events_until_date, - EvaluationDetailInternal( - value, variation, std::make_optional(reason.value()))}; - } - // We could not parse the reason. - return tl::unexpected(JsonError::kSchemaFailure); + if (reason.has_value()) { + return EvaluationResult{ + version, + flag_version, + track_events, + track_reason, + debug_events_until_date, + EvaluationDetailInternal(*maybe_value, variation, + std::make_optional(reason.value()))}; } - - // There was no reason. - return EvaluationResult{ - version, - flag_version, - track_events, - track_reason, - debug_events_until_date, - EvaluationDetailInternal(value, variation, std::nullopt)}; + // We could not parse the reason. + return tl::unexpected(JsonError::kSchemaFailure); } - return tl::unexpected(JsonError::kSchemaFailure); + // There was no reason. + return EvaluationResult{ + version, + flag_version, + track_events, + track_reason, + debug_events_until_date, + EvaluationDetailInternal(*maybe_value, variation, std::nullopt)}; } void tag_invoke(boost::json::value_from_tag const& unused, diff --git a/libs/internal/src/serialization/json_flag.cpp b/libs/internal/src/serialization/json_flag.cpp new file mode 100644 index 000000000..47429e2bd --- /dev/null +++ b/libs/internal/src/serialization/json_flag.cpp @@ -0,0 +1,371 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +namespace launchdarkly { + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, + JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + + REQUIRE_OBJECT(json_value); + auto const& obj = json_value.as_object(); + + data_model::Flag::Rollout rollout{}; + + PARSE_FIELD(rollout.variations, obj, "variations"); + PARSE_FIELD_DEFAULT(rollout.kind, obj, "kind", + data_model::Flag::Rollout::Kind::kRollout); + PARSE_CONDITIONAL_FIELD(rollout.seed, obj, "seed"); + + auto kind_and_bucket_by = boost::json::value_to, + JsonError>>(json_value); + if (!kind_and_bucket_by) { + return tl::make_unexpected(kind_and_bucket_by.error()); + } + + rollout.contextKind = kind_and_bucket_by->contextKind; + rollout.bucketBy = kind_and_bucket_by->reference; + + return rollout; +} + +tl::expected, + JsonError> +tag_invoke(boost::json::value_to_tag, + JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + REQUIRE_OBJECT(json_value); + auto const& obj = json_value.as_object(); + + data_model::Flag::Rollout::WeightedVariation weighted_variation{}; + PARSE_FIELD(weighted_variation.variation, obj, "variation"); + PARSE_FIELD(weighted_variation.weight, obj, "weight"); + PARSE_FIELD(weighted_variation.untracked, obj, "untracked"); + return weighted_variation; +} + +tl::expected, JsonError> +tag_invoke(boost::json::value_to_tag< + tl::expected, + JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + REQUIRE_STRING(json_value); + + auto const& str = json_value.as_string(); + if (str == "experiment") { + return data_model::Flag::Rollout::Kind::kExperiment; + } else if (str == "rollout") { + return data_model::Flag::Rollout::Kind::kRollout; + } else { + return data_model::Flag::Rollout::Kind::kUnrecognized; + } +} + +tl::expected, JsonError> +tag_invoke(boost::json::value_to_tag< + tl::expected, + JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + REQUIRE_OBJECT(json_value); + auto const& obj = json_value.as_object(); + + data_model::Flag::Prerequisite prerequisite{}; + PARSE_REQUIRED_FIELD(prerequisite.key, obj, "key"); + PARSE_FIELD(prerequisite.variation, obj, "variation"); + return prerequisite; +} + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& + unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + REQUIRE_OBJECT(json_value); + auto const& obj = json_value.as_object(); + + data_model::Flag::Target target{}; + PARSE_FIELD(target.values, obj, "values"); + PARSE_FIELD(target.variation, obj, "variation"); + PARSE_FIELD_DEFAULT(target.contextKind, obj, "contextKind", + data_model::ContextKind("user")); + return target; +} + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& + unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + REQUIRE_OBJECT(json_value); + auto const& obj = json_value.as_object(); + + data_model::Flag::Rule rule{}; + + PARSE_FIELD(rule.trackEvents, obj, "trackEvents"); + PARSE_FIELD(rule.clauses, obj, "clauses"); + PARSE_CONDITIONAL_FIELD(rule.id, obj, "id"); + + auto variation_or_rollout = boost::json::value_to, JsonError>>( + json_value); + if (!variation_or_rollout) { + return tl::make_unexpected(variation_or_rollout.error()); + } + + rule.variationOrRollout = + variation_or_rollout->value_or(data_model::Flag::Variation(0)); + + return rule; +} + +tl::expected, JsonError> +tag_invoke( + boost::json::value_to_tag< + tl::expected, + JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + REQUIRE_OBJECT(json_value); + auto const& obj = json_value.as_object(); + + data_model::Flag::ClientSideAvailability client_side_availability{}; + PARSE_FIELD(client_side_availability.usingEnvironmentId, obj, + "usingEnvironmentId"); + PARSE_FIELD(client_side_availability.usingMobileKey, obj, "usingMobileKey"); + return client_side_availability; +} + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + + REQUIRE_OBJECT(json_value); + + auto const& obj = json_value.as_object(); + + data_model::Flag flag{}; + + PARSE_REQUIRED_FIELD(flag.key, obj, "key"); + + PARSE_CONDITIONAL_FIELD(flag.debugEventsUntilDate, obj, + "debugEventsUntilDate"); + PARSE_CONDITIONAL_FIELD(flag.salt, obj, "salt"); + PARSE_CONDITIONAL_FIELD(flag.offVariation, obj, "offVariation"); + + PARSE_FIELD(flag.version, obj, "version"); + PARSE_FIELD(flag.on, obj, "on"); + PARSE_FIELD(flag.variations, obj, "variations"); + + PARSE_FIELD(flag.prerequisites, obj, "prerequisites"); + PARSE_FIELD(flag.targets, obj, "targets"); + PARSE_FIELD(flag.contextTargets, obj, "contextTargets"); + PARSE_FIELD(flag.rules, obj, "rules"); + PARSE_FIELD(flag.clientSide, obj, "clientSide"); + PARSE_FIELD(flag.clientSideAvailability, obj, "clientSideAvailability"); + PARSE_FIELD(flag.trackEvents, obj, "trackEvents"); + PARSE_FIELD(flag.trackEventsFallthrough, obj, "trackEventsFallthrough"); + PARSE_FIELD(flag.fallthrough, obj, "fallthrough"); + return flag; +} + +tl::expected, JsonError> +tag_invoke(boost::json::value_to_tag< + tl::expected, + JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + REQUIRE_OBJECT(json_value); + auto const& obj = json_value.as_object(); + + std::optional rollout{}; + PARSE_CONDITIONAL_FIELD(rollout, obj, "rollout"); + + if (rollout) { + return std::make_optional(*rollout); + } + + std::optional variation; + + /* If there's no rollout, this must be a variation. If there's no variation, + * then this will be detected as a malformed flag at evaluation time. */ + PARSE_CONDITIONAL_FIELD(variation, obj, "variation"); + + return variation; +} + +namespace data_model { +void tag_invoke( + boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Flag::Rollout::WeightedVariation const& weighted_variation) { + auto& obj = json_value.emplace_object(); + obj.emplace("variation", weighted_variation.variation); + obj.emplace("weight", weighted_variation.weight); + + WriteMinimal(obj, "untracked", weighted_variation.untracked); +} + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Flag::Rollout::Kind const& kind) { + switch (kind) { + case Flag::Rollout::Kind::kUnrecognized: + // TODO: Should we be preserving the original string. + break; + case Flag::Rollout::Kind::kExperiment: + json_value.emplace_string() = "experiment"; + break; + case Flag::Rollout::Kind::kRollout: + json_value.emplace_string() = "rollout"; + break; + } +} + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Flag::Rollout const& rollout) { + auto& obj = json_value.emplace_object(); + + obj.emplace("variations", boost::json::value_from(rollout.variations)); + if (rollout.kind != Flag::Rollout::Kind::kUnrecognized) { + // TODO: Should we be preserving the original string and putting it in. + obj.emplace("kind", boost::json::value_from(rollout.kind)); + } + WriteMinimal(obj, "seed", rollout.seed); + obj.emplace("bucketBy", rollout.bucketBy.RedactionName()); + obj.emplace("contextKind", rollout.contextKind.t); +} + +void tag_invoke( + boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Flag::VariationOrRollout const& variation_or_rollout) { + auto& obj = json_value.emplace_object(); + std::visit( + [&obj](auto&& arg) { + using T = std::decay_t; + if constexpr (std::is_same_v) { + obj.emplace("rollout", boost::json::value_from(arg)); + } else if constexpr (std::is_same_v< + T, std::optional< + data_model::Flag::Variation>>) { + if (arg) { + obj.emplace("variation", *arg); + } + } + }, + variation_or_rollout); +} + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Flag::Prerequisite const& prerequisite) { + auto& obj = json_value.emplace_object(); + obj.emplace("key", prerequisite.key); + obj.emplace("variation", prerequisite.variation); +} + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Flag::Target const& target) { + auto& obj = json_value.emplace_object(); + obj.emplace("values", boost::json::value_from(target.values)); + obj.emplace("variation", target.variation); + obj.emplace("contextKind", target.contextKind.t); +} + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Flag::ClientSideAvailability const& availability) { + auto& obj = json_value.emplace_object(); + WriteMinimal(obj, "usingEnvironmentId", availability.usingEnvironmentId); + WriteMinimal(obj, "usingMobileKey", availability.usingMobileKey); +} + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Flag::Rule const& rule) { + auto& obj = json_value.emplace_object(); + WriteMinimal(obj, "trackEvents", rule.trackEvents); + WriteMinimal(obj, "id", rule.id); + std::visit( + [&obj](auto&& arg) { + using T = std::decay_t; + if constexpr (std::is_same_v) { + obj.emplace("rollout", boost::json::value_from(arg)); + } else if constexpr (std::is_same_v) { + obj.emplace("variation", arg); + } + }, + rule.variationOrRollout); + obj.emplace("clauses", boost::json::value_from(rule.clauses)); +} + +// The "targets" array in a flag cannot have a contextKind, so this intermediate +// representation allows the flag data model to use Flag::Target, but still +// serialize a user target correctly. +struct UserTarget { + std::vector values; + std::uint64_t variation; + UserTarget(data_model::Flag::Target const& target) + : values(target.values), variation(target.variation) {} +}; + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + UserTarget const& target) { + auto& obj = json_value.emplace_object(); + obj.emplace("values", boost::json::value_from(target.values)); + obj.emplace("variation", target.variation); +} + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Flag const& flag) { + auto& obj = json_value.emplace_object(); + WriteMinimal(obj, "trackEvents", flag.trackEvents); + WriteMinimal(obj, "clientSide", flag.clientSide); + WriteMinimal(obj, "on", flag.on); + WriteMinimal(obj, "trackEventsFallthrough", flag.trackEventsFallthrough); + WriteMinimal(obj, "debugEventsUntilDate", flag.debugEventsUntilDate); + WriteMinimal(obj, "salt", flag.salt); + WriteMinimal(obj, "offVariation", flag.offVariation); + obj.emplace("key", flag.key); + obj.emplace("version", flag.version); + obj.emplace("variations", boost::json::value_from(flag.variations)); + obj.emplace("rules", boost::json::value_from(flag.rules)); + obj.emplace("prerequisites", boost::json::value_from(flag.prerequisites)); + obj.emplace("fallthrough", boost::json::value_from(flag.fallthrough)); + obj.emplace("clientSideAvailability", + boost::json::value_from(flag.clientSideAvailability)); + obj.emplace("contextTargets", boost::json::value_from(flag.contextTargets)); + + std::vector user_targets; + for (auto const& target : flag.targets) { + user_targets.emplace_back(target); + } + obj.emplace("targets", boost::json::value_from(user_targets)); +} + +} // namespace data_model + +} // namespace launchdarkly diff --git a/libs/internal/src/serialization/json_primitives.cpp b/libs/internal/src/serialization/json_primitives.cpp new file mode 100644 index 000000000..ea38767bf --- /dev/null +++ b/libs/internal/src/serialization/json_primitives.cpp @@ -0,0 +1,69 @@ +#include + +namespace launchdarkly { +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + if (json_value.is_null()) { + return std::nullopt; + } + if (!json_value.is_bool()) { + return tl::unexpected(JsonError::kSchemaFailure); + } + return json_value.as_bool(); +} + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + if (json_value.is_null()) { + return std::nullopt; + } + if (!json_value.is_number()) { + return tl::unexpected(JsonError::kSchemaFailure); + } + boost::json::error_code ec; + auto val = json_value.to_number(ec); + if (ec) { + return tl::unexpected(JsonError::kSchemaFailure); + } + return val; +} + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + if (json_value.is_null()) { + return std::nullopt; + } + if (!json_value.is_number()) { + return tl::unexpected(JsonError::kSchemaFailure); + } + boost::json::error_code ec; + auto val = json_value.to_number(ec); + if (ec) { + return tl::unexpected(JsonError::kSchemaFailure); + } + return val; +} + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + if (json_value.is_null()) { + return std::nullopt; + } + if (!json_value.is_string()) { + return tl::unexpected(JsonError::kSchemaFailure); + } + return std::string(json_value.as_string()); +} +} // namespace launchdarkly diff --git a/libs/internal/src/serialization/json_rule_clause.cpp b/libs/internal/src/serialization/json_rule_clause.cpp new file mode 100644 index 000000000..febc96f28 --- /dev/null +++ b/libs/internal/src/serialization/json_rule_clause.cpp @@ -0,0 +1,178 @@ +#include +#include +#include +#include +#include +#include + +namespace launchdarkly { + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag, + JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + + REQUIRE_OBJECT(json_value); + auto const& obj = json_value.as_object(); + + data_model::Clause clause{}; + + PARSE_REQUIRED_FIELD(clause.op, obj, "op"); + PARSE_FIELD(clause.values, obj, "values"); + PARSE_FIELD(clause.negate, obj, "negate"); + + auto kind_and_attr = boost::json::value_to, JsonError>>( + json_value); + if (!kind_and_attr) { + return tl::make_unexpected(kind_and_attr.error()); + } + + clause.contextKind = kind_and_attr->contextKind; + clause.attribute = kind_and_attr->reference; + return clause; +} + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& + unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + + REQUIRE_STRING(json_value); + + auto const& str = json_value.as_string(); + + if (str == "") { + // Treating empty string as indicating the field is absent, but could + // also treat it as a valid but unknown value (like kUnrecognized.) + return std::nullopt; + } else if (str == "in") { + return data_model::Clause::Op::kIn; + } else if (str == "endsWith") { + return data_model::Clause::Op::kEndsWith; + } else if (str == "startsWith") { + return data_model::Clause::Op::kStartsWith; + } else if (str == "matches") { + return data_model::Clause::Op::kMatches; + } else if (str == "contains") { + return data_model::Clause::Op::kContains; + } else if (str == "lessThan") { + return data_model::Clause::Op::kLessThan; + } else if (str == "lessThanOrEqual") { + return data_model::Clause::Op::kLessThanOrEqual; + } else if (str == "greaterThan") { + return data_model::Clause::Op::kGreaterThan; + } else if (str == "greaterThanOrEqual") { + return data_model::Clause::Op::kGreaterThanOrEqual; + } else if (str == "before") { + return data_model::Clause::Op::kBefore; + } else if (str == "after") { + return data_model::Clause::Op::kAfter; + } else if (str == "semVerEqual") { + return data_model::Clause::Op::kSemVerEqual; + } else if (str == "semVerLessThan") { + return data_model::Clause::Op::kSemVerLessThan; + } else if (str == "semVerGreaterThan") { + return data_model::Clause::Op::kSemVerGreaterThan; + } else if (str == "segmentMatch") { + return data_model::Clause::Op::kSegmentMatch; + } else { + return data_model::Clause::Op::kUnrecognized; + } +} + +tl::expected tag_invoke( + boost::json::value_to_tag< + tl::expected> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + auto maybe_op = boost::json::value_to< + tl::expected, JsonError>>( + json_value); + if (!maybe_op) { + return tl::unexpected(maybe_op.error()); + } + return maybe_op.value().value_or(data_model::Clause::Op::kUnrecognized); +} + +namespace data_model { + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Clause const& clause) { + auto& obj = json_value.emplace_object(); + + obj.emplace("values", boost::json::value_from(clause.values)); + + WriteMinimal(obj, "negate", clause.negate); + + if (clause.op != data_model::Clause::Op::kUnrecognized) { + // TODO: Should we store the original value? + obj.emplace("op", boost::json::value_from(clause.op)); + } + if (clause.attribute.Valid()) { + obj.emplace("attribute", clause.attribute.RedactionName()); + } + obj.emplace("contextKind", clause.contextKind.t); +} + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Clause::Op const& op) { + switch (op) { + case data_model::Clause::Op::kUnrecognized: + // TODO: Should we do anything? + break; + case data_model::Clause::Op::kIn: + json_value.emplace_string() = "in"; + break; + case data_model::Clause::Op::kStartsWith: + json_value.emplace_string() = "startsWith"; + break; + case data_model::Clause::Op::kEndsWith: + json_value.emplace_string() = "endsWith"; + break; + case data_model::Clause::Op::kMatches: + json_value.emplace_string() = "matches"; + break; + case data_model::Clause::Op::kContains: + json_value.emplace_string() = "contains"; + break; + case data_model::Clause::Op::kLessThan: + json_value.emplace_string() = "lessThan"; + break; + case data_model::Clause::Op::kLessThanOrEqual: + json_value.emplace_string() = "lessThanOrEqual"; + break; + case data_model::Clause::Op::kGreaterThan: + json_value.emplace_string() = "greaterThan"; + break; + case data_model::Clause::Op::kGreaterThanOrEqual: + json_value.emplace_string() = "greaterThanOrEqual"; + break; + case data_model::Clause::Op::kBefore: + json_value.emplace_string() = "before"; + break; + case data_model::Clause::Op::kAfter: + json_value.emplace_string() = "after"; + break; + case data_model::Clause::Op::kSemVerEqual: + json_value.emplace_string() = "semVerEqual"; + break; + case data_model::Clause::Op::kSemVerLessThan: + json_value.emplace_string() = "semVerLessThan"; + break; + case data_model::Clause::Op::kSemVerGreaterThan: + json_value.emplace_string() = "semVerGreaterThan"; + break; + case data_model::Clause::Op::kSegmentMatch: + json_value.emplace_string() = "segmentMatch"; + break; + } +} +} // namespace data_model + +} // namespace launchdarkly diff --git a/libs/internal/src/serialization/json_sdk_data_set.cpp b/libs/internal/src/serialization/json_sdk_data_set.cpp new file mode 100644 index 000000000..e7b894a0a --- /dev/null +++ b/libs/internal/src/serialization/json_sdk_data_set.cpp @@ -0,0 +1,28 @@ +#include +#include +#include +#include +#include +#include +#include + +namespace launchdarkly { +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& + unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + + REQUIRE_OBJECT(json_value); + + auto const& obj = json_value.as_object(); + + data_model::SDKDataSet data_set{}; + + PARSE_FIELD(data_set.flags, obj, "flags"); + PARSE_FIELD(data_set.segments, obj, "segments"); + + return data_set; +} +} // namespace launchdarkly diff --git a/libs/internal/src/serialization/json_segment.cpp b/libs/internal/src/serialization/json_segment.cpp new file mode 100644 index 000000000..157d26521 --- /dev/null +++ b/libs/internal/src/serialization/json_segment.cpp @@ -0,0 +1,144 @@ +#include +#include +#include +#include +#include +#include +#include + +namespace launchdarkly { + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, + JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + + REQUIRE_OBJECT(json_value); + auto const& obj = json_value.as_object(); + + data_model::Segment::Target target{}; + + PARSE_FIELD_DEFAULT(target.contextKind, obj, "contextKind", "user"); + + PARSE_FIELD(target.values, obj, "values"); + + return target; +} + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, + JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + + REQUIRE_OBJECT(json_value); + auto const& obj = json_value.as_object(); + + data_model::Segment::Rule rule{}; + + PARSE_FIELD(rule.clauses, obj, "clauses"); + + PARSE_CONDITIONAL_FIELD(rule.weight, obj, "weight"); + PARSE_CONDITIONAL_FIELD(rule.id, obj, "id"); + + auto kind_and_bucket_by = boost::json::value_to, + JsonError>>(json_value); + if (!kind_and_bucket_by) { + return tl::make_unexpected(kind_and_bucket_by.error()); + } + + rule.bucketBy = kind_and_bucket_by->reference; + rule.rolloutContextKind = kind_and_bucket_by->contextKind; + + return rule; +} + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag, + JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + + if (json_value.is_null()) { + return std::nullopt; + } + + if (!json_value.is_object()) { + return tl::unexpected(JsonError::kSchemaFailure); + } + + if (json_value.as_object().empty()) { + return std::nullopt; + } + + auto const& obj = json_value.as_object(); + + data_model::Segment segment{}; + + PARSE_REQUIRED_FIELD(segment.key, obj, "key"); + PARSE_REQUIRED_FIELD(segment.version, obj, "version"); + + PARSE_CONDITIONAL_FIELD(segment.generation, obj, "generation"); + PARSE_CONDITIONAL_FIELD(segment.salt, obj, "salt"); + PARSE_CONDITIONAL_FIELD(segment.unboundedContextKind, obj, + "unboundedContextKind"); + + PARSE_FIELD(segment.excluded, obj, "excluded"); + PARSE_FIELD(segment.included, obj, "included"); + PARSE_FIELD(segment.unbounded, obj, "unbounded"); + PARSE_FIELD(segment.includedContexts, obj, "includedContexts"); + PARSE_FIELD(segment.excludedContexts, obj, "excludedContexts"); + PARSE_FIELD(segment.rules, obj, "rules"); + + return segment; +} + +// Serializers need to be in launchdarkly::data_model for ADL. +namespace data_model { + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Segment const& segment) { + auto& obj = json_value.emplace_object(); + obj.emplace("key", segment.key); + obj.emplace("version", segment.version); + WriteMinimal(obj, "salt", segment.salt); + WriteMinimal(obj, "generation", segment.generation); + WriteMinimal(obj, "unboundedContextKind", segment.unboundedContextKind); + WriteMinimal(obj, "unbounded", segment.unbounded); + + obj.emplace("rules", boost::json::value_from(segment.rules)); + obj.emplace("excluded", boost::json::value_from(segment.excluded)); + obj.emplace("excludedContexts", + boost::json::value_from(segment.excludedContexts)); + obj.emplace("included", boost::json::value_from(segment.included)); + obj.emplace("includedContexts", + boost::json::value_from(segment.includedContexts)); +} + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Segment::Target const& target) { + auto& obj = json_value.emplace_object(); + obj.emplace("values", boost::json::value_from(target.values)); + obj.emplace("contextKind", target.contextKind); +} + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + data_model::Segment::Rule const& rule) { + auto& obj = json_value.emplace_object(); + WriteMinimal(obj, "weight", rule.weight); + WriteMinimal(obj, "id", rule.id); + obj.emplace("clauses", boost::json::value_from(rule.clauses)); + obj.emplace("bucketBy", rule.bucketBy.RedactionName()); + obj.emplace("rolloutContextKind", rule.rolloutContextKind.t); +} + +} // namespace data_model + +} // namespace launchdarkly diff --git a/libs/internal/src/serialization/json_value.cpp b/libs/internal/src/serialization/json_value.cpp index 551322763..c04fae3d2 100644 --- a/libs/internal/src/serialization/json_value.cpp +++ b/libs/internal/src/serialization/json_value.cpp @@ -1,3 +1,4 @@ +#include #include #include @@ -8,9 +9,12 @@ namespace launchdarkly { // constructors. Replacing them with braced init lists would result in all types // being lists. -Value tag_invoke(boost::json::value_to_tag const& unused, - boost::json::value const& json_value) { +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& unused, + boost::json::value const& json_value) { boost::ignore_unused(unused); + // The name of the function needs to be tag_invoke for boost::json. // The conditions in these switches explicitly use the constructors, because @@ -32,7 +36,12 @@ Value tag_invoke(boost::json::value_to_tag const& unused, auto vec = json_value.as_array(); std::vector values; for (auto const& item : vec) { - values.push_back(boost::json::value_to(item)); + auto value = + boost::json::value_to>(item); + if (!value) { + return tl::make_unexpected(value.error()); + } + values.emplace_back(std::move(*value)); } return Value(values); } @@ -40,8 +49,13 @@ Value tag_invoke(boost::json::value_to_tag const& unused, auto& map = json_value.as_object(); std::map values; for (auto const& pair : map) { - auto value = boost::json::value_to(pair.value()); - values.emplace(pair.key().data(), std::move(value)); + auto value = + boost::json::value_to>( + pair.value()); + if (!value) { + return tl::make_unexpected(value.error()); + } + values.emplace(pair.key().data(), std::move(*value)); } return Value(std::move(values)); } @@ -51,6 +65,13 @@ Value tag_invoke(boost::json::value_to_tag const& unused, assert(!"All types need to be handled."); } +Value tag_invoke(boost::json::value_to_tag const&, + boost::json::value const& json_value) { + auto val = + boost::json::value_to>(json_value); + return val ? std::move(*val) : Value(); +} + void tag_invoke(boost::json::value_from_tag const&, boost::json::value& json_value, Value const& ld_value) { diff --git a/libs/internal/src/serialization/value_mapping.cpp b/libs/internal/src/serialization/value_mapping.cpp index d8344efdb..7142d8bf7 100644 --- a/libs/internal/src/serialization/value_mapping.cpp +++ b/libs/internal/src/serialization/value_mapping.cpp @@ -1,5 +1,7 @@ #include +#include + namespace launchdarkly { template <> @@ -21,6 +23,23 @@ std::optional ValueAsOpt( return std::nullopt; } +template <> +std::optional> ValueAsOpt( + boost::json::object::const_iterator iterator, + boost::json::object::const_iterator end) { + if (iterator != end && iterator->value().is_array()) { + std::vector result; + for (auto const& item : iterator->value().as_array()) { + if (!item.is_string()) { + return std::nullopt; + } + result.emplace_back(item.as_string()); + } + return result; + } + return std::nullopt; +} + template <> bool ValueOrDefault(boost::json::object::const_iterator iterator, boost::json::object::const_iterator end, @@ -51,4 +70,9 @@ uint64_t ValueOrDefault(boost::json::object::const_iterator iterator, return default_value; } +void WriteMinimal(boost::json::object& obj, std::string const& key, bool val) { + if (val) { + obj.emplace(key, val); + } +} } // namespace launchdarkly diff --git a/libs/client-sdk/src/boost_signal_connection.cpp b/libs/internal/src/signals/boost_signal_connection.cpp similarity index 55% rename from libs/client-sdk/src/boost_signal_connection.cpp rename to libs/internal/src/signals/boost_signal_connection.cpp index 30cb90604..29f4323d1 100644 --- a/libs/client-sdk/src/boost_signal_connection.cpp +++ b/libs/internal/src/signals/boost_signal_connection.cpp @@ -1,6 +1,6 @@ -#include "boost_signal_connection.hpp" +#include -namespace launchdarkly::client_side { +namespace launchdarkly::internal::signals { SignalConnection::SignalConnection(boost::signals2::connection connection) : connection_(std::move(connection)) {} @@ -9,4 +9,4 @@ void SignalConnection::Disconnect() { connection_.disconnect(); } -} // namespace launchdarkly::client_side +} // namespace launchdarkly::internal::signals diff --git a/libs/internal/tests/data_model_serialization_test.cpp b/libs/internal/tests/data_model_serialization_test.cpp new file mode 100644 index 000000000..02cf3c90e --- /dev/null +++ b/libs/internal/tests/data_model_serialization_test.cpp @@ -0,0 +1,752 @@ +#include + +#include + +#include +#include +#include +#include +#include + +using namespace launchdarkly; +using launchdarkly::data_model::ContextKind; + +TEST(SDKDataSetTests, DeserializesEmptyDataSet) { + auto result = + boost::json::value_to>( + boost::json::parse("{}")); + ASSERT_TRUE(result); + ASSERT_TRUE(result->segments.empty()); + ASSERT_TRUE(result->flags.empty()); +} + +TEST(SDKDataSetTests, ErrorOnInvalidSchema) { + auto result = + boost::json::value_to>( + boost::json::parse("[]")); + ASSERT_FALSE(result); + ASSERT_EQ(result.error(), JsonError::kSchemaFailure); +} + +TEST(SDKDataSetTests, DeserializesZeroSegments) { + auto result = + boost::json::value_to>( + boost::json::parse(R"({"segments":{}})")); + ASSERT_TRUE(result); + ASSERT_TRUE(result->segments.empty()); +} + +TEST(SDKDataSetTests, DeserializesZeroFlags) { + auto result = + boost::json::value_to>( + boost::json::parse(R"({"flags":{}})")); + ASSERT_TRUE(result); + ASSERT_TRUE(result->flags.empty()); +} + +TEST(SegmentTests, DeserializesMinimumValid) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"({"key":"foo", "version": 42})")); + ASSERT_TRUE(result); + ASSERT_TRUE(result.value()); + + ASSERT_EQ(result.value()->version, 42); + ASSERT_EQ(result.value()->key, "foo"); +} + +TEST(SegmentTests, TolerantOfUnrecognizedFields) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse( + R"({"key":"foo", "version": 42, "somethingRandom" : true})")); + ASSERT_TRUE(result); + ASSERT_TRUE(result.value()); +} + +TEST(SegmentRuleTests, DeserializesMinimumValid) { + auto result = boost::json::value_to< + tl::expected>(boost::json::parse( + R"({"clauses": [{"attribute": "", "op": "in", "values": ["a"]}]})")); + ASSERT_TRUE(result); + + auto const& clauses = result->clauses; + ASSERT_EQ(clauses.size(), 1); + + auto const& clause = clauses.at(0); + ASSERT_EQ(clause.op, data_model::Clause::Op::kIn); +} + +TEST(SegmentRuleTests, TolerantOfUnrecognizedFields) { + auto result = boost::json::value_to< + tl::expected>(boost::json::parse( + R"({"somethingRandom": true, "clauses": [{"attribute": "", "op": "in", "values": ["a"]}]})")); + + ASSERT_TRUE(result); +} + +TEST(SegmentRuleTests, DeserializesSimpleAttributeReference) { + auto result = boost::json::value_to< + tl::expected>(boost::json::parse( + R"({"rolloutContextKind" : "foo", "bucketBy" : "bar", "clauses": []})")); + ASSERT_TRUE(result); + ASSERT_EQ(result->rolloutContextKind, ContextKind("foo")); + ASSERT_EQ(result->bucketBy, AttributeReference("bar")); +} + +TEST(SegmentRuleTests, DeserializesPointerAttributeReference) { + auto result = boost::json::value_to< + tl::expected>(boost::json::parse( + R"({"rolloutContextKind" : "foo", "bucketBy" : "/foo/bar", "clauses": []})")); + ASSERT_TRUE(result); + ASSERT_EQ(result->rolloutContextKind, ContextKind("foo")); + ASSERT_EQ(result->bucketBy, AttributeReference("/foo/bar")); +} + +TEST(SegmentRuleTests, DeserializesEscapedReference) { + auto result = boost::json::value_to< + tl::expected>(boost::json::parse( + R"({"rolloutContextKind" : "foo", "bucketBy" : "/~1foo~1bar", "clauses": []})")); + ASSERT_TRUE(result); + ASSERT_EQ(result->rolloutContextKind, ContextKind("foo")); + ASSERT_EQ(result->bucketBy, AttributeReference("/~1foo~1bar")); +} + +TEST(SegmentRuleTests, RejectsEmptyContextKind) { + auto result = boost::json::value_to< + tl::expected>(boost::json::parse( + R"({"rolloutContextKind" : "", "bucketBy" : "/~1foo~1bar", "clauses": []})")); + ASSERT_FALSE(result); +} + +TEST(SegmentRuleTests, DeserializesLiteralAttributeName) { + auto result = boost::json::value_to< + tl::expected>( + boost::json::parse(R"({"bucketBy" : "/~1foo~1bar", "clauses": []})")); + ASSERT_TRUE(result); + ASSERT_EQ(result->bucketBy, + AttributeReference::FromLiteralStr("/~1foo~1bar")); +} + +TEST(ClauseTests, DeserializesMinimumValid) { + auto result = + boost::json::value_to>( + boost::json::parse(R"({"op": "segmentMatch", "values": []})")); + ASSERT_TRUE(result); + + ASSERT_EQ(result->op, data_model::Clause::Op::kSegmentMatch); + ASSERT_TRUE(result->values.empty()); +} + +TEST(ClauseTests, TolerantOfUnrecognizedFields) { + auto result = boost::json::value_to< + tl::expected>(boost::json::parse( + R"({"somethingRandom": true, "attribute": "", "op": "in", "values": ["a"]})")); + ASSERT_TRUE(result); +} + +TEST(ClauseTests, TolerantOfEmptyAttribute) { + auto result = + boost::json::value_to>( + boost::json::parse( + R"({"attribute": "", "op": "segmentMatch", "values": ["a"]})")); + ASSERT_TRUE(result); + ASSERT_FALSE(result->attribute.Valid()); +} + +TEST(ClauseTests, TolerantOfUnrecognizedOperator) { + auto result = boost::json::value_to< + tl::expected>(boost::json::parse( + R"({"attribute": "", "op": "notAnActualOperator", "values": ["a"]})")); + ASSERT_TRUE(result); + ASSERT_EQ(result->op, data_model::Clause::Op::kUnrecognized); +} + +TEST(ClauseTests, DeserializesSimpleAttributeReference) { + auto result = boost::json::value_to< + tl::expected>(boost::json::parse( + R"({"attribute": "foo", "op": "in", "values": ["a"], "contextKind" : "user"})")); + ASSERT_TRUE(result); + ASSERT_EQ(result->attribute, AttributeReference("foo")); +} + +TEST(ClauseTests, DeserializesPointerAttributeReference) { + auto result = boost::json::value_to< + tl::expected>(boost::json::parse( + R"({"attribute": "/foo/bar", "op": "in", "values": ["a"], "contextKind" : "user"})")); + ASSERT_TRUE(result); + ASSERT_EQ(result->attribute, AttributeReference("/foo/bar")); +} + +TEST(ClauseTests, DeserializesEscapedReference) { + auto result = boost::json::value_to< + tl::expected>(boost::json::parse( + R"({"attribute": "/~1foo~1bar", "op": "in", "values": ["a"], "contextKind" : "user"})")); + ASSERT_TRUE(result); + ASSERT_EQ(result->attribute, AttributeReference("/~1foo~1bar")); +} + +TEST(ClauseTests, RejectsEmptyContextKind) { + auto result = boost::json::value_to< + tl::expected>(boost::json::parse( + R"({"attribute": "/~1foo~1bar", "op": "in", "values": ["a"], "contextKind" : ""})")); + ASSERT_FALSE(result); +} + +TEST(ClauseTests, DeserializesLiteralAttributeName) { + auto result = + boost::json::value_to>( + boost::json::parse( + R"({"attribute": "/foo/bar", "op": "in", "values": ["a"]})")); + ASSERT_TRUE(result); + ASSERT_EQ(result->attribute, + AttributeReference::FromLiteralStr("/foo/bar")); +} + +TEST(RolloutTests, DeserializesMinimumValid) { + auto result = boost::json::value_to< + tl::expected>( + boost::json::parse(R"({})")); + ASSERT_TRUE(result); + ASSERT_EQ(result->kind, data_model::Flag::Rollout::Kind::kRollout); + ASSERT_EQ(result->contextKind, ContextKind("user")); + ASSERT_EQ(result->bucketBy, "key"); +} + +TEST(RolloutTests, DeserializesAllFieldsWithAttributeReference) { + auto result = boost::json::value_to< + tl::expected>(boost::json::parse( + R"({"kind": "experiment", "contextKind": "org", "bucketBy": "/foo/bar", "seed" : 123, "variations" : []})")); + ASSERT_TRUE(result); + ASSERT_EQ(result->kind, data_model::Flag::Rollout::Kind::kExperiment); + ASSERT_EQ(result->contextKind, ContextKind("org")); + ASSERT_EQ(result->bucketBy, "/foo/bar"); + ASSERT_EQ(result->seed, 123); + ASSERT_TRUE(result->variations.empty()); +} + +TEST(RolloutTests, DeserializesAllFieldsWithLiteralAttributeName) { + auto result = boost::json::value_to< + tl::expected>(boost::json::parse( + R"({"kind": "experiment", "bucketBy": "/foo/bar", "seed" : 123, "variations" : []})")); + ASSERT_TRUE(result); + ASSERT_EQ(result->kind, data_model::Flag::Rollout::Kind::kExperiment); + ASSERT_EQ(result->contextKind, ContextKind("user")); + ASSERT_EQ(result->bucketBy, "/~1foo~1bar"); + ASSERT_EQ(result->seed, 123); + ASSERT_TRUE(result->variations.empty()); +} + +TEST(WeightedVariationTests, DeserializesMinimumValid) { + auto result = boost::json::value_to< + tl::expected>( + boost::json::parse(R"({})")); + ASSERT_TRUE(result); + ASSERT_EQ(result->variation, 0); + ASSERT_EQ(result->weight, 0); + ASSERT_FALSE(result->untracked); +} + +TEST(WeightedVariationTests, DeserializesAllFields) { + auto result = boost::json::value_to< + tl::expected>( + boost::json::parse( + R"({"variation" : 2, "weight" : 123, "untracked" : true})")); + ASSERT_TRUE(result); + ASSERT_EQ(result->variation, 2); + ASSERT_EQ(result->weight, 123); + ASSERT_TRUE(result->untracked); +} + +TEST(PrerequisiteTests, DeserializeFailsWithoutKey) { + auto result = boost::json::value_to< + tl::expected>( + boost::json::parse(R"({})")); + ASSERT_FALSE(result); +} + +TEST(PrerequisiteTests, DeserializesMinimumValid) { + auto result = boost::json::value_to< + tl::expected>( + boost::json::parse(R"({"key" : "foo"})")); + ASSERT_TRUE(result); + ASSERT_EQ(result->variation, 0); + ASSERT_EQ(result->key, "foo"); +} + +TEST(PrerequisiteTests, DeserializesAllFields) { + auto result = boost::json::value_to< + tl::expected>( + boost::json::parse(R"({"key" : "foo", "variation" : 123})")); + ASSERT_TRUE(result); + ASSERT_EQ(result->key, "foo"); + ASSERT_EQ(result->variation, 123); +} + +TEST(PrerequisiteTests, DeserializeSucceedsWithNegativeVariation) { + auto result = boost::json::value_to< + tl::expected>( + boost::json::parse(R"({"key" : "foo", "variation" : -123})")); + ASSERT_TRUE(result); +} + +TEST(TargetTests, DeserializesMinimumValid) { + auto result = boost::json::value_to< + tl::expected>( + boost::json::parse(R"({})")); + ASSERT_TRUE(result); + ASSERT_EQ(result->contextKind, ContextKind("user")); + ASSERT_EQ(result->variation, 0); + ASSERT_TRUE(result->values.empty()); +} + +TEST(TargetTests, DeserializesSucceedsWithNegativeVariation) { + auto result = boost::json::value_to< + tl::expected>( + boost::json::parse(R"({"variation" : -123})")); + ASSERT_TRUE(result); +} + +TEST(TargetTests, DeserializesAllFields) { + auto result = boost::json::value_to< + tl::expected>(boost::json::parse( + R"({"variation" : 123, "values" : ["a"], "contextKind" : "org"})")); + ASSERT_TRUE(result); + ASSERT_EQ(result->contextKind, ContextKind("org")); + ASSERT_EQ(result->variation, 123); + ASSERT_EQ(result->values.size(), 1); + ASSERT_EQ(result->values[0], "a"); +} + +TEST(FlagRuleTests, DeserializesMinimumValid) { + auto result = + boost::json::value_to>( + boost::json::parse(R"({"variation" : 123})")); + ASSERT_TRUE(result); + ASSERT_FALSE(result->trackEvents); + ASSERT_TRUE(result->clauses.empty()); + ASSERT_FALSE(result->id); + ASSERT_EQ(std::get>( + result->variationOrRollout), + data_model::Flag::Variation(123)); +} + +TEST(FlagRuleTests, DeserializesRollout) { + auto result = + boost::json::value_to>( + boost::json::parse(R"({"rollout" : {}})")); + ASSERT_TRUE(result); + ASSERT_EQ( + std::get(result->variationOrRollout).kind, + data_model::Flag::Rollout::Kind::kRollout); +} + +TEST(FlagRuleTests, DeserializesAllFields) { + auto result = boost::json::value_to< + tl::expected>(boost::json::parse( + R"({"id" : "foo", "variation" : 123, "trackEvents" : true, "clauses" : []})")); + ASSERT_TRUE(result); + ASSERT_TRUE(result->trackEvents); + ASSERT_TRUE(result->clauses.empty()); + ASSERT_EQ(result->id, "foo"); + ASSERT_EQ(std::get>( + result->variationOrRollout), + data_model::Flag::Variation(123)); +} + +TEST(ClientSideAvailabilityTests, DeserializesMinimumValid) { + auto result = boost::json::value_to< + tl::expected>( + boost::json::parse(R"({})")); + ASSERT_TRUE(result); + ASSERT_FALSE(result->usingMobileKey); + ASSERT_FALSE(result->usingEnvironmentId); +} + +TEST(ClientSideAvailabilityTests, DeserializesAllFields) { + auto result = boost::json::value_to< + tl::expected>( + boost::json::parse( + R"({"usingMobileKey" : true, "usingEnvironmentId" : true})")); + ASSERT_TRUE(result); + ASSERT_TRUE(result->usingMobileKey); + ASSERT_TRUE(result->usingEnvironmentId); +} + +TEST(WeightedVariationTests, SerializeAllFields) { + data_model::Flag::Rollout::WeightedVariation variation(1, 2); + variation.untracked = true; + auto json = boost::json::value_from(variation); + + auto expected = boost::json::parse( + R"({"variation": 1, "weight": 2, "untracked": true})"); + + EXPECT_EQ(expected, json); +} + +TEST(WeightedVariationTests, SerializeUntrackedOnlyTrue) { + data_model::Flag::Rollout::WeightedVariation variation(1, 2); + variation.untracked = false; + auto json = boost::json::value_from(variation); + + auto expected = boost::json::parse(R"({"variation": 1, "weight": 2})"); + + EXPECT_EQ(expected, json); +} + +TEST(RolloutTests, SerializeAllFields) { + using Rollout = data_model::Flag::Rollout; + Rollout rollout; + rollout.kind = Rollout::Kind::kExperiment; + rollout.contextKind = "user"; + rollout.bucketBy = AttributeReference("ham"); + rollout.seed = 42; + rollout.variations = { + data_model::Flag::Rollout::WeightedVariation::Untracked(1, 2), {3, 4}}; + + auto json = boost::json::value_from(rollout); + + auto expected = boost::json::parse(R"({ + "kind": "experiment", + "contextKind": "user", + "bucketBy": "ham", + "seed": 42, + "variations": [ + {"variation": 1, "weight": 2, "untracked": true}, + {"variation": 3, "weight": 4} + ] + })"); + + EXPECT_EQ(expected, json); +} + +TEST(VariationOrRolloutTests, SerializeVariation) { + data_model::Flag::VariationOrRollout variation = 5; + + auto json = boost::json::value_from(variation); + + auto expected = boost::json::parse(R"({"variation":5})"); + EXPECT_EQ(expected, json); +} + +TEST(VariationOrRolloutTests, SerializeRollout) { + using Rollout = data_model::Flag::Rollout; + Rollout rollout; + rollout.kind = Rollout::Kind::kExperiment; + rollout.contextKind = "user"; + rollout.bucketBy = AttributeReference("ham"); + rollout.seed = 42; + rollout.variations = { + data_model::Flag::Rollout::WeightedVariation::Untracked(1, 2), {3, 4}}; + data_model::Flag::VariationOrRollout var_or_roll = rollout; + auto json = boost::json::value_from(var_or_roll); + + auto expected = boost::json::parse(R"({ + "rollout":{ + "kind": "experiment", + "contextKind": "user", + "bucketBy": "ham", + "seed": 42, + "variations": [ + {"variation": 1, "weight": 2, "untracked": true}, + {"variation": 3, "weight": 4} + ] + }})"); + EXPECT_EQ(expected, json); +} + +TEST(PrerequisiteTests, SerializeAll) { + data_model::Flag::Prerequisite prerequisite{"potato", 6}; + auto json = boost::json::value_from(prerequisite); + + auto expected = boost::json::parse(R"({"key":"potato","variation":6})"); + EXPECT_EQ(expected, json); +} + +TEST(TargetTests, SerializeAll) { + data_model::Flag::Target target{{"a", "b"}, 42, ContextKind("taco_stand")}; + auto json = boost::json::value_from(target); + + auto expected = boost::json::parse( + R"({"values":["a", "b"], "variation": 42, "contextKind":"taco_stand"})"); + EXPECT_EQ(expected, json); +} + +TEST(ClientSideAvailabilityTests, SerializeAll) { + data_model::Flag::ClientSideAvailability availability{true, true}; + + auto json = boost::json::value_from(availability); + + auto expected = boost::json::parse( + R"({"usingMobileKey": true, "usingEnvironmentId": true})"); + EXPECT_EQ(expected, json); +} + +class ClauseOperatorsFixture + : public ::testing::TestWithParam {}; + +INSTANTIATE_TEST_SUITE_P( + ClauseTests, + ClauseOperatorsFixture, + testing::Values(data_model::Clause::Op::kSegmentMatch, + data_model::Clause::Op::kAfter, + data_model::Clause::Op::kBefore, + data_model::Clause::Op::kContains, + data_model::Clause::Op::kEndsWith, + data_model::Clause::Op::kGreaterThan, + data_model::Clause::Op::kGreaterThanOrEqual, + data_model::Clause::Op::kIn, + data_model::Clause::Op::kLessThan, + data_model::Clause::Op::kLessThanOrEqual, + data_model::Clause::Op::kMatches, + data_model::Clause::Op::kSemVerEqual, + data_model::Clause::Op::kSemVerGreaterThan, + data_model::Clause::Op::kSemVerLessThan, + data_model::Clause::Op::kStartsWith)); + +TEST_P(ClauseOperatorsFixture, AllOperatorsSerializeDeserialize) { + auto op = GetParam(); + + auto serialized = boost::json::serialize(boost::json::value_from(op)); + auto parsed = boost::json::parse(serialized); + auto deserialized = boost::json::value_to< + tl::expected, JsonError>>(parsed); + + EXPECT_EQ(op, **deserialized); +} + +TEST(ClauseTests, SerializeAll) { + data_model::Clause clause{data_model::Clause::Op::kIn, + {"a", "b"}, + true, + ContextKind("bob"), + "/potato"}; + + auto json = boost::json::value_from(clause); + auto expected = boost::json::parse( + R"({ + "op": "in", + "negate": true, + "values": ["a", "b"], + "contextKind": "bob", + "attribute": "/potato" + })"); + EXPECT_EQ(expected, json); +} + +TEST(FlagRuleTests, SerializeAllRollout) { + using Rollout = data_model::Flag::Rollout; + Rollout rollout; + rollout.kind = Rollout::Kind::kExperiment; + rollout.contextKind = "user"; + rollout.bucketBy = AttributeReference("ham"); + rollout.seed = 42; + rollout.variations = { + data_model::Flag::Rollout::WeightedVariation::Untracked(1, 2), {3, 4}}; + data_model::Flag::Rule rule{{{data_model::Clause::Op::kIn, + {"a", "b"}, + true, + ContextKind("bob"), + "/potato"}}, + rollout, + true, + "therule"}; + + auto json = boost::json::value_from(rule); + auto expected = boost::json::parse( + R"({ + "clauses":[{ + "op": "in", + "negate": true, + "values": ["a", "b"], + "contextKind": "bob", + "attribute": "/potato" + }], + "rollout": { + "kind": "experiment", + "contextKind": "user", + "bucketBy": "ham", + "seed": 42, + "variations": [ + {"variation": 1, "weight": 2, "untracked": true}, + {"variation": 3, "weight": 4} + ] + }, + "trackEvents": true, + "id": "therule" + })"); + EXPECT_EQ(expected, json); +} + +TEST(FlagTests, SerializeAll) { + data_model::Flag flag{ + "the-key", + 21, // version + true, // on + 42, // fallthrough + {"a", "b"}, // variations + {{"prereqA", 2}, {"prereqB", 3}}, // prerequisites + {{{ + "1", + "2", + "3", + }, + 12, + ContextKind("user")}}, // targets + {{{ + "4", + "5", + "6", + }, + 24, + ContextKind("bob")}}, // contextTargets + {}, // rules + 84, // offVariation + true, // clientSide + data_model::Flag::ClientSideAvailability{true, true}, + "4242", // salt + true, // trackEvents + true, // trackEventsFalltrhough + 900 // debugEventsUntilDate + }; + + auto json = boost::json::value_from(flag); + auto expected = boost::json::parse( + R"({ + "trackEvents":true, + "clientSide":true, + "on":true, + "trackEventsFallthrough":true, + "debugEventsUntilDate":900, + "salt":"4242", + "offVariation":84, + "key":"the-key", + "version":21, + "variations":["a","b"], + "rules":[], + "prerequisites":[{"key":"prereqA","variation":2}, + {"key":"prereqB","variation":3}], + "fallthrough":{"variation":42}, + "clientSideAvailability": + {"usingEnvironmentId":true,"usingMobileKey":true}, + "contextTargets": + [{"values":["4","5","6"],"variation":24,"contextKind":"bob"}], + "targets":[{"values":["1","2","3"],"variation":12}] + })"); + EXPECT_EQ(expected, json); +} + +TEST(SegmentTargetTests, SerializeAll) { + data_model::Segment::Target target{"bob", {"bill", "sam"}}; + + auto json = boost::json::value_from(target); + auto expected = boost::json::parse( + R"({ + "contextKind": "bob", + "values": ["bill", "sam"] + })"); + EXPECT_EQ(expected, json); +} + +TEST(SegmentRuleTests, SerializeAll) { + data_model::Segment::Rule rule{{{data_model::Clause::Op::kIn, + {"a", "b"}, + true, + ContextKind("bob"), + "/potato"}}, + "ididid", + 300, + ContextKind("bob"), + "/happy"}; + + auto json = boost::json::value_from(rule); + auto expected = boost::json::parse( + R"({ + "clauses": [{ + "op": "in", + "negate": true, + "values": ["a", "b"], + "contextKind": "bob", + "attribute": "/potato" + }], + "id": "ididid", + "weight": 300, + "rolloutContextKind": "bob", + "bucketBy": "/happy" + })"); + EXPECT_EQ(expected, json); +} + +TEST(SegmentTests, SerializeBasicAll) { + data_model::Segment segment{ + "my-segment", + 87, + {"bob", "sam"}, + {"sally", "johan"}, + {{"vegetable", {"potato", "yam"}}}, + {{"material", {"cardboard", "plastic"}}}, + {{{{data_model::Clause::Op::kIn, + {"a", "b"}, + true, + ContextKind("bob"), + "/potato"}}, + "ididid", + 300, + ContextKind("bob"), + "/happy"}}, + "salty", + false, + std::nullopt, + std::nullopt, + }; + + auto json = boost::json::value_from(segment); + auto expected = boost::json::parse( + R"({ + "key": "my-segment", + "version": 87, + "included": ["bob", "sam"], + "excluded": ["sally", "johan"], + "includedContexts": + [{"contextKind": "vegetable", "values":["potato", "yam"]}], + "excludedContexts": + [{"contextKind": "material", "values":["cardboard", "plastic"]}], + "salt": "salty", + "rules":[{ + "clauses": [{ + "op": "in", + "negate": true, + "values": ["a", "b"], + "contextKind": "bob", + "attribute": "/potato" + }], + "id": "ididid", + "weight": 300, + "rolloutContextKind": "bob", + "bucketBy": "/happy" + }] + })"); + EXPECT_EQ(expected, json); +} + +TEST(SegmentTests, SerializeUnbounded) { + data_model::Segment segment{"my-segment", 87, {}, {}, {}, {}, + {}, "salty", true, "company", 12}; + + auto json = boost::json::value_from(segment); + auto expected = boost::json::parse( + R"({ + "key": "my-segment", + "version": 87, + "included": [], + "excluded": [], + "includedContexts": [], + "excludedContexts": [], + "salt": "salty", + "rules":[], + "unbounded": true, + "unboundedContextKind": "company", + "generation": 12 + })"); + EXPECT_EQ(expected, json); +} diff --git a/libs/internal/tests/evaluation_result_test.cpp b/libs/internal/tests/evaluation_result_test.cpp index 7db22aa92..48956a3af 100644 --- a/libs/internal/tests/evaluation_result_test.cpp +++ b/libs/internal/tests/evaluation_result_test.cpp @@ -15,54 +15,49 @@ using launchdarkly::JsonError; using launchdarkly::Value; TEST(EvaluationResultTests, FromJsonAllFields) { - auto evaluation_result = - boost::json::value_to>( - boost::json::parse("{" - "\"version\": 12," - "\"flagVersion\": 24," - "\"trackEvents\": true," - "\"trackReason\": true," - "\"debugEventsUntilDate\": 1680555761," - "\"value\": {\"item\": 42}," - "\"variation\": 84," - "\"reason\": {" - "\"kind\":\"OFF\"," - "\"errorKind\":\"MALFORMED_FLAG\"," - "\"ruleIndex\":12," - "\"ruleId\":\"RULE_ID\"," - "\"prerequisiteKey\":\"PREREQ_KEY\"," - "\"inExperiment\":true," - "\"bigSegmentStatus\":\"STORE_ERROR\"" - "}" - "}")); + auto evaluation_result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse("{" + "\"version\": 12," + "\"flagVersion\": 24," + "\"trackEvents\": true," + "\"trackReason\": true," + "\"debugEventsUntilDate\": 1680555761," + "\"value\": {\"item\": 42}," + "\"variation\": 84," + "\"reason\": {" + "\"kind\":\"OFF\"," + "\"errorKind\":\"MALFORMED_FLAG\"," + "\"ruleIndex\":12," + "\"ruleId\":\"RULE_ID\"," + "\"prerequisiteKey\":\"PREREQ_KEY\"," + "\"inExperiment\":true," + "\"bigSegmentStatus\":\"STORE_ERROR\"" + "}" + "}")); EXPECT_TRUE(evaluation_result.has_value()); - EXPECT_EQ(12, evaluation_result.value().Version()); - EXPECT_EQ(24, evaluation_result.value().FlagVersion()); - EXPECT_TRUE(evaluation_result.value().TrackEvents()); - EXPECT_TRUE(evaluation_result.value().TrackReason()); + auto const& val = evaluation_result.value(); + EXPECT_TRUE(val.has_value()); + + EXPECT_EQ(12, val->Version()); + EXPECT_EQ(24, val->FlagVersion()); + EXPECT_TRUE(val->TrackEvents()); + EXPECT_TRUE(val->TrackReason()); EXPECT_EQ(std::chrono::system_clock::time_point{std::chrono::milliseconds{ 1680555761}}, - evaluation_result.value().DebugEventsUntilDate()); - EXPECT_EQ( - 42, - evaluation_result.value().Detail().Value().AsObject()["item"].AsInt()); - EXPECT_EQ(84, evaluation_result.value().Detail().VariationIndex()); + val->DebugEventsUntilDate()); + EXPECT_EQ(42, val->Detail().Value().AsObject()["item"].AsInt()); + EXPECT_EQ(84, val->Detail().VariationIndex()); EXPECT_EQ(EvaluationReason::Kind::kOff, - evaluation_result.value().Detail().Reason()->get().Kind()); + val->Detail().Reason()->get().Kind()); EXPECT_EQ(EvaluationReason::ErrorKind::kMalformedFlag, - evaluation_result.value().Detail().Reason()->get().ErrorKind()); - EXPECT_EQ(12, - evaluation_result.value().Detail().Reason()->get().RuleIndex()); - EXPECT_EQ("RULE_ID", - evaluation_result.value().Detail().Reason()->get().RuleId()); - EXPECT_EQ( - "PREREQ_KEY", - evaluation_result.value().Detail().Reason()->get().PrerequisiteKey()); - EXPECT_EQ("STORE_ERROR", - evaluation_result.value().Detail().Reason()->get().BigSegmentStatus()); - EXPECT_TRUE( - evaluation_result.value().Detail().Reason()->get().InExperiment()); + val->Detail().Reason()->get().ErrorKind()); + EXPECT_EQ(12, val->Detail().Reason()->get().RuleIndex()); + EXPECT_EQ("RULE_ID", val->Detail().Reason()->get().RuleId()); + EXPECT_EQ("PREREQ_KEY", val->Detail().Reason()->get().PrerequisiteKey()); + EXPECT_EQ("STORE_ERROR", val->Detail().Reason()->get().BigSegmentStatus()); + EXPECT_TRUE(val->Detail().Reason()->get().InExperiment()); } TEST(EvaluationResultTests, ToJsonAllFields) { @@ -91,29 +86,27 @@ TEST(EvaluationResultTests, ToJsonAllFields) { } TEST(EvaluationResultTests, FromJsonMinimalFields) { - auto evaluation_result = - boost::json::value_to>( - boost::json::parse("{" - "\"version\": 12," - "\"value\": {\"item\": 42}" - "}")); - - EXPECT_EQ(12, evaluation_result.value().Version()); - EXPECT_EQ(std::nullopt, evaluation_result.value().FlagVersion()); - EXPECT_FALSE(evaluation_result.value().TrackEvents()); - EXPECT_FALSE(evaluation_result.value().TrackReason()); - EXPECT_EQ(std::nullopt, evaluation_result.value().DebugEventsUntilDate()); - EXPECT_EQ( - 42, - evaluation_result.value().Detail().Value().AsObject()["item"].AsInt()); - EXPECT_EQ(std::nullopt, - evaluation_result.value().Detail().VariationIndex()); - EXPECT_EQ(std::nullopt, evaluation_result.value().Detail().Reason()); + auto evaluation_result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse("{" + "\"version\": 12," + "\"value\": {\"item\": 42}" + "}")); + + auto const& value = evaluation_result.value(); + EXPECT_EQ(12, value->Version()); + EXPECT_EQ(std::nullopt, value->FlagVersion()); + EXPECT_FALSE(value->TrackEvents()); + EXPECT_FALSE(value->TrackReason()); + EXPECT_EQ(std::nullopt, value->DebugEventsUntilDate()); + EXPECT_EQ(42, value->Detail().Value().AsObject()["item"].AsInt()); + EXPECT_EQ(std::nullopt, value->Detail().VariationIndex()); + EXPECT_EQ(std::nullopt, value->Detail().Reason()); } TEST(EvaluationResultTests, FromMapOfResults) { - auto results = boost::json::value_to< - std::map>>( + auto results = boost::json::value_to, JsonError>>>( boost::json::parse("{" "\"flagA\":{" "\"version\": 12," @@ -124,26 +117,25 @@ TEST(EvaluationResultTests, FromMapOfResults) { "\"value\": false" "}" "}")); - - EXPECT_TRUE(results.at("flagA").value().Detail().Value().AsBool()); - EXPECT_FALSE(results.at("flagB").value().Detail().Value().AsBool()); + EXPECT_TRUE(results.at("flagA").value().value().Detail().Value().AsBool()); + EXPECT_FALSE(results.at("flagB").value().value().Detail().Value().AsBool()); } TEST(EvaluationResultTests, NoResultFieldsJson) { - auto results = - boost::json::value_to>( - boost::json::parse("{}")); + auto results = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse("{}")); - EXPECT_FALSE(results.has_value()); + EXPECT_FALSE(results); EXPECT_EQ(JsonError::kSchemaFailure, results.error()); } TEST(EvaluationResultTests, VersionWrongTypeJson) { - auto results = - boost::json::value_to>( - boost::json::parse("{\"version\": \"apple\"}")); + auto results = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse("{\"version\": \"apple\"}")); - EXPECT_FALSE(results.has_value()); + EXPECT_FALSE(results); EXPECT_EQ(JsonError::kSchemaFailure, results.error()); } diff --git a/libs/internal/tests/event_processor_test.cpp b/libs/internal/tests/event_processor_test.cpp index d210f8e8c..f8962ee08 100644 --- a/libs/internal/tests/event_processor_test.cpp +++ b/libs/internal/tests/event_processor_test.cpp @@ -7,12 +7,11 @@ #include #include #include -#include -#include -#include +#include #include using namespace launchdarkly::events; +using namespace launchdarkly::events::detail; using namespace launchdarkly::network; static std::chrono::system_clock::time_point TimeZero() { @@ -82,16 +81,16 @@ TEST(EventProcessorTests, ProcessorCompiles) { auto context = launchdarkly::ContextBuilder().Kind("org", "ld").Build(); ASSERT_TRUE(context.Valid()); - auto identify_event = events::client::IdentifyEventParams{ + auto identify_event = events::IdentifyEventParams{ std::chrono::system_clock::now(), context, }; for (std::size_t i = 0; i < 10; i++) { - processor.AsyncSend(identify_event); + processor.SendAsync(identify_event); } - processor.AsyncClose(); + processor.ShutdownAsync(); ioc_thread.join(); } @@ -99,7 +98,7 @@ TEST(EventProcessorTests, ParseValidDateHeader) { using namespace launchdarkly; using Clock = std::chrono::system_clock; - auto date = events::ParseDateHeader("Wed, 21 Oct 2015 07:28:00 GMT"); + auto date = detail::ParseDateHeader("Wed, 21 Oct 2015 07:28:00 GMT"); ASSERT_TRUE(date); @@ -110,17 +109,17 @@ TEST(EventProcessorTests, ParseValidDateHeader) { TEST(EventProcessorTests, ParseInvalidDateHeader) { using namespace launchdarkly; - auto not_a_date = events::ParseDateHeader( + auto not_a_date = detail::ParseDateHeader( "this is definitely not a date"); ASSERT_FALSE(not_a_date); - auto not_gmt = events::ParseDateHeader( + auto not_gmt = detail::ParseDateHeader( "Wed, 21 Oct 2015 07:28:00 PST"); ASSERT_FALSE(not_gmt); - auto missing_year = events::ParseDateHeader( + auto missing_year = detail::ParseDateHeader( "Wed, 21 Oct 07:28:00 GMT"); ASSERT_FALSE(missing_year); diff --git a/libs/internal/tests/event_serialization_test.cpp b/libs/internal/tests/event_serialization_test.cpp index 00e274946..f1f1cddfc 100644 --- a/libs/internal/tests/event_serialization_test.cpp +++ b/libs/internal/tests/event_serialization_test.cpp @@ -3,18 +3,16 @@ #include #include -#include - #include +#include #include -#include namespace launchdarkly::events { TEST(EventSerialization, FeatureEvent) { auto creation_date = std::chrono::system_clock::from_time_t({}); - auto event = events::client::FeatureEvent{ - client::FeatureEventBase(client::FeatureEventParams{ + auto event = events::FeatureEvent{ + events::FeatureEventBase(events::FeatureEventParams{ creation_date, "key", ContextBuilder().Kind("foo", "bar").Build(), @@ -42,9 +40,9 @@ TEST(EventSerialization, DebugEvent) { AttributeReference::SetType attrs; ContextFilter filter(false, attrs); auto context = ContextBuilder().Kind("foo", "bar").Build(); - auto event = events::client::DebugEvent{ - client::FeatureEventBase( - client::FeatureEventBase(client::FeatureEventParams{ + auto event = events::DebugEvent{ + events::FeatureEventBase( + events::FeatureEventBase(events::FeatureEventParams{ creation_date, "key", ContextBuilder().Kind("foo", "bar").Build(), @@ -71,7 +69,7 @@ TEST(EventSerialization, IdentifyEvent) { auto creation_date = std::chrono::system_clock::from_time_t({}); AttributeReference::SetType attrs; ContextFilter filter(false, attrs); - auto event = events::client::IdentifyEvent{ + auto event = events::IdentifyEvent{ creation_date, filter.filter(ContextBuilder().Kind("foo", "bar").Build())}; @@ -82,4 +80,19 @@ TEST(EventSerialization, IdentifyEvent) { ASSERT_EQ(result, event_json); } +TEST(EventSerialization, IndexEvent) { + auto creation_date = std::chrono::system_clock::from_time_t({}); + AttributeReference::SetType attrs; + ContextFilter filter(false, attrs); + auto event = events::server_side::IndexEvent{ + creation_date, + filter.filter(ContextBuilder().Kind("foo", "bar").Build())}; + + auto event_json = boost::json::value_from(event); + + auto result = boost::json::parse( + R"({"kind":"index","creationDate":0,"context":{"key":"bar","kind":"foo"}})"); + ASSERT_EQ(result, event_json); +} + } // namespace launchdarkly::events diff --git a/libs/internal/tests/event_summarizer_test.cpp b/libs/internal/tests/event_summarizer_test.cpp index 2922f9b2d..9e657ef8c 100644 --- a/libs/internal/tests/event_summarizer_test.cpp +++ b/libs/internal/tests/event_summarizer_test.cpp @@ -2,15 +2,14 @@ #include #include #include -#include -#include +#include #include #include #include +#include "launchdarkly/events/detail/summarizer.hpp" using namespace launchdarkly::events; -using namespace launchdarkly::events::client; -using namespace launchdarkly::events; +using namespace launchdarkly::events::detail; static std::chrono::system_clock::time_point TimeZero() { return std::chrono::system_clock::time_point{}; @@ -27,13 +26,13 @@ TEST(SummarizerTests, IsEmptyOnConstruction) { TEST(SummarizerTests, DefaultConstructionUsesZeroStartTime) { Summarizer summarizer; - ASSERT_EQ(summarizer.start_time(), TimeZero()); + ASSERT_EQ(summarizer.StartTime(), TimeZero()); } TEST(SummarizerTests, ExplicitStartTimeIsCorrect) { auto start = std::chrono::system_clock::from_time_t(12345); Summarizer summarizer(start); - ASSERT_EQ(summarizer.start_time(), start); + ASSERT_EQ(summarizer.StartTime(), start); } struct EvaluationParams { @@ -292,7 +291,7 @@ INSTANTIATE_TEST_SUITE_P( {Summarizer::VariationKey(1, 1), 1}}}}})); TEST(SummarizerTests, MissingFlagCreatesCounterUsingDefaultValue) { - using namespace launchdarkly::events::client; + using namespace launchdarkly::events; using namespace launchdarkly; Summarizer summarizer; @@ -340,7 +339,7 @@ TEST(SummarizerTests, MissingFlagCreatesCounterUsingDefaultValue) { } TEST(SummarizerTests, JsonSerialization) { - using namespace launchdarkly::events::client; + using namespace launchdarkly::events; using namespace launchdarkly; Summarizer summarizer; diff --git a/libs/internal/tests/ld_logger_test.cpp b/libs/internal/tests/ld_logger_test.cpp index cf393d332..d0072dcc1 100644 --- a/libs/internal/tests/ld_logger_test.cpp +++ b/libs/internal/tests/ld_logger_test.cpp @@ -86,11 +86,7 @@ INSTANTIATE_TEST_SUITE_P(LDLoggerTest, testing::Values(LogLevel::kDebug, LogLevel::kInfo, LogLevel::kWarn, - LogLevel::kError), - [](testing::TestParamInfo const& info) { - return launchdarkly::GetLogLevelName(info.param, - "unknown"); - }); + LogLevel::kError)); TEST(LDLoggerTest, UsesOstreamForEnabledLevel) { Messages messages; diff --git a/libs/internal/tests/lru_cache_test.cpp b/libs/internal/tests/lru_cache_test.cpp new file mode 100644 index 000000000..e1cf64ee0 --- /dev/null +++ b/libs/internal/tests/lru_cache_test.cpp @@ -0,0 +1,65 @@ +#include "launchdarkly/events/detail/lru_cache.hpp" +#include + +using namespace launchdarkly::events::detail; + +TEST(ContextKeyCacheTests, CacheSizeOne) { + LRUCache cache(1); + + auto keys = {"foo", "bar", "baz", "qux"}; + for (auto const& k : keys) { + ASSERT_FALSE(cache.Notice(k)); + ASSERT_EQ(cache.Size(), 1); + } +} + +TEST(ContextKeyCacheTests, CacheIsCleared) { + LRUCache cache(3); + auto keys = {"foo", "bar", "baz"}; + for (auto const& k : keys) { + cache.Notice(k); + } + ASSERT_EQ(cache.Size(), 3); + cache.Clear(); + ASSERT_EQ(cache.Size(), 0); +} + +TEST(ContextKeyCacheTests, LRUProperty) { + LRUCache cache(3); + auto keys = {"foo", "bar", "baz"}; + for (auto const& k : keys) { + cache.Notice(k); + } + + for (auto const& k : keys) { + ASSERT_TRUE(cache.Notice(k)); + } + + // Evict foo. + cache.Notice("qux"); + ASSERT_TRUE(cache.Notice("bar")); + ASSERT_TRUE(cache.Notice("baz")); + ASSERT_TRUE(cache.Notice("qux")); + + // Evict bar. + cache.Notice("foo"); + ASSERT_TRUE(cache.Notice("baz")); + ASSERT_TRUE(cache.Notice("qux")); + ASSERT_TRUE(cache.Notice("foo")); +} + +TEST(ContextKeyCacheTests, DoesNotExceedCapacity) { + const std::size_t CAP = 100; + const std::size_t N = 100000; + LRUCache cache(CAP); + + for (int i = 0; i < N; ++i) { + cache.Notice(std::to_string(i)); + } + + for (int i = N - CAP; i < N; ++i) { + ASSERT_TRUE(cache.Notice(std::to_string(i))); + } + + ASSERT_EQ(cache.Size(), CAP); +} diff --git a/libs/internal/tests/request_worker_test.cpp b/libs/internal/tests/request_worker_test.cpp index d38b665e9..645769fad 100644 --- a/libs/internal/tests/request_worker_test.cpp +++ b/libs/internal/tests/request_worker_test.cpp @@ -1,8 +1,9 @@ +#include "launchdarkly/events/detail/request_worker.hpp" #include -#include #include using namespace launchdarkly::events; +using namespace launchdarkly::events::detail; using namespace launchdarkly::network; struct TestCase { diff --git a/libs/internal/tests/sha_1_test.cpp b/libs/internal/tests/sha_1_test.cpp new file mode 100644 index 000000000..a491de37e --- /dev/null +++ b/libs/internal/tests/sha_1_test.cpp @@ -0,0 +1,20 @@ +#include + +#include "launchdarkly/encoding/base_16.hpp" +#include "launchdarkly/encoding/sha_1.hpp" + +using namespace launchdarkly::encoding; + +TEST(Sha1, CanEncodeString) { + // Test vectors from + // https://www.di-mgt.com.au/sha_testvectors.html + EXPECT_EQ(std::string("da39a3ee5e6b4b0d3255bfef95601890afd80709"), + Base16Encode(Sha1String(""))); + + EXPECT_EQ(std::string("a9993e364706816aba3e25717850c26c9cd0d89d"), + Base16Encode(Sha1String("abc"))); + + EXPECT_EQ(std::string("84983e441c3bd26ebaae4aa1f95129e5e54670f1"), + Base16Encode(Sha1String( + "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"))); +} diff --git a/libs/internal/tests/sha_256_test.cpp b/libs/internal/tests/sha_256_test.cpp index 99105a755..1e417aef8 100644 --- a/libs/internal/tests/sha_256_test.cpp +++ b/libs/internal/tests/sha_256_test.cpp @@ -1,18 +1,9 @@ #include +#include "launchdarkly/encoding/base_16.hpp" #include "launchdarkly/encoding/sha_256.hpp" -using launchdarkly::encoding::Sha256String; - -static std::string HexEncode(std::array arr) { - std::stringstream output_stream; - output_stream << std::hex << std::noshowbase; - for (unsigned char byte : arr) { - output_stream << std::setw(2) << std::setfill('0') - << static_cast(byte); - } - return output_stream.str(); -} +using namespace launchdarkly::encoding; TEST(Sha256, CanEncodeString) { // Test vectors from @@ -20,16 +11,16 @@ TEST(Sha256, CanEncodeString) { EXPECT_EQ( std::string( "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"), - HexEncode(Sha256String(""))); + Base16Encode(Sha256String(""))); EXPECT_EQ( std::string( "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"), - HexEncode(Sha256String("abc"))); + Base16Encode(Sha256String("abc"))); EXPECT_EQ( std::string( "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"), - HexEncode(Sha256String( + Base16Encode(Sha256String( "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"))); } diff --git a/libs/server-sdk/CMakeLists.txt b/libs/server-sdk/CMakeLists.txt new file mode 100644 index 000000000..5f41ab6ba --- /dev/null +++ b/libs/server-sdk/CMakeLists.txt @@ -0,0 +1,38 @@ +# This project aims to follow modern cmake guidelines, e.g. +# https://cliutils.gitlab.io/modern-cmake + +# Required for Apple Silicon support. +cmake_minimum_required(VERSION 3.19) + +project( + LaunchDarklyCPPServer + VERSION 0.1 + DESCRIPTION "LaunchDarkly C++ Server SDK" + LANGUAGES CXX C +) + +set(LIBNAME "launchdarkly-cpp-server") + +# If this project is the main CMake project (as opposed to being included via add_subdirectory) +if (CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME) + # Disable C++ extensions for portability. + set(CMAKE_CXX_EXTENSIONS OFF) + # Enable folder support in IDEs. + set_property(GLOBAL PROPERTY USE_FOLDERS ON) +endif () + +#set(CMAKE_FILES "${CMAKE_CURRENT_SOURCE_DIR}/cmake") +#set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} ${CMAKE_FILES}) + +# Needed to fetch external dependencies. +include(FetchContent) + +# Needed to parse RFC3339 dates in flag rules. +include(${CMAKE_FILES}/rfc3339_timestamp.cmake) + +# Add main SDK sources. +add_subdirectory(src) + +if (BUILD_TESTING) + add_subdirectory(tests) +endif () diff --git a/libs/server-sdk/Doxyfile b/libs/server-sdk/Doxyfile new file mode 100644 index 000000000..c8ba8bd90 --- /dev/null +++ b/libs/server-sdk/Doxyfile @@ -0,0 +1,94 @@ +# Doxyfile 1.8.17 + +# This file describes the settings to be used by the documentation system +# doxygen (www.doxygen.org) for a project. +# +# All text after a double hash (##) is considered a comment and is placed in +# front of the TAG it is preceding. +# +# All text after a single hash (#) is considered a comment and will be ignored. +# The format is: +# TAG = value [value, ...] +# For lists, items can also be appended using: +# TAG += value [value, ...] +# Values that contain spaces should be placed between quotes (\" \"). + +#--------------------------------------------------------------------------- +# Project related configuration options +#--------------------------------------------------------------------------- + +# This tag specifies the encoding used for all characters in the configuration +# file that follow. The default is UTF-8 which is also the encoding used for all +# text before the first occurrence of this tag. Doxygen uses libiconv (or the +# iconv built into libc) for the transcoding. See +# https://www.gnu.org/software/libiconv/ for the list of possible encodings. +# The default value is: UTF-8. + +DOXYFILE_ENCODING = UTF-8 + +# The PROJECT_NAME tag is a single word (or a sequence of words surrounded by +# double-quotes, unless you are using Doxywizard) that should identify the +# project for which the documentation is generated. This name is used in the +# title of most generated pages and in a few other places. +# The default value is: My Project. + +PROJECT_NAME = "C++ Server-Side SDK" + +# Using the PROJECT_BRIEF tag one can provide an optional one line description +# for a project that appears at the top of each page and should give viewer a +# quick idea about the purpose of the project. Keep the description short. + +PROJECT_BRIEF = "LaunchDarkly SDK" + +# The OUTPUT_DIRECTORY tag is used to specify the (relative or absolute) path +# into which the generated documentation will be written. If a relative path is +# entered, it will be relative to the location where doxygen was started. If +# left blank the current directory will be used. + +OUTPUT_DIRECTORY = docs + +# If you use STL classes (i.e. std::string, std::vector, etc.) but do not want +# to include (a tag file for) the STL sources as input, then you should set this +# tag to YES in order to let doxygen match functions declarations and +# definitions whose arguments contain STL classes (e.g. func(std::string); +# versus func(std::string) {}). This also make the inheritance and collaboration +# diagrams that involve STL classes more complete and accurate. +# The default value is: NO. + +BUILTIN_STL_SUPPORT = YES + +# When TYPEDEF_HIDES_STRUCT tag is enabled, a typedef of a struct, union, or +# enum is documented as struct, union, or enum with the name of the typedef. So +# typedef struct TypeS {} TypeT, will appear in the documentation as a struct +# with name TypeT. When disabled the typedef will appear as a member of a file, +# namespace, or class. And the struct will be named TypeS. This can typically be +# useful for C code in case the coding convention dictates that all compound +# types are typedef'ed and only the typedef is referenced, never the tag name. +# The default value is: NO. + +TYPEDEF_HIDES_STRUCT = YES + +#--------------------------------------------------------------------------- +# Configuration options related to the input files +#--------------------------------------------------------------------------- + +# The INPUT tag is used to specify the files and/or directories that contain +# documented source files. You may enter file names like myfile.cpp or +# directories like /usr/src/myproject. Separate the files or directories with +# spaces. See also FILE_PATTERNS and EXTENSION_MAPPING +# Note: If this tag is empty the current directory is searched. + +INPUT = include src docs ../common/include ../common/src + +# The RECURSIVE tag can be used to specify whether or not subdirectories should +# be searched for input files as well. +# The default value is: NO. + +RECURSIVE = YES + +# If the USE_MDFILE_AS_MAINPAGE tag refers to the name of a markdown file that +# is part of the input, its contents will be placed on the main page +# (index.html). This can be useful if you have a project on for instance GitHub +# and want to reuse the introduction page also for the doxygen output. + +USE_MDFILE_AS_MAINPAGE = ./docs/doc.md diff --git a/libs/server-sdk/README.md b/libs/server-sdk/README.md new file mode 100644 index 000000000..25ad3bf40 --- /dev/null +++ b/libs/server-sdk/README.md @@ -0,0 +1,121 @@ +LaunchDarkly Server-Side SDK for C/C++ +=================================== + +[![Actions Status](https://github.com/launchdarkly/cpp-sdks/actions/workflows/server.yml/badge.svg)](https://github.com/launchdarkly/cpp-sdks/actions/workflows/server.yml) +[![Documentation](https://img.shields.io/static/v1?label=GitHub+Pages&message=API+reference&color=00add8)](https://launchdarkly.github.io/cpp-sdks/libs/server-sdk/docs/html/) + +The LaunchDarkly Server-Side SDK for C/C++ is designed primarily for use in multi-user systems such as web servers +and applications. It follows the server-side LaunchDarkly model for multi-user contexts. +It is not intended for use in desktop and embedded systems applications. + +For using LaunchDarkly in client-side C/C++ applications, refer to our [Client-Side C/C++ SDK](../client-sdk/README.md). + +LaunchDarkly overview +------------------------- +[LaunchDarkly](https://www.launchdarkly.com) is a feature management platform that serves trillions of feature flags +daily to help teams build better software, faster. [Get started](https://docs.launchdarkly.com/docs/getting-started) +using LaunchDarkly today! + +[![Twitter Follow](https://img.shields.io/twitter/follow/launchdarkly.svg?style=social&label=Follow&maxAge=2592000)](https://twitter.com/intent/follow?screen_name=launchdarkly) + +Compatibility +------------------------- + +This version of the LaunchDarkly SDK is compatible with POSIX environments (Linux, OS X, BSD) and Windows. + +Getting started +--------------- + +Download a release archive from +the [Github releases](https://github.com/launchdarkly/cpp-sdks/releases?q=cpp-server&expanded=true) for use in your +project. + +Refer to the [SDK documentation][reference-guide] for complete instructions on +installing and using the SDK. + +### Incorporating the SDK + +The SDK can be used via a C++ or C interface and can be incorporated via a static library or shared object. The static +library and shared object each have their own use cases and limitations. + +The static library supports both the C++ and C interface. When using the static library, you should ensure that it is +compiled using a compatible configuration and toolchain. For instance, when using MSVC, it needs to be using the same +runtime library. + +Using the static library also requires that you have OpenSSL and Boost available at the time of compilation for your +project. + +The C++ API does not have a stable ABI, so if this is important to you, consider using the shared object with the C API. + +Example of basic compilation using the C++ API with a static library using gcc: + +```shell +g++ -I path_to_the_sdk_install/include -O3 -std=c++17 -Llib -fPIE -g main.cpp path_to_the_sdk_install/lib/liblaunchdarkly-cpp-server.a -lpthread -lstdc++ -lcrypto -lssl -lboost_json -lboost_url +``` + +Example of basic compilation using the C API with a static library using msvc: + +```shell +cl /I include /Fe: hello.exe main.cpp /link lib/launchdarkly-cpp-server.lib +``` + +The shared library (so, DLL, dylib), only supports the C interface. The shared object does not require you to have Boost +or OpenSSL available when linking the shared object to your project. + +Example of basic compilation using the C API with a shared library using gcc: + +```shell +gcc -I $(pwd)/include -Llib -fPIE -g main.c liblaunchdarkly-cpp-server.so +``` + +The examples here are to help with getting started, but generally speaking the SDK should be incorporated using your +build system (CMake for instance). + +Learn more +----------- + +Read our [documentation](https://docs.launchdarkly.com) for in-depth instructions on configuring and using LaunchDarkly. +You can also head straight to +the [complete reference guide for this SDK][reference-guide]. + +Testing +------- + +We run integration tests for all our SDKs using a centralized test harness. This approach gives us the ability to test +for consistency across SDKs, as well as test networking behavior in a long-running application. These tests cover each +method in the SDK, and verify that event sending, flag evaluation, stream reconnection, and other aspects of the SDK all +behave correctly. + +Contributing +------------ + +We encourage pull requests and other contributions from the community. Read +our [contributing guidelines](../../CONTRIBUTING.md) for instructions on how to contribute to this SDK. + +About LaunchDarkly +----------- + +* LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to + iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. + With LaunchDarkly, you can: + * Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), + gathering feedback and bug reports from real-world use cases. + * Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on + key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?). + * Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, + or even restart the application with a changed configuration file. + * Grant access to certain features based on user attributes, like payment plan (eg: users on the ‘gold’ plan get + access to more features than users in the ‘silver’ plan). Disable parts of your application to facilitate + maintenance, without taking everything offline. +* LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. + Read [our documentation](https://docs.launchdarkly.com/docs) for a complete list. +* Explore LaunchDarkly + * [launchdarkly.com](https://www.launchdarkly.com/ "LaunchDarkly Main Website") for more information + * [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and + SDK reference guides + * [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API + documentation + * [blog.launchdarkly.com](https://blog.launchdarkly.com/ "LaunchDarkly Blog Documentation") for the latest product + updates + +[reference-guide]: https://docs.launchdarkly.com/sdk/server-side/c-c-- diff --git a/libs/server-sdk/docs/doc.md b/libs/server-sdk/docs/doc.md new file mode 100644 index 000000000..fbc1d6a9f --- /dev/null +++ b/libs/server-sdk/docs/doc.md @@ -0,0 +1,9 @@ +# SDK Layout and Overview + +## Basic Functionality + +The following pages document the core of the API, every application will use these portions of the SDK: + +- [Client](@ref launchdarkly::server_side::Client) +- [Config Builder](@ref launchdarkly::config::shared::builders::ConfigBuilder) +- [Context Builder](@ref launchdarkly::ContextBuilder) diff --git a/libs/server-sdk/include/launchdarkly/server_side/all_flags_state.hpp b/libs/server-sdk/include/launchdarkly/server_side/all_flags_state.hpp new file mode 100644 index 000000000..6f4b50b63 --- /dev/null +++ b/libs/server-sdk/include/launchdarkly/server_side/all_flags_state.hpp @@ -0,0 +1,172 @@ +#pragma once + +#include +#include + +#include +#include +#include + +namespace launchdarkly::server_side { + +/** + * AllFlagsState is a snapshot of the state of multiple feature flags with + * regard to a specific evaluation context. + * + * Serializing this object to JSON using boost::json::value_from will produce + * the appropriate data structure for bootstrapping the LaunchDarkly JavaScript + * client. + * + * To do this, the header + * must be + * included to make the appropriate `tag_invoke` implementations available to + * boost. + */ +class AllFlagsState { + public: + enum class Options : std::uint8_t { + /** + * Default behavior. + */ + Default = 0, + /** + * Include evaluation reasons in the state object. By default, they + * are not. + */ + IncludeReasons = (1 << 0), + /** + * Include detailed flag metadata only for flags with event tracking + * or debugging turned on. + * + * This reduces the size of the JSON data if you are + * passing the flag state to the front end. + */ + DetailsOnlyForTrackedFlags = (1 << 1), + /** + * Include only flags marked for use with the client-side SDK. + * By default, all flags are included. + */ + ClientSideOnly = (1 << 2) + }; + + /** + * State contains information pertaining to a single feature flag. + */ + class State { + public: + State(std::uint64_t version, + std::optional variation, + std::optional reason, + bool track_events, + bool track_reason, + std::optional debug_events_until_date); + + /** + * @return The flag's version number when it was evaluated. + */ + [[nodiscard]] std::uint64_t Version() const; + + /** + * @return The variation index that was selected for the specified + * evaluation context. + */ + [[nodiscard]] std::optional Variation() const; + + /** + * @return The reason that the flag evaluation produced the specified + * variation. + */ + [[nodiscard]] std::optional const& Reason() const; + + /** + * @return True if a full feature event must be sent when evaluating + * this flag. This will be true if tracking was explicitly enabled for + * this flag for data export, or if the evaluation involved an + * experiment, or both. + */ + [[nodiscard]] bool TrackEvents() const; + + /** + * @return True if the evaluation reason should always be included in + * any full feature event created for this flag, regardless of whether a + * VariationDetail method was called. This will be true if the + * evaluation involved an experiment. + */ + [[nodiscard]] bool TrackReason() const; + + /** + * @return The date on which debug mode expires for this flag, if + * enabled. + */ + [[nodiscard]] std::optional const& DebugEventsUntilDate() + const; + + /** + * + * @return True if the options passed to AllFlagsState, combined with + * the obtained flag state, indicate that some metadata can be left out + * of the JSON serialization. + */ + [[nodiscard]] bool OmitDetails() const; + + friend class AllFlagsStateBuilder; + + private: + std::uint64_t version_; + std::optional variation_; + std::optional reason_; + bool track_events_; + bool track_reason_; + std::optional debug_events_until_date_; + bool omit_details_; + }; + + /** + * @return True if the call to AllFlagsState succeeded. False if there was + * an error, such as the data store being unavailable. When false, the other + * accessors will return empty maps. + */ + [[nodiscard]] bool Valid() const; + + /** + * @return A map of metadata for each flag. + */ + [[nodiscard]] std::unordered_map const& States() const; + + /** + * @return A map of evaluation results for each flag. + */ + [[nodiscard]] std::unordered_map const& Values() const; + + /** + * Constructs an invalid instance of AllFlagsState. + */ + AllFlagsState(); + + /** + * Constructs a valid instance of AllFlagsState. + * @param evaluations A map of evaluation results for each flag. + * @param flags_state A map of metadata for each flag. + */ + AllFlagsState(std::unordered_map evaluations, + std::unordered_map flags_state); + + private: + bool const valid_; + const std::unordered_map flags_state_; + const std::unordered_map evaluations_; +}; + +void operator|=(AllFlagsState::Options& lhs, AllFlagsState::Options rhs); +AllFlagsState::Options operator|(AllFlagsState::Options lhs, + AllFlagsState::Options rhs); + +AllFlagsState::Options operator&(AllFlagsState::Options lhs, + AllFlagsState::Options rhs); + +bool operator==(class AllFlagsState::State const& lhs, + class AllFlagsState::State const& rhs); + +bool operator==(AllFlagsState const& lhs, AllFlagsState const& rhs); + +} // namespace launchdarkly::server_side diff --git a/libs/server-sdk/include/launchdarkly/server_side/change_notifier.hpp b/libs/server-sdk/include/launchdarkly/server_side/change_notifier.hpp new file mode 100644 index 000000000..70f2fe17e --- /dev/null +++ b/libs/server-sdk/include/launchdarkly/server_side/change_notifier.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include + +#include +#include +#include +#include + +namespace launchdarkly::server_side { + +/** + * Interface to allow listening for flag changes. Notification events should + * be distributed after the store has been updated. + */ +class IChangeNotifier { + public: + using ChangeSet = std::set; + using ChangeHandler = std::function)>; + + /** + * Listen for changes to flag configuration. The change handler will be + * called with a set of affected flag keys. Changes include flags whose + * dependencies (either other flags, or segments) changed. + * + * @param signal The handler for the changes. + * @return A connection which can be used to stop listening. + */ + virtual std::unique_ptr OnFlagChange( + ChangeHandler handler) = 0; + + virtual ~IChangeNotifier() = default; + IChangeNotifier(IChangeNotifier const& item) = delete; + IChangeNotifier(IChangeNotifier&& item) = delete; + IChangeNotifier& operator=(IChangeNotifier const&) = delete; + IChangeNotifier& operator=(IChangeNotifier&&) = delete; + + protected: + IChangeNotifier() = default; +}; + +} // namespace launchdarkly::server_side diff --git a/libs/server-sdk/include/launchdarkly/server_side/client.hpp b/libs/server-sdk/include/launchdarkly/server_side/client.hpp new file mode 100644 index 000000000..bc13bf85c --- /dev/null +++ b/libs/server-sdk/include/launchdarkly/server_side/client.hpp @@ -0,0 +1,345 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +namespace launchdarkly::server_side { +/** + * Interface for the standard SDK client methods and properties. + */ +class IClient { + public: + /** + * Represents the key of a feature flag. + */ + using FlagKey = std::string; + + /** Connects the client to LaunchDarkly's flag delivery endpoints. + * + * If StartAsync isn't called, the client is able to post events but is + * unable to obtain flag data. + * + * The returned future will resolve to true or false based on the logic + * outlined on @ref Initialized. + */ + virtual std::future StartAsync() = 0; + + /** + * Returns a boolean value indicating LaunchDarkly connection and flag state + * within the client. + * + * When you first start the client, once StartAsync has completed, + * Initialized should return true if and only if either 1. it connected to + * LaunchDarkly and successfully retrieved flags, or 2. it started in + * offline mode so there's no need to connect to LaunchDarkly. If the client + * timed out trying to connect to LD, then Initialized returns false (even + * if we do have cached flags). If the client connected and got a 401 error, + * Initialized is will return false. This serves the purpose of letting the + * app know that there was a problem of some kind. + * + * @return True if the client is initialized. + */ + [[nodiscard]] virtual bool Initialized() const = 0; + + /** + * Returns a map from feature flag keys to feature + * flag values for the current context. + * + * This method will not send analytics events back to LaunchDarkly. + * + * @return A map from feature flag keys to values for the current context. + */ + [[nodiscard]] virtual class AllFlagsState AllFlagsState( + Context const& context, + AllFlagsState::Options options = AllFlagsState::Options::Default) = 0; + + /** + * Tracks that the current context performed an event for the given event + * name, and associates it with a numeric metric value. + * + * @param event_name The name of the event. + * @param data A JSON value containing additional data associated with the + * event. + * @param metric_value this value is used by the LaunchDarkly + * experimentation feature in numeric custom metrics, and will also be + * returned as part of the custom event for Data Export + */ + virtual void Track(Context const& ctx, + std::string event_name, + Value data, + double metric_value) = 0; + + /** + * Tracks that the current context performed an event for the given event + * name, with additional JSON data. + * + * @param event_name The name of the event. + * @param data A JSON value containing additional data associated with the + * event. + */ + virtual void Track(Context const& ctx, + std::string event_name, + Value data) = 0; + + /** + * Tracks that the current context performed an event for the given event + * name. + * + * @param event_name The name of the event. + */ + virtual void Track(Context const& ctx, std::string event_name) = 0; + + /** + * Tells the client that all pending analytics events (if any) should be + * delivered as soon as possible. + */ + virtual void FlushAsync() = 0; + + /** + * Generates an identify event for a context. + * + * @param context The new evaluation context. + */ + + virtual void Identify(Context context) = 0; + + /** + * Returns the boolean value of a feature flag for a given flag key. + * + * @param key The unique feature key for the feature flag. + * @param default_value The default value of the flag. + * @return The variation for the selected context, or default_value if the + * flag is disabled in the LaunchDarkly control panel + */ + virtual bool BoolVariation(Context const& ctx, + FlagKey const& key, + bool default_value) = 0; + + /** + * Returns the boolean value of a feature flag for a given flag key, in an + * object that also describes the way the value was determined. + * + * @param key The unique feature key for the feature flag. + * @param default_value The default value of the flag. + * @return An evaluation detail object. + */ + virtual EvaluationDetail BoolVariationDetail(Context const& ctx, + FlagKey const& key, + bool default_value) = 0; + + /** + * Returns the string value of a feature flag for a given flag key. + * + * @param key The unique feature key for the feature flag. + * @param default_value The default value of the flag. + * @return The variation for the selected context, or default_value if the + * flag is disabled in the LaunchDarkly control panel + */ + virtual std::string StringVariation(Context const& ctx, + FlagKey const& key, + std::string default_value) = 0; + + /** + * Returns the string value of a feature flag for a given flag key, in an + * object that also describes the way the value was determined. + * + * @param key The unique feature key for the feature flag. + * @param default_value The default value of the flag. + * @return An evaluation detail object. + */ + virtual EvaluationDetail StringVariationDetail( + Context const& ctx, + FlagKey const& key, + std::string default_value) = 0; + + /** + * Returns the double value of a feature flag for a given flag key. + * + * @param key The unique feature key for the feature flag. + * @param default_value The default value of the flag. + * @return The variation for the selected context, or default_value if the + * flag is disabled in the LaunchDarkly control panel + */ + virtual double DoubleVariation(Context const& ctx, + FlagKey const& key, + double default_value) = 0; + + /** + * Returns the double value of a feature flag for a given flag key, in an + * object that also describes the way the value was determined. + * + * @param key The unique feature key for the feature flag. + * @param default_value The default value of the flag. + * @return An evaluation detail object. + */ + virtual EvaluationDetail DoubleVariationDetail( + Context const& ctx, + FlagKey const& key, + double default_value) = 0; + + /** + * Returns the int value of a feature flag for a given flag key. + * + * @param key The unique feature key for the feature flag. + * @param default_value The default value of the flag. + * @return The variation for the selected context, or default_value if the + * flag is disabled in the LaunchDarkly control panel + */ + virtual int IntVariation(Context const& ctx, + FlagKey const& key, + int default_value) = 0; + + /** + * Returns the int value of a feature flag for a given flag key, in an + * object that also describes the way the value was determined. + * + * @param key The unique feature key for the feature flag. + * @param default_value The default value of the flag. + * @return An evaluation detail object. + */ + virtual EvaluationDetail IntVariationDetail(Context const& ctx, + FlagKey const& key, + int default_value) = 0; + + /** + * Returns the JSON value of a feature flag for a given flag key. + * + * @param key The unique feature key for the feature flag. + * @param default_value The default value of the flag. + * @return The variation for the selected context, or default_value if the + * flag is disabled in the LaunchDarkly control panel + */ + virtual Value JsonVariation(Context const& ctx, + FlagKey const& key, + Value default_value) = 0; + + /** + * Returns the JSON value of a feature flag for a given flag key, in an + * object that also describes the way the value was determined. + * + * @param key The unique feature key for the feature flag. + * @param default_value The default value of the flag. + * @return An evaluation detail object. + */ + virtual EvaluationDetail JsonVariationDetail( + Context const& ctx, + FlagKey const& key, + Value default_value) = 0; + + /** + * Returns an interface which provides methods for subscribing to data + * source status. + * @return A data source status provider. + */ + virtual data_sources::IDataSourceStatusProvider& DataSourceStatus() = 0; + + virtual ~IClient() = default; + IClient(IClient const& item) = delete; + IClient(IClient&& item) = delete; + IClient& operator=(IClient const&) = delete; + IClient& operator=(IClient&&) = delete; + + protected: + IClient() = default; +}; + +class Client : public IClient { + public: + Client(Config config); + + Client(Client&&) = delete; + Client(Client const&) = delete; + Client& operator=(Client) = delete; + Client& operator=(Client&& other) = delete; + + std::future StartAsync() override; + + [[nodiscard]] bool Initialized() const override; + + using FlagKey = std::string; + [[nodiscard]] class AllFlagsState AllFlagsState( + Context const& context, + enum AllFlagsState::Options options = + AllFlagsState::Options::Default) override; + + void Track(Context const& ctx, + std::string event_name, + Value data, + double metric_value) override; + + void Track(Context const& ctx, std::string event_name, Value data) override; + + void Track(Context const& ctx, std::string event_name) override; + + void FlushAsync() override; + + void Identify(Context context) override; + + bool BoolVariation(Context const& ctx, + FlagKey const& key, + bool default_value) override; + + EvaluationDetail BoolVariationDetail(Context const& ctx, + FlagKey const& key, + bool default_value) override; + + std::string StringVariation(Context const& ctx, + FlagKey const& key, + std::string default_value) override; + + EvaluationDetail StringVariationDetail( + Context const& ctx, + FlagKey const& key, + std::string default_value) override; + + double DoubleVariation(Context const& ctx, + FlagKey const& key, + double default_value) override; + + EvaluationDetail DoubleVariationDetail( + Context const& ctx, + FlagKey const& key, + double default_value) override; + + int IntVariation(Context const& ctx, + FlagKey const& key, + int default_value) override; + + EvaluationDetail IntVariationDetail(Context const& ctx, + FlagKey const& key, + int default_value) override; + + Value JsonVariation(Context const& ctx, + FlagKey const& key, + Value default_value) override; + + EvaluationDetail JsonVariationDetail(Context const& ctx, + FlagKey const& key, + Value default_value) override; + + data_sources::IDataSourceStatusProvider& DataSourceStatus() override; + + /** + * Returns the version of the SDK. + * @return String representing version of the SDK. + */ + [[nodiscard]] static char const* Version(); + + private: + inline static char const* const kVersion = + "0.1.0"; // {x-release-please-version} + std::unique_ptr client; +}; + +} // namespace launchdarkly::server_side diff --git a/libs/server-sdk/include/launchdarkly/server_side/data_source_status.hpp b/libs/server-sdk/include/launchdarkly/server_side/data_source_status.hpp new file mode 100644 index 000000000..b0b540d55 --- /dev/null +++ b/libs/server-sdk/include/launchdarkly/server_side/data_source_status.hpp @@ -0,0 +1,116 @@ +#pragma once + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace launchdarkly::server_side::data_sources { + +/** + * Enumeration of possible data source states. + */ +enum class ServerDataSourceState { + /** + * The initial state of the data source when the SDK is being + * initialized. + * + * If it encounters an error that requires it to retry initialization, + * the state will remain at kInitializing until it either succeeds and + * becomes kValid, or permanently fails and becomes kShutdown. + */ + kInitializing = 0, + + /** + * Indicates that the data source is currently operational and has not + * had any problems since the last time it received data. + * + * In streaming mode, this means that there is currently an open stream + * connection and that at least one initial message has been received on + * the stream. In polling mode, it means that the last poll request + * succeeded. + */ + kValid = 1, + + /** + * Indicates that the data source encountered an error that it will + * attempt to recover from. + * + * In streaming mode, this means that the stream connection failed, or + * had to be dropped due to some other error, and will be retried after + * a backoff delay. In polling mode, it means that the last poll request + * failed, and a new poll request will be made after the configured + * polling interval. + */ + kInterrupted = 2, + + /** + * Indicates that the data source has been permanently shut down. + * + * This could be because it encountered an unrecoverable error (for + * instance, the LaunchDarkly service rejected the SDK key; an invalid + * SDK key will never become valid), or because the SDK client was + * explicitly closed. + */ + kOff = 3, +}; + +using DataSourceStatus = + common::data_sources::DataSourceStatusBase; + +/** + * Interface for accessing and listening to the data source status. + */ +class IDataSourceStatusProvider { + public: + /** + * The current status of the data source. Suitable for broadcast to + * data source status listeners. + */ + [[nodiscard]] virtual DataSourceStatus Status() const = 0; + + /** + * Listen to changes to the data source status. + * + * @param handler Function which will be called with the new status. + * @return A IConnection which can be used to stop listening to the status. + */ + virtual std::unique_ptr OnDataSourceStatusChange( + std::function handler) = 0; + + /** + * Listen to changes to the data source status, with ability for listener + * to unregister itself. + * + * @param handler Function which will be called with the new status. Return + * true to unregister. + * @return A IConnection which can be used to stop listening to the status. + */ + virtual std::unique_ptr OnDataSourceStatusChangeEx( + std::function handler) = 0; + + virtual ~IDataSourceStatusProvider() = default; + IDataSourceStatusProvider(IDataSourceStatusProvider const& item) = delete; + IDataSourceStatusProvider(IDataSourceStatusProvider&& item) = delete; + IDataSourceStatusProvider& operator=(IDataSourceStatusProvider const&) = + delete; + IDataSourceStatusProvider& operator=(IDataSourceStatusProvider&&) = delete; + + protected: + IDataSourceStatusProvider() = default; +}; + +std::ostream& operator<<(std::ostream& out, + DataSourceStatus::DataSourceState const& state); + +std::ostream& operator<<(std::ostream& out, DataSourceStatus const& status); + +} // namespace launchdarkly::server_side::data_sources diff --git a/libs/server-sdk/include/launchdarkly/server_side/integrations/persistent_store_core.hpp b/libs/server-sdk/include/launchdarkly/server_side/integrations/persistent_store_core.hpp new file mode 100644 index 000000000..a49f389be --- /dev/null +++ b/libs/server-sdk/include/launchdarkly/server_side/integrations/persistent_store_core.hpp @@ -0,0 +1,214 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +namespace launchdarkly::server_side::integrations { + +/** + * A versioned item which can be stored in a persistent store. + */ +struct SerializedItemDescriptor { + uint64_t version; + + /** + * During an Init/Upsert, when this is true, the serializedItem will + * contain a tombstone representation. If the persistence implementation + * can efficiently store the deletion state, and version, then it may + * choose to discard the item. + */ + bool deleted; + + /** + * When reading from a persistent store the serializedItem may be + * std::nullopt for deleted items. + */ + std::optional serializedItem; +}; + +/** + * Represents a namespace of persistent data. + */ +class IPersistentKind { + public: + /** + * The namespace for the data. + */ + [[nodiscard]] virtual std::string const& Namespace(); + + /** + * Deserialize data and return the version of the data. + * + * This is for cases where the persistent store cannot avoid deserializing + * data to determine its version. For instance a Redis store where + * the only columns are the prefixed key and the serialized data. + * + * @param data The data to deserialize. + * @return The version of the data. + */ + [[nodiscard]] virtual uint64_t Version(std::string const& data); + + IPersistentKind(IPersistentKind const& item) = delete; + IPersistentKind(IPersistentKind&& item) = delete; + IPersistentKind& operator=(IPersistentKind const&) = delete; + IPersistentKind& operator=(IPersistentKind&&) = delete; + virtual ~IPersistentKind() = default; + + protected: + IPersistentKind() = default; +}; + +/** + * Interface for a data store that holds feature flags and related data in a + * serialized form. + * + * This interface should be used for database integrations, or any other data + * store implementation that stores data in some external service. + * The SDK will take care of converting between its own internal data model and + * a serialized string form; the data store interacts only with the serialized + * form. + * + * The SDK will also provide its own caching layer on top of the persistent data + * store; the data store implementation should not provide caching, but simply + * do every query or update that the SDK tells it to do. + * + * Implementations must be thread-safe. + */ +class IPersistentStoreCore { + public: + enum class InitResult { + /** + * The init operation completed successfully. + */ + kSuccess, + + /** + * There was an error with the init operation. + */ + kError, + }; + + enum class UpsertResult { + /** + * The upsert completed successfully. + */ + kSuccess, + + /** + * There was an error with the upsert operation. + */ + kError, + + /** + * The upsert did not encounter errors, but the version of the + * existing item was greater than that the version of the upsert item. + */ + kNotUpdated + }; + + struct Error { + std::string message; + }; + + using GetResult = + tl::expected, Error>; + + using AllResult = + tl::expected, + Error>; + + using ItemKey = std::string; + using KeyItemPair = std::pair; + using OrderedNamepace = std::vector; + using KindCollectionPair = + std::pair; + using OrderedData = std::vector; + + /** + * Overwrites the store's contents with a set of items for each collection. + * + * All previous data should be discarded, regardless of versioning. + * + * The update should be done atomically. If it cannot be done atomically, + * then the store must first add or update each item in the same order that + * they are given in the input data, and then delete any previously stored + * items that were not in the input data. + * + * @param allData The ordered set of data to replace all current data with. + * @return The status of the init operation. + */ + virtual InitResult Init(OrderedData const& allData) = 0; + + /** + * Updates or inserts an item in the specified collection. For updates, the + * object will only be updated if the existing version is less than the new + * version. + * + * @param kind The collection kind to use. + * @param itemKey The unique key for the item within the collection. + * @param item The item to insert or update. + * + * @return The status of the operation. + */ + virtual UpsertResult Upsert(IPersistentKind const& kind, + std::string const& itemKey, + SerializedItemDescriptor const& item) = 0; + + /** + * Retrieves an item from the specified collection, if available. + * + * @param kind The kind of the item. + * @param itemKey The key for the item. + * @return A serialized item descriptor if the item existed, a std::nullopt + * if the item did not exist, or an error. For a deleted item the serialized + * item descriptor may contain a std::nullopt for the serializedItem. + */ + virtual GetResult Get(IPersistentKind const& kind, + std::string const& itemKey) const = 0; + + /** + * Retrieves all items from the specified collection. + * + * If the store contains placeholders for deleted items, it should include + * them in the results, not filter them out. + * @param kind The kind of data to get. + * @return Either all of the items of the type, or an error. If there are + * no items of the specified type, then return an empty collection. + */ + virtual AllResult All(IPersistentKind const& kind) const = 0; + + /** + * Returns true if this store has been initialized. + * + * In a shared data store, the implementation should be able to detect this + * state even if Init was called in a different process, i.e. it must query + * the underlying data store in some way. The method does not need to worry + * about caching this value; the SDK will call it rarely. + * + * @return True if the store has been initialized. + */ + virtual bool Initialized() const = 0; + + /** + * A short description of the store, for instance "Redis". May be used + * in diagnostic information and logging. + * + * @return A short description of the sore. + */ + virtual std::string const& Description() const = 0; + + IPersistentStoreCore(IPersistentStoreCore const& item) = delete; + IPersistentStoreCore(IPersistentStoreCore&& item) = delete; + IPersistentStoreCore& operator=(IPersistentStoreCore const&) = delete; + IPersistentStoreCore& operator=(IPersistentStoreCore&&) = delete; + virtual ~IPersistentStoreCore() = default; + + protected: + IPersistentStoreCore() = default; +}; +} // namespace launchdarkly::server_side::integrations diff --git a/libs/server-sdk/include/launchdarkly/server_side/serialization/json_all_flags_state.hpp b/libs/server-sdk/include/launchdarkly/server_side/serialization/json_all_flags_state.hpp new file mode 100644 index 000000000..b644119a1 --- /dev/null +++ b/libs/server-sdk/include/launchdarkly/server_side/serialization/json_all_flags_state.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include + +#include + +namespace launchdarkly::server_side { + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + server_side::AllFlagsState::State const& state); + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + server_side::AllFlagsState const& state); +} // namespace launchdarkly::server_side diff --git a/libs/server-sdk/src/CMakeLists.txt b/libs/server-sdk/src/CMakeLists.txt new file mode 100644 index 000000000..f3afa8aab --- /dev/null +++ b/libs/server-sdk/src/CMakeLists.txt @@ -0,0 +1,86 @@ + +file(GLOB HEADER_LIST CONFIGURE_DEPENDS + "${LaunchDarklyCPPServer_SOURCE_DIR}/include/launchdarkly/server_side/*.hpp" + "${LaunchDarklyCPPServer_SOURCE_DIR}/include/launchdarkly/server_side/integrations/*.hpp" + ) + +# Automatic library: static or dynamic based on user config. + +add_library(${LIBNAME} + ${HEADER_LIST} + boost.cpp + client.cpp + client_impl.cpp + all_flags_state/all_flags_state.cpp + all_flags_state/json_all_flags_state.cpp + all_flags_state/all_flags_state_builder.cpp + data_sources/data_source_update_sink.hpp + data_store/data_store.hpp + data_store/data_store_updater.hpp + data_store/data_store_updater.cpp + data_store/memory_store.cpp + data_store/dependency_tracker.hpp + data_store/dependency_tracker.cpp + data_store/descriptors.hpp + data_sources/data_source_event_handler.cpp + data_sources/data_source_event_handler.hpp + data_sources/data_source_status.cpp + data_sources/polling_data_source.hpp + data_sources/polling_data_source.cpp + data_sources/data_source_status_manager.hpp + data_sources/streaming_data_source.hpp + data_sources/streaming_data_source.cpp + data_sources/null_data_source.cpp + evaluation/evaluator.cpp + evaluation/rules.cpp + evaluation/bucketing.cpp + evaluation/operators.cpp + evaluation/evaluation_error.cpp + evaluation/detail/evaluation_stack.cpp + evaluation/detail/semver_operations.cpp + evaluation/detail/timestamp_operations.cpp + data_store/persistent/persistent_data_store.hpp + data_store/persistent/expiration_tracker.hpp + data_store/persistent/persistent_data_store.cpp + data_store/persistent/expiration_tracker.cpp + events/event_factory.cpp + ) + +if (MSVC OR (NOT BUILD_SHARED_LIBS)) + target_link_libraries(${LIBNAME} + PUBLIC launchdarkly::common + PRIVATE Boost::headers Boost::json Boost::url launchdarkly::sse launchdarkly::internal foxy timestamp) +else () + # The default static lib builds, for linux, are position independent. + # So they do not link into a shared object without issues. So, when + # building shared objects do not link the static libraries and instead + # use the "src.hpp" files for required libraries. + # macOS shares the same path for simplicity. + target_link_libraries(${LIBNAME} + PUBLIC launchdarkly::common + PRIVATE Boost::headers launchdarkly::sse launchdarkly::internal foxy timestamp) + + target_sources(${LIBNAME} PRIVATE boost.cpp) +endif () + +add_library(launchdarkly::server ALIAS ${LIBNAME}) + +set_property(TARGET ${LIBNAME} PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") + +install(TARGETS ${LIBNAME}) +if (BUILD_SHARED_LIBS AND MSVC) + install(FILES $ DESTINATION bin OPTIONAL) +endif () +# Using PUBLIC_HEADERS would flatten the include. +# This will preserve it, but dependencies must do the same. + +install(DIRECTORY "${LaunchDarklyCPPServer_SOURCE_DIR}/include/launchdarkly" + DESTINATION "include" + ) + +# Need the public headers to build. +target_include_directories(${LIBNAME} PUBLIC ../include) + +# Minimum C++ standard needed for consuming the public API is C++17. +target_compile_features(${LIBNAME} PUBLIC cxx_std_17) diff --git a/libs/server-sdk/src/all_flags_state/all_flags_state.cpp b/libs/server-sdk/src/all_flags_state/all_flags_state.cpp new file mode 100644 index 000000000..d2b1254ab --- /dev/null +++ b/libs/server-sdk/src/all_flags_state/all_flags_state.cpp @@ -0,0 +1,86 @@ +#include "launchdarkly/server_side/all_flags_state.hpp" + +namespace launchdarkly::server_side { + +AllFlagsState::State::State( + std::uint64_t version, + std::optional variation, + std::optional reason, + bool track_events, + bool track_reason, + std::optional debug_events_until_date) + : version_(version), + variation_(variation), + reason_(reason), + track_events_(track_events), + track_reason_(track_reason), + debug_events_until_date_(debug_events_until_date), + omit_details_(false) {} + +std::uint64_t AllFlagsState::State::Version() const { + return version_; +} + +std::optional AllFlagsState::State::Variation() const { + return variation_; +} + +std::optional const& AllFlagsState::State::Reason() const { + return reason_; +} + +bool AllFlagsState::State::TrackEvents() const { + return track_events_; +} + +bool AllFlagsState::State::TrackReason() const { + return track_reason_; +} + +std::optional const& AllFlagsState::State::DebugEventsUntilDate() + const { + return debug_events_until_date_; +} + +bool AllFlagsState::State::OmitDetails() const { + return omit_details_; +} + +AllFlagsState::AllFlagsState() + : valid_(false), evaluations_(), flags_state_() {} + +AllFlagsState::AllFlagsState(std::unordered_map evaluations, + std::unordered_map flags_state) + : valid_(true), + evaluations_(std::move(evaluations)), + flags_state_(std::move(flags_state)) {} + +bool AllFlagsState::Valid() const { + return valid_; +} + +std::unordered_map const& +AllFlagsState::States() const { + return flags_state_; +} + +std::unordered_map const& AllFlagsState::Values() const { + return evaluations_; +} + +bool operator==(AllFlagsState const& lhs, AllFlagsState const& rhs) { + return lhs.Valid() == rhs.Valid() && lhs.Values() == rhs.Values() && + lhs.States() == rhs.States(); +} + +bool operator==(AllFlagsState::State const& lhs, + AllFlagsState::State const& rhs) { + return lhs.Version() == rhs.Version() && + lhs.Variation() == rhs.Variation() && lhs.Reason() == rhs.Reason() && + lhs.TrackEvents() == rhs.TrackEvents() && + lhs.TrackReason() == rhs.TrackReason() && + lhs.DebugEventsUntilDate() == rhs.DebugEventsUntilDate() && + lhs.OmitDetails() == rhs.OmitDetails(); +} + +} // namespace launchdarkly::server_side diff --git a/libs/server-sdk/src/all_flags_state/all_flags_state_builder.cpp b/libs/server-sdk/src/all_flags_state/all_flags_state_builder.cpp new file mode 100644 index 000000000..efa4314b6 --- /dev/null +++ b/libs/server-sdk/src/all_flags_state/all_flags_state_builder.cpp @@ -0,0 +1,71 @@ +#include "all_flags_state_builder.hpp" + +namespace launchdarkly::server_side { + +bool IsDebuggingEnabled(std::optional debug_events_until); + +AllFlagsStateBuilder::AllFlagsStateBuilder(AllFlagsState::Options options) + : options_(options), flags_state_(), evaluations_() {} + +void AllFlagsStateBuilder::AddFlag(std::string const& key, + Value value, + AllFlagsState::State flag) { + if (IsSet(options_, AllFlagsState::Options::DetailsOnlyForTrackedFlags)) { + if (!flag.TrackEvents() && !flag.TrackReason() && + !IsDebuggingEnabled(flag.DebugEventsUntilDate())) { + flag.omit_details_ = true; + } + } + if (NotSet(options_, AllFlagsState::Options::IncludeReasons) && + !flag.TrackReason()) { + flag.reason_ = std::nullopt; + } + flags_state_.emplace(key, std::move(flag)); + evaluations_.emplace(std::move(key), std::move(value)); +} + +AllFlagsState AllFlagsStateBuilder::Build() { + return AllFlagsState{std::move(evaluations_), std::move(flags_state_)}; +} + +bool IsExperimentationEnabled(data_model::Flag const& flag, + std::optional const& reason) { + if (!reason) { + return false; + } + if (reason->InExperiment()) { + return true; + } + switch (reason->Kind()) { + case EvaluationReason::Kind::kFallthrough: + return flag.trackEventsFallthrough; + case EvaluationReason::Kind::kRuleMatch: + if (!reason->RuleIndex() || + reason->RuleIndex() >= flag.rules.size()) { + return false; + } + return flag.rules.at(*reason->RuleIndex()).trackEvents; + default: + return false; + } +} + +bool IsSet(AllFlagsState::Options options, AllFlagsState::Options flag) { + return (options & flag) == flag; +} + +bool NotSet(AllFlagsState::Options options, AllFlagsState::Options flag) { + return !IsSet(options, flag); +} + +std::uint64_t NowUnixMillis() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +bool IsDebuggingEnabled(std::optional debug_events_until) { + return debug_events_until && *debug_events_until > NowUnixMillis(); +} + +} // namespace launchdarkly::server_side diff --git a/libs/server-sdk/src/all_flags_state/all_flags_state_builder.hpp b/libs/server-sdk/src/all_flags_state/all_flags_state_builder.hpp new file mode 100644 index 000000000..34e1a839b --- /dev/null +++ b/libs/server-sdk/src/all_flags_state/all_flags_state_builder.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include + +#include "../data_store/data_store.hpp" +#include "../evaluation/evaluator.hpp" + +namespace launchdarkly::server_side { + +bool IsSet(AllFlagsState::Options options, AllFlagsState::Options flag); +bool NotSet(AllFlagsState::Options options, AllFlagsState::Options flag); + +class AllFlagsStateBuilder { + public: + /** + * Constructs a builder capable of generating a AllFlagsState structure. + * @param options Options affecting the behavior of the builder. + */ + AllFlagsStateBuilder(AllFlagsState::Options options); + + /** + * Adds a flag, including its evaluation result and additional state. + * @param key Key of the flag. + * @param value Value of the flag. + * @param state State of the flag. + */ + void AddFlag(std::string const& key, + Value value, + AllFlagsState::State state); + + /** + * Builds a AllFlagsState structure from the flags added to the builder. + * This operation consumes the builder, and must only be called once. + * @return + */ + [[nodiscard]] AllFlagsState Build(); + + private: + enum AllFlagsState::Options options_; + std::unordered_map flags_state_; + std::unordered_map evaluations_; +}; +} // namespace launchdarkly::server_side diff --git a/libs/server-sdk/src/all_flags_state/json_all_flags_state.cpp b/libs/server-sdk/src/all_flags_state/json_all_flags_state.cpp new file mode 100644 index 000000000..222ac27f7 --- /dev/null +++ b/libs/server-sdk/src/all_flags_state/json_all_flags_state.cpp @@ -0,0 +1,56 @@ +#include +#include +#include + +#include +#include + +namespace launchdarkly::server_side { + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + server_side::AllFlagsState::State const& state) { + boost::ignore_unused(unused); + auto& obj = json_value.emplace_object(); + + if (!state.OmitDetails()) { + obj.emplace("version", state.Version()); + + if (auto const& reason = state.Reason()) { + obj.emplace("reason", boost::json::value_from(*reason)); + } + } + + if (auto const& variation = state.Variation()) { + obj.emplace("variation", *variation); + } + + if (state.TrackEvents()) { + obj.emplace("trackEvents", true); + } + + if (state.TrackReason()) { + obj.emplace("trackReason", true); + } + + if (auto const& date = state.DebugEventsUntilDate()) { + if (*date > 0) { + obj.emplace("debugEventsUntilDate", boost::json::value_from(*date)); + } + } +} + +void tag_invoke(boost::json::value_from_tag const& unused, + boost::json::value& json_value, + server_side::AllFlagsState const& state) { + boost::ignore_unused(unused); + auto& obj = json_value.emplace_object(); + obj.emplace("$valid", state.Valid()); + + obj.emplace("$flagsState", boost::json::value_from(state.States())); + + for (auto const& [k, v] : state.Values()) { + obj.emplace(k, boost::json::value_from(v)); + } +} +} // namespace launchdarkly::server_side diff --git a/libs/server-sdk/src/boost.cpp b/libs/server-sdk/src/boost.cpp new file mode 100644 index 000000000..5c9ea02b1 --- /dev/null +++ b/libs/server-sdk/src/boost.cpp @@ -0,0 +1,5 @@ +// This file is used to include boost url/json when building a shared library on linux/mac. +// Windows links static libs in this case and does not include these src files, as there +// are issues compiling the value.ipp file from JSON with MSVC. +#include +#include diff --git a/libs/server-sdk/src/client.cpp b/libs/server-sdk/src/client.cpp new file mode 100644 index 000000000..ce992de85 --- /dev/null +++ b/libs/server-sdk/src/client.cpp @@ -0,0 +1,135 @@ +#include + +#include "client_impl.hpp" + +namespace launchdarkly::server_side { + +void operator|=(AllFlagsState::Options& lhs, AllFlagsState::Options rhs) { + lhs = lhs | rhs; +} + +AllFlagsState::Options operator|(AllFlagsState::Options lhs, + AllFlagsState::Options rhs) { + return static_cast( + static_cast>(lhs) | + static_cast>(rhs)); +} + +AllFlagsState::Options operator&(AllFlagsState::Options lhs, + AllFlagsState::Options rhs) { + return static_cast( + static_cast>(lhs) & + static_cast>(rhs)); +} + +Client::Client(Config config) + : client(std::make_unique(std::move(config), kVersion)) {} + +bool Client::Initialized() const { + return client->Initialized(); +} + +std::future Client::StartAsync() { + return client->StartAsync(); +} + +using FlagKey = std::string; +[[nodiscard]] AllFlagsState Client::AllFlagsState( + Context const& context, + enum AllFlagsState::Options options) { + return client->AllFlagsState(context, options); +} + +void Client::Track(Context const& ctx, + std::string event_name, + Value data, + double metric_value) { + client->Track(ctx, std::move(event_name), std::move(data), metric_value); +} + +void Client::Track(Context const& ctx, std::string event_name, Value data) { + client->Track(ctx, std::move(event_name), std::move(data)); +} + +void Client::Track(Context const& ctx, std::string event_name) { + client->Track(ctx, std::move(event_name)); +} + +void Client::FlushAsync() { + client->FlushAsync(); +} + +void Client::Identify(Context context) { + return client->Identify(std::move(context)); +} + +bool Client::BoolVariation(Context const& ctx, + FlagKey const& key, + bool default_value) { + return client->BoolVariation(ctx, key, default_value); +} + +EvaluationDetail Client::BoolVariationDetail(Context const& ctx, + FlagKey const& key, + bool default_value) { + return client->BoolVariationDetail(ctx, key, default_value); +} + +std::string Client::StringVariation(Context const& ctx, + FlagKey const& key, + std::string default_value) { + return client->StringVariation(ctx, key, std::move(default_value)); +} + +EvaluationDetail Client::StringVariationDetail( + Context const& ctx, + FlagKey const& key, + std::string default_value) { + return client->StringVariationDetail(ctx, key, std::move(default_value)); +} + +double Client::DoubleVariation(Context const& ctx, + FlagKey const& key, + double default_value) { + return client->DoubleVariation(ctx, key, default_value); +} + +EvaluationDetail Client::DoubleVariationDetail(Context const& ctx, + FlagKey const& key, + double default_value) { + return client->DoubleVariationDetail(ctx, key, default_value); +} + +int Client::IntVariation(Context const& ctx, + FlagKey const& key, + int default_value) { + return client->IntVariation(ctx, key, default_value); +} + +EvaluationDetail Client::IntVariationDetail(Context const& ctx, + FlagKey const& key, + int default_value) { + return client->IntVariationDetail(ctx, key, default_value); +} + +Value Client::JsonVariation(Context const& ctx, + FlagKey const& key, + Value default_value) { + return client->JsonVariation(ctx, key, std::move(default_value)); +} + +EvaluationDetail Client::JsonVariationDetail(Context const& ctx, + FlagKey const& key, + Value default_value) { + return client->JsonVariationDetail(ctx, key, std::move(default_value)); +} + +data_sources::IDataSourceStatusProvider& Client::DataSourceStatus() { + return client->DataSourceStatus(); +} + +char const* Client::Version() { + return kVersion; +} + +} // namespace launchdarkly::server_side diff --git a/libs/server-sdk/src/client_impl.cpp b/libs/server-sdk/src/client_impl.cpp new file mode 100644 index 000000000..5d3f9c149 --- /dev/null +++ b/libs/server-sdk/src/client_impl.cpp @@ -0,0 +1,459 @@ + +#include + +#include +#include + +#include "client_impl.hpp" + +#include "all_flags_state/all_flags_state_builder.hpp" +#include "data_sources/null_data_source.hpp" +#include "data_sources/polling_data_source.hpp" +#include "data_sources/streaming_data_source.hpp" +#include "data_store/memory_store.hpp" + +#include +#include +#include +#include +#include + +namespace launchdarkly::server_side { + +// The ASIO implementation assumes that the io_context will be run from a +// single thread, and applies several optimisations based on this +// assumption. +auto const kAsioConcurrencyHint = 1; + +// Client's destructor attempts to gracefully shut down the datasource +// connection in this amount of time. +auto const kDataSourceShutdownWait = std::chrono::milliseconds(100); + +using config::shared::ServerSDK; +using launchdarkly::config::shared::built::DataSourceConfig; +using launchdarkly::config::shared::built::HttpProperties; +using launchdarkly::server_side::data_sources::DataSourceStatus; + +static std::shared_ptr<::launchdarkly::data_sources::IDataSource> +MakeDataSource(HttpProperties const& http_properties, + Config const& config, + boost::asio::any_io_executor const& executor, + data_sources::IDataSourceUpdateSink& flag_updater, + data_sources::DataSourceStatusManager& status_manager, + Logger& logger) { + if (config.Offline()) { + return std::make_shared(executor, + status_manager); + } + + auto builder = HttpPropertiesBuilder(http_properties); + + auto data_source_properties = builder.Build(); + + if (config.DataSourceConfig().method.index() == 0) { + // TODO: use initial reconnect delay. + return std::make_shared< + launchdarkly::server_side::data_sources::StreamingDataSource>( + config.ServiceEndpoints(), config.DataSourceConfig(), + data_source_properties, executor, flag_updater, status_manager, + logger); + } + return std::make_shared< + launchdarkly::server_side::data_sources::PollingDataSource>( + config.ServiceEndpoints(), config.DataSourceConfig(), + data_source_properties, executor, flag_updater, status_manager, logger); +} + +static Logger MakeLogger(config::shared::built::Logging const& config) { + if (config.disable_logging) { + return {std::make_shared()}; + } + if (config.backend) { + return {config.backend}; + } + return { + std::make_shared(config.level, config.tag)}; +} + +bool EventsEnabled(Config const& config) { + return config.Events().Enabled() && !config.Offline(); +} + +std::unique_ptr> MakeEventProcessor( + Config const& config, + boost::asio::any_io_executor const& exec, + HttpProperties const& http_properties, + Logger& logger) { + if (EventsEnabled(config)) { + return std::make_unique>( + exec, config.ServiceEndpoints(), config.Events(), http_properties, + logger); + } + return nullptr; +} + +/** + * Returns true if the flag pointer is valid and the underlying item is present. + */ +bool IsFlagPresent( + std::shared_ptr const& flag_desc); + +ClientImpl::ClientImpl(Config config, std::string const& version) + : config_(config), + http_properties_( + HttpPropertiesBuilder(config.HttpProperties()) + .Header("user-agent", "CPPClient/" + version) + .Header("authorization", config.SdkKey()) + .Header("x-launchdarkly-tags", config.ApplicationTag()) + .Build()), + logger_(MakeLogger(config.Logging())), + ioc_(kAsioConcurrencyHint), + work_(boost::asio::make_work_guard(ioc_)), + memory_store_(), + status_manager_(), + data_store_updater_(memory_store_, memory_store_), + data_source_(MakeDataSource(http_properties_, + config_, + ioc_.get_executor(), + data_store_updater_, + status_manager_, + logger_)), + event_processor_(MakeEventProcessor(config, + ioc_.get_executor(), + http_properties_, + logger_)), + evaluator_(logger_, memory_store_), + events_default_(event_processor_.get(), EventFactory::WithoutReasons()), + events_with_reasons_(event_processor_.get(), + EventFactory::WithReasons()) { + run_thread_ = std::move(std::thread([&]() { ioc_.run(); })); +} + +// TODO: audit if this is correct for server +// Was an attempt made to initialize the data source, and did that attempt +// succeed? The data source being connected, or not being connected due to +// offline mode, both represent successful terminal states. +static bool IsInitializedSuccessfully(DataSourceStatus::DataSourceState state) { + return state == DataSourceStatus::DataSourceState::kValid; +} + +// TODO: audit if this is correct for server +// Was any attempt made to initialize the data source (with a successful or +// permanent failure outcome?) +static bool IsInitialized(DataSourceStatus::DataSourceState state) { + return IsInitializedSuccessfully(state) || + (state == DataSourceStatus::DataSourceState::kOff); +} + +void ClientImpl::Identify(Context context) { + events_default_.Send([&](EventFactory const& factory) { + return factory.Identify(std::move(context)); + }); +} + +std::future ClientImpl::StartAsyncInternal( + std::function result_predicate) { + auto pr = std::make_shared>(); + auto fut = pr->get_future(); + + status_manager_.OnDataSourceStatusChangeEx( + [result_predicate, pr](data_sources::DataSourceStatus status) { + auto state = status.State(); + if (IsInitialized(state)) { + pr->set_value(result_predicate(status.State())); + return true; /* delete this change listener since the + desired state was reached */ + } + return false; /* keep the change listener */ + }); + + data_source_->Start(); + + return fut; +} + +std::future ClientImpl::StartAsync() { + return StartAsyncInternal(IsInitializedSuccessfully); +} + +bool ClientImpl::Initialized() const { + return IsInitializedSuccessfully(status_manager_.Status().State()); +} + +AllFlagsState ClientImpl::AllFlagsState(Context const& context, + AllFlagsState::Options options) { + std::unordered_map result; + + if (!Initialized()) { + if (memory_store_.Initialized()) { + LD_LOG(logger_, LogLevel::kWarn) + << "AllFlagsState() called before client has finished " + "initializing; using last known values from data store"; + } else { + LD_LOG(logger_, LogLevel::kWarn) + << "AllFlagsState() called before client has finished " + "initializing. Data store not available. Returning empty " + "state"; + return {}; + } + } + + AllFlagsStateBuilder builder{options}; + + EventScope no_events; + + for (auto const& [k, v] : memory_store_.AllFlags()) { + if (!v || !v->item) { + continue; + } + + auto const& flag = *(v->item); + + if (IsSet(options, AllFlagsState::Options::ClientSideOnly) && + !flag.clientSideAvailability.usingEnvironmentId) { + continue; + } + + EvaluationDetail detail = + evaluator_.Evaluate(flag, context, no_events); + + bool in_experiment = flag.IsExperimentationEnabled(detail.Reason()); + builder.AddFlag(k, detail.Value(), + AllFlagsState::State{ + flag.Version(), detail.VariationIndex(), + detail.Reason(), flag.trackEvents || in_experiment, + in_experiment, flag.debugEventsUntilDate}); + } + + return builder.Build(); +} + +void ClientImpl::TrackInternal(Context const& ctx, + std::string event_name, + std::optional data, + std::optional metric_value) { + events_default_.Send([&](EventFactory const& factory) { + return factory.Custom(ctx, std::move(event_name), std::move(data), + metric_value); + }); +} + +void ClientImpl::Track(Context const& ctx, + std::string event_name, + Value data, + double metric_value) { + this->TrackInternal(ctx, std::move(event_name), std::move(data), + metric_value); +} + +void ClientImpl::Track(Context const& ctx, std::string event_name, Value data) { + this->TrackInternal(ctx, std::move(event_name), std::move(data), + std::nullopt); +} + +void ClientImpl::Track(Context const& ctx, std::string event_name) { + this->TrackInternal(ctx, std::move(event_name), std::nullopt, std::nullopt); +} + +void ClientImpl::FlushAsync() { + if (event_processor_) { + event_processor_->FlushAsync(); + } +} + +void ClientImpl::LogVariationCall(std::string const& key, + bool flag_present) const { + if (Initialized()) { + if (!flag_present) { + LD_LOG(logger_, LogLevel::kInfo) << "Unknown feature flag " << key + << "; returning default value"; + } + } else { + if (flag_present) { + LD_LOG(logger_, LogLevel::kInfo) + << "LaunchDarkly client has not yet been initialized; using " + "last " + "known flag rules from data store"; + } else { + LD_LOG(logger_, LogLevel::kInfo) + << "LaunchDarkly client has not yet been initialized; " + "returning default value"; + } + } +} + +Value ClientImpl::Variation(Context const& ctx, + enum Value::Type value_type, + IClient::FlagKey const& key, + Value const& default_value) { + auto result = *VariationInternal(ctx, key, default_value, events_default_); + if (result.Type() != value_type) { + return default_value; + } + return result; +} + +EvaluationDetail ClientImpl::VariationInternal( + Context const& context, + IClient::FlagKey const& key, + Value const& default_value, + EventScope const& event_scope) { + if (auto error = PreEvaluationChecks(context)) { + return PostEvaluation(key, context, default_value, *error, event_scope, + std::nullopt); + } + + auto flag_rule = memory_store_.GetFlag(key); + + bool flag_present = IsFlagPresent(flag_rule); + + LogVariationCall(key, flag_present); + + if (!flag_present) { + return PostEvaluation(key, context, default_value, + EvaluationReason::ErrorKind::kFlagNotFound, + event_scope, std::nullopt); + } + + EvaluationDetail result = + evaluator_.Evaluate(*flag_rule->item, context, event_scope); + return PostEvaluation(key, context, default_value, result, event_scope, + flag_rule.get()->item); +} + +std::optional ClientImpl::PreEvaluationChecks( + Context const& context) { + if (!memory_store_.Initialized()) { + return EvaluationReason::ErrorKind::kClientNotReady; + } + if (!context.Valid()) { + return EvaluationReason::ErrorKind::kUserNotSpecified; + } + return std::nullopt; +} + +EvaluationDetail ClientImpl::PostEvaluation( + std::string const& key, + Context const& context, + Value const& default_value, + std::variant> + error_or_detail, + EventScope const& event_scope, + std::optional const& flag) { + return std::visit( + [&](auto&& arg) { + using T = std::decay_t; + // VARIANT: ErrorKind + if constexpr (std::is_same_v) { + auto detail = EvaluationDetail{arg, default_value}; + + event_scope.Send([&](EventFactory const& factory) { + return factory.UnknownFlag(key, context, detail, + default_value); + }); + + return detail; + } + // VARIANT: EvaluationDetail + else if constexpr (std::is_same_v>) { + auto detail = EvaluationDetail{ + (!arg.VariationIndex() ? default_value : arg.Value()), + arg.VariationIndex(), arg.Reason()}; + + event_scope.Send([&](EventFactory const& factory) { + return factory.Eval(key, context, flag, detail, + default_value, std::nullopt); + }); + + return detail; + } + }, + std::move(error_or_detail)); +} + +bool IsFlagPresent( + std::shared_ptr const& flag_desc) { + return flag_desc && flag_desc->item; +} + +EvaluationDetail ClientImpl::BoolVariationDetail( + Context const& ctx, + IClient::FlagKey const& key, + bool default_value) { + return VariationDetail(ctx, Value::Type::kBool, key, default_value); +} + +bool ClientImpl::BoolVariation(Context const& ctx, + IClient::FlagKey const& key, + bool default_value) { + return Variation(ctx, Value::Type::kBool, key, default_value); +} + +EvaluationDetail ClientImpl::StringVariationDetail( + Context const& ctx, + ClientImpl::FlagKey const& key, + std::string default_value) { + return VariationDetail(ctx, Value::Type::kString, key, + default_value); +} + +std::string ClientImpl::StringVariation(Context const& ctx, + IClient::FlagKey const& key, + std::string default_value) { + return Variation(ctx, Value::Type::kString, key, default_value); +} + +EvaluationDetail ClientImpl::DoubleVariationDetail( + Context const& ctx, + ClientImpl::FlagKey const& key, + double default_value) { + return VariationDetail(ctx, Value::Type::kNumber, key, + default_value); +} + +double ClientImpl::DoubleVariation(Context const& ctx, + IClient::FlagKey const& key, + double default_value) { + return Variation(ctx, Value::Type::kNumber, key, default_value); +} + +EvaluationDetail ClientImpl::IntVariationDetail( + Context const& ctx, + IClient::FlagKey const& key, + int default_value) { + return VariationDetail(ctx, Value::Type::kNumber, key, default_value); +} + +int ClientImpl::IntVariation(Context const& ctx, + IClient::FlagKey const& key, + int default_value) { + return Variation(ctx, Value::Type::kNumber, key, default_value); +} + +EvaluationDetail ClientImpl::JsonVariationDetail( + Context const& ctx, + IClient::FlagKey const& key, + Value default_value) { + return VariationInternal(ctx, key, default_value, events_with_reasons_); +} + +Value ClientImpl::JsonVariation(Context const& ctx, + IClient::FlagKey const& key, + Value default_value) { + return *VariationInternal(ctx, key, default_value, events_default_); +} + +data_sources::IDataSourceStatusProvider& ClientImpl::DataSourceStatus() { + return status_manager_; +} + +// flag_manager::IFlagNotifier& ClientImpl::FlagNotifier() { +// return flag_manager_.Notifier(); +// } + +ClientImpl::~ClientImpl() { + ioc_.stop(); + // TODO: Probably not the best. + run_thread_.join(); +} +} // namespace launchdarkly::server_side diff --git a/libs/server-sdk/src/client_impl.hpp b/libs/server-sdk/src/client_impl.hpp new file mode 100644 index 000000000..d6c77ecee --- /dev/null +++ b/libs/server-sdk/src/client_impl.hpp @@ -0,0 +1,195 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "data_sources/data_source_status_manager.hpp" +#include "data_sources/data_source_update_sink.hpp" + +#include "data_store/data_store_updater.hpp" +#include "data_store/memory_store.hpp" + +#include "evaluation/evaluator.hpp" + +#include "events/event_scope.hpp" + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace launchdarkly::server_side { + +class ClientImpl : public IClient { + public: + ClientImpl(Config config, std::string const& version); + + ClientImpl(ClientImpl&&) = delete; + ClientImpl(ClientImpl const&) = delete; + ClientImpl& operator=(ClientImpl) = delete; + ClientImpl& operator=(ClientImpl&& other) = delete; + + bool Initialized() const override; + + using FlagKey = std::string; + [[nodiscard]] class AllFlagsState AllFlagsState( + Context const& context, + AllFlagsState::Options options = + AllFlagsState::Options::Default) override; + + void Track(Context const& ctx, + std::string event_name, + Value data, + double metric_value) override; + + void Track(Context const& ctx, std::string event_name, Value data) override; + + void Track(Context const& ctx, std::string event_name) override; + + void FlushAsync() override; + + void Identify(Context context) override; + + bool BoolVariation(Context const& ctx, + FlagKey const& key, + bool default_value) override; + + EvaluationDetail BoolVariationDetail(Context const& ctx, + FlagKey const& key, + bool default_value) override; + + std::string StringVariation(Context const& ctx, + FlagKey const& key, + std::string default_value) override; + + EvaluationDetail StringVariationDetail( + Context const& ctx, + FlagKey const& key, + std::string default_value) override; + + double DoubleVariation(Context const& ctx, + FlagKey const& key, + double default_value) override; + + EvaluationDetail DoubleVariationDetail( + Context const& ctx, + FlagKey const& key, + double default_value) override; + + int IntVariation(Context const& ctx, + FlagKey const& key, + int default_value) override; + + EvaluationDetail IntVariationDetail(Context const& ctx, + FlagKey const& key, + int default_value) override; + + Value JsonVariation(Context const& ctx, + FlagKey const& key, + Value default_value) override; + + EvaluationDetail JsonVariationDetail(Context const& ctx, + FlagKey const& key, + Value default_value) override; + + data_sources::IDataSourceStatusProvider& DataSourceStatus() override; + + ~ClientImpl(); + + std::future StartAsync() override; + + private: + [[nodiscard]] EvaluationDetail VariationInternal( + Context const& ctx, + FlagKey const& key, + Value const& default_value, + EventScope const& scope); + + template + [[nodiscard]] EvaluationDetail VariationDetail( + Context const& ctx, + enum Value::Type value_type, + IClient::FlagKey const& key, + Value const& default_value) { + auto result = + VariationInternal(ctx, key, default_value, events_with_reasons_); + if (result.Value().Type() == value_type) { + return EvaluationDetail{result.Value(), result.VariationIndex(), + result.Reason()}; + } + return EvaluationDetail{EvaluationReason::ErrorKind::kWrongType, + default_value}; + } + + [[nodiscard]] Value Variation(Context const& ctx, + enum Value::Type value_type, + std::string const& key, + Value const& default_value); + + [[nodiscard]] EvaluationDetail PostEvaluation( + std::string const& key, + Context const& context, + Value const& default_value, + std::variant> + result, + EventScope const& event_scope, + std::optional const& flag); + + [[nodiscard]] std::optional + PreEvaluationChecks(Context const& context); + + void TrackInternal(Context const& ctx, + std::string event_name, + std::optional data, + std::optional metric_value); + + std::future StartAsyncInternal( + std::function + predicate); + + void LogVariationCall(std::string const& key, bool flag_present) const; + + Config config_; + Logger logger_; + + launchdarkly::config::shared::built::HttpProperties http_properties_; + + boost::asio::io_context ioc_; + boost::asio::executor_work_guard + work_; + + data_store::MemoryStore memory_store_; + + data_sources::DataSourceStatusManager status_manager_; + data_store::DataStoreUpdater data_store_updater_; + + std::shared_ptr<::launchdarkly::data_sources::IDataSource> data_source_; + + std::unique_ptr event_processor_; + + mutable std::mutex init_mutex_; + std::condition_variable init_waiter_; + + evaluation::Evaluator evaluator_; + + EventScope const events_default_; + EventScope const events_with_reasons_; + + std::thread run_thread_; +}; +} // namespace launchdarkly::server_side diff --git a/libs/server-sdk/src/data_sources/data_source_event_handler.cpp b/libs/server-sdk/src/data_sources/data_source_event_handler.cpp new file mode 100644 index 000000000..2d8c436b3 --- /dev/null +++ b/libs/server-sdk/src/data_sources/data_source_event_handler.cpp @@ -0,0 +1,241 @@ +#include "data_source_event_handler.hpp" + +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include + +#include "tl/expected.hpp" + +namespace launchdarkly::server_side::data_sources { + +static char const* const kErrorParsingPut = "Could not parse PUT message"; +static char const* const kErrorPutInvalid = + "PUT message contained invalid data"; +static char const* const kErrorParsingPatch = "Could not parse PATCH message"; +static char const* const kErrorPatchInvalid = + "PATCH message contained invalid data"; +static char const* const kErrorParsingDelete = "Could not parse DELETE message"; +static char const* const kErrorDeleteInvalid = + "DELETE message contained invalid data\""; + +template +tl::expected Patch( + std::string const& path, + boost::json::object const& obj) { + auto const* data_iter = obj.find("data"); + if (data_iter == obj.end()) { + return tl::unexpected(JsonError::kSchemaFailure); + } + auto data = + boost::json::value_to, JsonError>>( + data_iter->value()); + if (!data.has_value()) { + return tl::unexpected(JsonError::kSchemaFailure); + } + return DataSourceEventHandler::Patch{ + TStreamingDataKind::Key(path), + data_model::ItemDescriptor(data->value())}; +} + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, + JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + + if (!json_value.is_object()) { + return tl::unexpected(JsonError::kSchemaFailure); + } + + DataSourceEventHandler::Put put; + std::string path; + auto const& obj = json_value.as_object(); + PARSE_FIELD(path, obj, "path"); + // We don't know what to do with a path other than "/". + if (!(path == "/" || path.empty())) { + return std::nullopt; + } + PARSE_FIELD(put.data, obj, "data"); + + return put; +} + +tl::expected, JsonError> +tag_invoke(boost::json::value_to_tag< + tl::expected, + JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + if (!json_value.is_object()) { + return tl::unexpected(JsonError::kSchemaFailure); + } + + auto const& obj = json_value.as_object(); + + std::string path; + PARSE_FIELD(path, obj, "path"); + + if (StreamingDataKinds::Flag::IsKind(path)) { + return Patch(path, obj); + } + + if (StreamingDataKinds::Segment::IsKind(path)) { + return Patch(path, + obj); + } + + return std::nullopt; +} + +static tl::expected tag_invoke( + boost::json::value_to_tag< + tl::expected> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + + if (!json_value.is_object()) { + return tl::unexpected(JsonError::kSchemaFailure); + } + + auto const& obj = json_value.as_object(); + + DataSourceEventHandler::Delete del; + PARSE_REQUIRED_FIELD(del.version, obj, "version"); + std::string path; + PARSE_REQUIRED_FIELD(path, obj, "path"); + + auto kind = StreamingDataKinds::Kind(path); + auto key = StreamingDataKinds::Key(path); + + if (kind.has_value() && key.has_value()) { + del.kind = *kind; + del.key = *key; + return del; + } + return tl::unexpected(JsonError::kSchemaFailure); +} + +DataSourceEventHandler::DataSourceEventHandler( + IDataSourceUpdateSink& handler, + Logger const& logger, + DataSourceStatusManager& status_manager) + : handler_(handler), logger_(logger), status_manager_(status_manager) {} + +DataSourceEventHandler::MessageStatus DataSourceEventHandler::HandleMessage( + std::string const& type, + std::string const& data) { + if (type == "put") { + boost::json::error_code error_code; + auto parsed = boost::json::parse(data, error_code); + if (error_code) { + LD_LOG(logger_, LogLevel::kError) << kErrorParsingPut; + status_manager_.SetError( + DataSourceStatus::ErrorInfo::ErrorKind::kInvalidData, + kErrorParsingPut); + return DataSourceEventHandler::MessageStatus::kInvalidMessage; + } + auto res = + boost::json::value_to, JsonError>>( + parsed); + + if (!res) { + LD_LOG(logger_, LogLevel::kError) << kErrorPutInvalid; + status_manager_.SetError( + DataSourceStatus::ErrorInfo::ErrorKind::kInvalidData, + kErrorPutInvalid); + return DataSourceEventHandler::MessageStatus::kInvalidMessage; + } + + // Check the inner optional. + if (res->has_value()) { + handler_.Init(std::move((*res)->data)); + status_manager_.SetState(DataSourceStatus::DataSourceState::kValid); + return DataSourceEventHandler::MessageStatus::kMessageHandled; + } + return DataSourceEventHandler::MessageStatus::kMessageHandled; + } + if (type == "patch") { + boost::json::error_code error_code; + auto parsed = boost::json::parse(data, error_code); + if (error_code) { + LD_LOG(logger_, LogLevel::kError) << kErrorParsingPut; + status_manager_.SetError( + DataSourceStatus::ErrorInfo::ErrorKind::kInvalidData, + kErrorParsingPatch); + return DataSourceEventHandler::MessageStatus::kInvalidMessage; + } + + auto res = boost::json::value_to< + tl::expected, JsonError>>(parsed); + + if (!res.has_value()) { + status_manager_.SetError( + DataSourceStatus::ErrorInfo::ErrorKind::kInvalidData, + kErrorPatchInvalid); + return DataSourceEventHandler::MessageStatus::kInvalidMessage; + } + + // This references the optional inside the expected. + if (res->has_value()) { + auto const& patch = (**res); + auto const& key = patch.key; + std::visit([this, &key](auto&& arg) { handler_.Upsert(key, arg); }, + patch.data); + return DataSourceEventHandler::MessageStatus::kMessageHandled; + } + // We didn't recognize the type of the patch. So we ignore it. + return DataSourceEventHandler::MessageStatus::kMessageHandled; + } + if (type == "delete") { + boost::json::error_code error_code; + auto parsed = boost::json::parse(data, error_code); + if (error_code) { + LD_LOG(logger_, LogLevel::kError) << kErrorParsingDelete; + status_manager_.SetError( + DataSourceStatus::ErrorInfo::ErrorKind::kInvalidData, + kErrorParsingDelete); + return DataSourceEventHandler::MessageStatus::kInvalidMessage; + } + + auto res = + boost::json::value_to>(parsed); + + if (res.has_value()) { + switch (res->kind) { + case data_store::DataKind::kFlag: { + handler_.Upsert(res->key, + data_store::FlagDescriptor(res->version)); + return DataSourceEventHandler::MessageStatus:: + kMessageHandled; + } + case data_store::DataKind::kSegment: { + handler_.Upsert( + res->key, data_store::SegmentDescriptor(res->version)); + return DataSourceEventHandler::MessageStatus:: + kMessageHandled; + } + default: { + } break; + } + } + + status_manager_.SetError( + DataSourceStatus::ErrorInfo::ErrorKind::kInvalidData, + kErrorDeleteInvalid); + return DataSourceEventHandler::MessageStatus::kInvalidMessage; + } + + return DataSourceEventHandler::MessageStatus::kUnhandledVerb; +} + +} // namespace launchdarkly::server_side::data_sources diff --git a/libs/server-sdk/src/data_sources/data_source_event_handler.hpp b/libs/server-sdk/src/data_sources/data_source_event_handler.hpp new file mode 100644 index 000000000..798796506 --- /dev/null +++ b/libs/server-sdk/src/data_sources/data_source_event_handler.hpp @@ -0,0 +1,124 @@ +#pragma once + +#include + +#include + +#include "../data_store/data_kind.hpp" +#include "data_source_status_manager.hpp" +#include "data_source_update_sink.hpp" + +#include +#include +#include +#include +#include + +namespace launchdarkly::server_side::data_sources { + +// The FlagsPath and SegmentsPath are made to turn a string literal into a type +// for use in a template. +// You can use a char array as a const char* template +// parameter, but this causes a number of issues with the clang linter. + +struct FlagsPath { + static constexpr std::string_view path = "/flags/"; +}; + +struct SegmentsPath { + static constexpr std::string_view path = "/segments/"; +}; + +template +class StreamingDataKind { + public: + static data_store::DataKind Kind() { return kind; } + static bool IsKind(std::string const& patch_path) { + return patch_path.rfind(TPath::path) == 0; + } + static std::string Key(std::string const& patch_path) { + return patch_path.substr(TPath::path.size()); + } +}; + +struct StreamingDataKinds { + using Flag = StreamingDataKind; + using Segment = + StreamingDataKind; + + static std::optional Kind(std::string const& path) { + if (Flag::IsKind(path)) { + return data_store::DataKind::kFlag; + } + if (Segment::IsKind(path)) { + return data_store::DataKind::kSegment; + } + return std::nullopt; + } + + static std::optional Key(std::string const& path) { + if (Flag::IsKind(path)) { + return Flag::Key(path); + } + if (Segment::IsKind(path)) { + return Segment::Key(path); + } + return std::nullopt; + } +}; + +/** + * This class handles LaunchDarkly events, parses them, and then uses + * a IDataSourceUpdateSink to process the parsed events. + * + * This is only used for streaming. For server polling the shape of the poll + * response is different than the put, so there is limited utility in + * sharing this handler. + */ +class DataSourceEventHandler { + public: + /** + * Status indicating if the message was processed, or if there + * was an issue encountered. + */ + enum class MessageStatus { + kMessageHandled, + kInvalidMessage, + kUnhandledVerb + }; + + struct Put { + data_model::SDKDataSet data; + }; + + struct Patch { + std::string key; + std::variant + data; + }; + + struct Delete { + std::string key; + data_store::DataKind kind; + uint64_t version; + }; + + DataSourceEventHandler(IDataSourceUpdateSink& handler, + Logger const& logger, + DataSourceStatusManager& status_manager); + + /** + * Handles an event from the LaunchDarkly service. + * @param type The type of the event. "put"/"patch"/"delete". + * @param data The content of the event. + * @return A status indicating if the message could be handled. + */ + MessageStatus HandleMessage(std::string const& type, + std::string const& data); + + private: + IDataSourceUpdateSink& handler_; + Logger const& logger_; + DataSourceStatusManager& status_manager_; +}; +} // namespace launchdarkly::server_side::data_sources diff --git a/libs/server-sdk/src/data_sources/data_source_status.cpp b/libs/server-sdk/src/data_sources/data_source_status.cpp new file mode 100644 index 000000000..6881ed4ba --- /dev/null +++ b/libs/server-sdk/src/data_sources/data_source_status.cpp @@ -0,0 +1,40 @@ +#include + +#include + +namespace launchdarkly::server_side::data_sources { + +std::ostream& operator<<(std::ostream& out, + DataSourceStatus::DataSourceState const& state) { + switch (state) { + case DataSourceStatus::DataSourceState::kInitializing: + out << "INITIALIZING"; + break; + case DataSourceStatus::DataSourceState::kValid: + out << "VALID"; + break; + case DataSourceStatus::DataSourceState::kInterrupted: + out << "INTERRUPTED"; + break; + case DataSourceStatus::DataSourceState::kOff: + out << "OFF"; + break; + } + + return out; +} + +std::ostream& operator<<(std::ostream& out, DataSourceStatus const& status) { + std::time_t as_time_t = + std::chrono::system_clock::to_time_t(status.StateSince()); + out << "Status(" << status.State() << ", Since(" + << std::put_time(std::gmtime(&as_time_t), "%Y-%m-%d %H:%M:%S") << ")"; + auto const& last_error = status.LastError(); + if (last_error.has_value()) { + out << ", " << last_error.value(); + } + out << ")"; + return out; +} + +} // namespace launchdarkly::server_side::data_sources diff --git a/libs/server-sdk/src/data_sources/data_source_status_manager.hpp b/libs/server-sdk/src/data_sources/data_source_status_manager.hpp new file mode 100644 index 000000000..d19040ed8 --- /dev/null +++ b/libs/server-sdk/src/data_sources/data_source_status_manager.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +#include + +#include +#include +#include + +namespace launchdarkly::server_side::data_sources { + +class DataSourceStatusManager + : public internal::data_sources::DataSourceStatusManagerBase< + DataSourceStatus, + IDataSourceStatusProvider> { + public: + DataSourceStatusManager() = default; + + ~DataSourceStatusManager() override = default; + DataSourceStatusManager(DataSourceStatusManager const& item) = delete; + DataSourceStatusManager(DataSourceStatusManager&& item) = delete; + DataSourceStatusManager& operator=(DataSourceStatusManager const&) = delete; + DataSourceStatusManager& operator=(DataSourceStatusManager&&) = delete; +}; + +} // namespace launchdarkly::server_side::data_sources diff --git a/libs/server-sdk/src/data_sources/data_source_update_sink.hpp b/libs/server-sdk/src/data_sources/data_source_update_sink.hpp new file mode 100644 index 000000000..294f82ee7 --- /dev/null +++ b/libs/server-sdk/src/data_sources/data_source_update_sink.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include +#include + +#include "../data_store/descriptors.hpp" + +namespace launchdarkly::server_side::data_sources { +/** + * Interface for handling updates from LaunchDarkly. + */ +class IDataSourceUpdateSink { + public: + virtual void Init(launchdarkly::data_model::SDKDataSet data_set) = 0; + virtual void Upsert(std::string const& key, + data_store::FlagDescriptor flag) = 0; + virtual void Upsert(std::string const& key, + data_store::SegmentDescriptor segment) = 0; + + IDataSourceUpdateSink(IDataSourceUpdateSink const& item) = delete; + IDataSourceUpdateSink(IDataSourceUpdateSink&& item) = delete; + IDataSourceUpdateSink& operator=(IDataSourceUpdateSink const&) = delete; + IDataSourceUpdateSink& operator=(IDataSourceUpdateSink&&) = delete; + virtual ~IDataSourceUpdateSink() = default; + + protected: + IDataSourceUpdateSink() = default; +}; +} // namespace launchdarkly::server_side::data_sources diff --git a/libs/server-sdk/src/data_sources/null_data_source.cpp b/libs/server-sdk/src/data_sources/null_data_source.cpp new file mode 100644 index 000000000..223dee39c --- /dev/null +++ b/libs/server-sdk/src/data_sources/null_data_source.cpp @@ -0,0 +1,19 @@ +#include "null_data_source.hpp" + +#include + +namespace launchdarkly::server_side::data_sources { + +void NullDataSource::Start() { + status_manager_.SetState(DataSourceStatus::DataSourceState::kValid); +} + +void NullDataSource::ShutdownAsync(std::function complete) { + boost::asio::post(exec_, complete); +} + +NullDataSource::NullDataSource(boost::asio::any_io_executor exec, + DataSourceStatusManager& status_manager) + : status_manager_(status_manager), exec_(exec) {} + +} // namespace launchdarkly::server_side::data_sources diff --git a/libs/server-sdk/src/data_sources/null_data_source.hpp b/libs/server-sdk/src/data_sources/null_data_source.hpp new file mode 100644 index 000000000..7a1102f49 --- /dev/null +++ b/libs/server-sdk/src/data_sources/null_data_source.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include "data_source_status_manager.hpp" + +#include + +#include + +namespace launchdarkly::server_side::data_sources { + +class NullDataSource : public ::launchdarkly::data_sources::IDataSource { + public: + explicit NullDataSource(boost::asio::any_io_executor exec, + DataSourceStatusManager& status_manager); + void Start() override; + void ShutdownAsync(std::function) override; + + private: + DataSourceStatusManager& status_manager_; + boost::asio::any_io_executor exec_; +}; + +} // namespace launchdarkly::server_side::data_sources diff --git a/libs/server-sdk/src/data_sources/polling_data_source.cpp b/libs/server-sdk/src/data_sources/polling_data_source.cpp new file mode 100644 index 000000000..2f250de01 --- /dev/null +++ b/libs/server-sdk/src/data_sources/polling_data_source.cpp @@ -0,0 +1,245 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "data_source_update_sink.hpp" +#include "polling_data_source.hpp" + +namespace launchdarkly::server_side::data_sources { + +static char const* const kErrorParsingPut = "Could not parse polling payload"; +static char const* const kErrorPutInvalid = + "Polling payload contained invalid data"; + +static char const* const kCouldNotParseEndpoint = + "Could not parse polling endpoint URL"; + +static network::HttpRequest MakeRequest( + config::shared::built::DataSourceConfig const& + data_source_config, + config::shared::built::ServiceEndpoints const& endpoints, + config::shared::built::HttpProperties const& http_properties) { + auto url = std::make_optional(endpoints.PollingBaseUrl()); + + auto const& polling_config = std::get< + config::shared::built::PollingConfig>( + data_source_config.method); + + url = network::AppendUrl(url, polling_config.polling_get_path); + + network::HttpRequest::BodyType body; + network::HttpMethod method = network::HttpMethod::kGet; + + config::shared::builders::HttpPropertiesBuilder + builder(http_properties); + + // If no URL is set, then we will fail the request. + return {url.value_or(""), method, builder.Build(), body}; +} + +PollingDataSource::PollingDataSource( + config::shared::built::ServiceEndpoints const& endpoints, + config::shared::built::DataSourceConfig const& + data_source_config, + config::shared::built::HttpProperties const& http_properties, + boost::asio::any_io_executor const& ioc, + IDataSourceUpdateSink& handler, + DataSourceStatusManager& status_manager, + Logger const& logger) + : ioc_(ioc), + logger_(logger), + status_manager_(status_manager), + update_sink_(handler), + requester_(ioc), + timer_(ioc), + polling_interval_( + std::get< + config::shared::built::PollingConfig>( + data_source_config.method) + .poll_interval), + request_(MakeRequest(data_source_config, endpoints, http_properties)) { + auto const& polling_config = std::get< + config::shared::built::PollingConfig>( + data_source_config.method); + if (polling_interval_ < polling_config.min_polling_interval) { + LD_LOG(logger_, LogLevel::kWarn) + << "Polling interval too frequent, defaulting to " + << std::chrono::duration_cast( + polling_config.min_polling_interval) + .count() + << " seconds"; + + polling_interval_ = polling_config.min_polling_interval; + } +} + +void PollingDataSource::DoPoll() { + last_poll_start_ = std::chrono::system_clock::now(); + + auto weak_self = weak_from_this(); + requester_.Request(request_, [weak_self](network::HttpResult const& res) { + if (auto self = weak_self.lock()) { + self->HandlePollResult(res); + } + }); +} + +void PollingDataSource::HandlePollResult(network::HttpResult const& res) { + auto header_etag = res.Headers().find("etag"); + bool has_etag = header_etag != res.Headers().end(); + + if (etag_ && has_etag) { + if (etag_.value() == header_etag->second) { + // Got the same etag; we know the content has not changed. + // So we can just start the next timer. + + // We don't need to update the "request_" because it would have + // the same Etag. + StartPollingTimer(); + return; + } + } + + if (has_etag) { + config::shared::builders::HttpPropertiesBuilder< + config::shared::ServerSDK> + builder(request_.Properties()); + builder.Header("If-None-Match", header_etag->second); + request_ = network::HttpRequest(request_, builder.Build()); + + etag_ = header_etag->second; + } + + if (res.IsError()) { + auto const& error_message = res.ErrorMessage(); + status_manager_.SetState( + DataSourceStatus::DataSourceState::kInterrupted, + DataSourceStatus::ErrorInfo::ErrorKind::kNetworkError, + error_message.has_value() ? *error_message : "unknown error"); + LD_LOG(logger_, LogLevel::kWarn) + << "Polling for feature flag updates failed: " + << (error_message.has_value() ? *error_message : "unknown error"); + } else if (res.Status() == 200) { + auto const& body = res.Body(); + if (body.has_value()) { + boost::json::error_code error_code; + auto parsed = boost::json::parse(body.value(), error_code); + if (error_code) { + LD_LOG(logger_, LogLevel::kError) << kErrorParsingPut; + status_manager_.SetError( + DataSourceStatus::ErrorInfo::ErrorKind::kInvalidData, + kErrorParsingPut); + return; + } + auto poll_result = boost::json::value_to< + tl::expected>(parsed); + + if (poll_result.has_value()) { + update_sink_.Init(std::move(*poll_result)); + status_manager_.SetState( + DataSourceStatus::DataSourceState::kValid); + return; + } + LD_LOG(logger_, LogLevel::kError) << kErrorPutInvalid; + status_manager_.SetError( + DataSourceStatus::ErrorInfo::ErrorKind::kInvalidData, + kErrorPutInvalid); + return; + } + status_manager_.SetState( + DataSourceStatus::DataSourceState::kInterrupted, + DataSourceStatus::ErrorInfo::ErrorKind::kUnknown, + "polling response contained no body."); + + } else if (res.Status() == 304) { + // This should be handled ahead of here, but if we get a 304, + // and it didn't have an etag, we still don't want to try to + // parse the body. + } else { + if (network::IsRecoverableStatus(res.Status())) { + status_manager_.SetState( + DataSourceStatus::DataSourceState::kInterrupted, res.Status(), + launchdarkly::network::ErrorForStatusCode( + res.Status(), "polling request", "will retry")); + } else { + status_manager_.SetState( + DataSourceStatus::DataSourceState::kOff, res.Status(), + launchdarkly::network::ErrorForStatusCode( + res.Status(), "polling request", std::nullopt)); + // We are giving up. Do not start a new polling request. + return; + } + } + + StartPollingTimer(); +} + +void PollingDataSource::StartPollingTimer() { + auto time_since_poll_seconds = + std::chrono::duration_cast( + std::chrono::system_clock::now() - last_poll_start_); + + // Calculate a delay based on the polling interval and the duration elapsed + // since the last poll. + + // Example: If the poll took 5 seconds, and the interval is 30 seconds, then + // we want to poll after 25 seconds. We do not want the interval to be + // negative, so we clamp it to 0. + auto delay = std::chrono::seconds(std::max( + polling_interval_ - time_since_poll_seconds, std::chrono::seconds(0))); + + timer_.cancel(); + timer_.expires_after(delay); + + auto weak_self = weak_from_this(); + + timer_.async_wait([weak_self](boost::system::error_code const& ec) { + if (ec == boost::asio::error::operation_aborted) { + // The timer was canceled. Stop polling. + return; + } + if (auto self = weak_self.lock()) { + if (ec) { + // Something unexpected happened. Log it and continue to try + // polling. + LD_LOG(self->logger_, LogLevel::kError) + << "Unexpected error in polling timer: " << ec.message(); + } + self->DoPoll(); + } + }); +} + +void PollingDataSource::Start() { + status_manager_.SetState(DataSourceStatus::DataSourceState::kInitializing); + if (!request_.Valid()) { + LD_LOG(logger_, LogLevel::kError) << kCouldNotParseEndpoint; + status_manager_.SetState( + DataSourceStatus::DataSourceState::kOff, + DataSourceStatus::ErrorInfo::ErrorKind::kNetworkError, + kCouldNotParseEndpoint); + + // No need to attempt to poll if the URL is not valid. + return; + } + + DoPoll(); +} + +void PollingDataSource::ShutdownAsync(std::function completion) { + status_manager_.SetState(DataSourceStatus::DataSourceState::kInitializing); + timer_.cancel(); + if (completion) { + boost::asio::post(timer_.get_executor(), completion); + } +} + +} // namespace launchdarkly::server_side::data_sources diff --git a/libs/server-sdk/src/data_sources/polling_data_source.hpp b/libs/server-sdk/src/data_sources/polling_data_source.hpp new file mode 100644 index 000000000..1d99417b3 --- /dev/null +++ b/libs/server-sdk/src/data_sources/polling_data_source.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include + +#include + +#include "data_source_event_handler.hpp" +#include "data_source_status_manager.hpp" +#include "data_source_update_sink.hpp" + +#include +#include +#include +#include +#include +#include + +namespace launchdarkly::server_side::data_sources { + +class PollingDataSource + : public ::launchdarkly::data_sources::IDataSource, + public std::enable_shared_from_this { + public: + PollingDataSource( + config::shared::built::ServiceEndpoints const& endpoints, + config::shared::built::DataSourceConfig< + config::shared::ServerSDK> const& data_source_config, + config::shared::built::HttpProperties const& http_properties, + boost::asio::any_io_executor const& ioc, + IDataSourceUpdateSink& handler, + DataSourceStatusManager& status_manager, + Logger const& logger); + + void Start() override; + void ShutdownAsync(std::function completion) override; + + private: + void DoPoll(); + void HandlePollResult(network::HttpResult const& res); + + DataSourceStatusManager& status_manager_; + std::string polling_endpoint_; + + network::AsioRequester requester_; + Logger const& logger_; + boost::asio::any_io_executor ioc_; + std::chrono::seconds polling_interval_; + network::HttpRequest request_; + std::optional etag_; + + boost::asio::steady_timer timer_; + std::chrono::time_point last_poll_start_; + IDataSourceUpdateSink& update_sink_; + + void StartPollingTimer(); +}; + +} // namespace launchdarkly::server_side::data_sources diff --git a/libs/server-sdk/src/data_sources/streaming_data_source.cpp b/libs/server-sdk/src/data_sources/streaming_data_source.cpp new file mode 100644 index 000000000..2b76e05ac --- /dev/null +++ b/libs/server-sdk/src/data_sources/streaming_data_source.cpp @@ -0,0 +1,156 @@ +#include +#include +#include +#include +#include + +#include + +#include "streaming_data_source.hpp" + +#include + +namespace launchdarkly::server_side::data_sources { + +static char const* const kCouldNotParseEndpoint = + "Could not parse streaming endpoint URL"; + +static char const* DataSourceErrorToString(launchdarkly::sse::Error error) { + switch (error) { + case sse::Error::NoContent: + return "server responded 204 (No Content), will not attempt to " + "reconnect"; + case sse::Error::InvalidRedirectLocation: + return "server responded with an invalid redirection"; + case sse::Error::UnrecoverableClientError: + return "unrecoverable client-side error"; + default: + return "unrecognized error"; + } +} + +StreamingDataSource::StreamingDataSource( + config::shared::built::ServiceEndpoints const& endpoints, + config::shared::built::DataSourceConfig const& + data_source_config, + config::shared::built::HttpProperties http_properties, + boost::asio::any_io_executor ioc, + IDataSourceUpdateSink& handler, + DataSourceStatusManager& status_manager, + Logger const& logger) + : exec_(std::move(ioc)), + logger_(logger), + status_manager_(status_manager), + data_source_handler_( + DataSourceEventHandler(handler, logger, status_manager_)), + http_config_(std::move(http_properties)), + streaming_config_( + std::get>(data_source_config.method)), + streaming_endpoint_(endpoints.StreamingBaseUrl()) {} + +void StreamingDataSource::Start() { + status_manager_.SetState(DataSourceStatus::DataSourceState::kInitializing); + + auto updated_url = network::AppendUrl(streaming_endpoint_, + streaming_config_.streaming_path); + + // Bad URL, don't set the client. Start will then report the bad status. + if (!updated_url) { + LD_LOG(logger_, LogLevel::kError) << kCouldNotParseEndpoint; + status_manager_.SetState( + DataSourceStatus::DataSourceState::kOff, + DataSourceStatus::ErrorInfo::ErrorKind::kNetworkError, + kCouldNotParseEndpoint); + return; + } + + auto uri_components = boost::urls::parse_uri(*updated_url); + + // Unlikely that it could be parsed earlier, and it cannot be parsed now. + if (!uri_components) { + LD_LOG(logger_, LogLevel::kError) << kCouldNotParseEndpoint; + status_manager_.SetState( + DataSourceStatus::DataSourceState::kOff, + DataSourceStatus::ErrorInfo::ErrorKind::kNetworkError, + kCouldNotParseEndpoint); + return; + } + + boost::urls::url url = uri_components.value(); + + auto client_builder = launchdarkly::sse::Builder(exec_, url.buffer()); + + client_builder.method(boost::beast::http::verb::get); + + // TODO: can the read timeout be shared with *all* http requests? Or should + // it have a default in defaults.hpp? This must be greater than the + // heartbeat interval of the streaming service. + client_builder.read_timeout(std::chrono::minutes(5)); + + client_builder.write_timeout(http_config_.WriteTimeout()); + + client_builder.connect_timeout(http_config_.ConnectTimeout()); + + client_builder.initial_reconnect_delay( + streaming_config_.initial_reconnect_delay); + + for (auto const& header : http_config_.BaseHeaders()) { + client_builder.header(header.first, header.second); + } + + // TODO: Handle proxy support. sc-204386 + + auto weak_self = weak_from_this(); + + client_builder.receiver([weak_self](launchdarkly::sse::Event const& event) { + if (auto self = weak_self.lock()) { + self->data_source_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 + } + }); + + client_builder.logger([weak_self](auto msg) { + if (auto self = weak_self.lock()) { + LD_LOG(self->logger_, LogLevel::kDebug) << msg; + } + }); + + client_builder.errors([weak_self](auto error) { + if (auto self = weak_self.lock()) { + auto error_string = DataSourceErrorToString(error); + LD_LOG(self->logger_, LogLevel::kError) << error_string; + self->status_manager_.SetState( + DataSourceStatus::DataSourceState::kOff, + DataSourceStatus::ErrorInfo::ErrorKind::kErrorResponse, + error_string); + } + }); + + client_ = client_builder.build(); + + if (!client_) { + LD_LOG(logger_, LogLevel::kError) << kCouldNotParseEndpoint; + status_manager_.SetState( + DataSourceStatus::DataSourceState::kOff, + DataSourceStatus::ErrorInfo::ErrorKind::kNetworkError, + kCouldNotParseEndpoint); + return; + } + client_->run(); +} + +void StreamingDataSource::ShutdownAsync(std::function completion) { + if (client_) { + status_manager_.SetState( + DataSourceStatus::DataSourceState::kInitializing); + return client_->async_shutdown(std::move(completion)); + } + if (completion) { + boost::asio::post(exec_, completion); + } +} + +} // namespace launchdarkly::server_side::data_sources diff --git a/libs/server-sdk/src/data_sources/streaming_data_source.hpp b/libs/server-sdk/src/data_sources/streaming_data_source.hpp new file mode 100644 index 000000000..9663501f0 --- /dev/null +++ b/libs/server-sdk/src/data_sources/streaming_data_source.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include +using namespace std::chrono_literals; + +#include + +#include "data_source_event_handler.hpp" +#include "data_source_status_manager.hpp" +#include "data_source_update_sink.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace launchdarkly::server_side::data_sources { + +class StreamingDataSource final + : public ::launchdarkly::data_sources::IDataSource, + public std::enable_shared_from_this { + public: + StreamingDataSource( + config::shared::built::ServiceEndpoints const& endpoints, + config::shared::built::DataSourceConfig< + config::shared::ServerSDK> const& data_source_config, + config::shared::built::HttpProperties http_properties, + boost::asio::any_io_executor ioc, + IDataSourceUpdateSink& handler, + DataSourceStatusManager& status_manager, + Logger const& logger); + + void Start() override; + void ShutdownAsync(std::function completion) override; + + private: + boost::asio::any_io_executor exec_; + DataSourceStatusManager& status_manager_; + DataSourceEventHandler data_source_handler_; + std::string streaming_endpoint_; + + config::shared::built::StreamingConfig + streaming_config_; + + config::shared::built::HttpProperties http_config_; + + Logger const& logger_; + std::shared_ptr client_; +}; +} // namespace launchdarkly::server_side::data_sources diff --git a/libs/server-sdk/src/data_store/data_kind.hpp b/libs/server-sdk/src/data_store/data_kind.hpp new file mode 100644 index 000000000..17ead105b --- /dev/null +++ b/libs/server-sdk/src/data_store/data_kind.hpp @@ -0,0 +1,7 @@ +#pragma once + +#include + +namespace launchdarkly::server_side::data_store { +enum class DataKind : std::size_t { kFlag = 0, kSegment = 1, kKindCount = 2 }; +} // namespace launchdarkly::server_side::data_store diff --git a/libs/server-sdk/src/data_store/data_store.hpp b/libs/server-sdk/src/data_store/data_store.hpp new file mode 100644 index 000000000..3ae0184e0 --- /dev/null +++ b/libs/server-sdk/src/data_store/data_store.hpp @@ -0,0 +1,81 @@ +#pragma once + +#include "descriptors.hpp" + +#include +#include +#include + +#include +#include +#include + +namespace launchdarkly::server_side::data_store { + +/** + * Interface for readonly access to SDK data. + */ +class IDataStore { + public: + /** + * Get a flag from the store. + * + * @param key The key for the flag. + * @return Returns a shared_ptr to the FlagDescriptor, or a nullptr if there + * is no such flag or the flag was deleted. + */ + [[nodiscard]] virtual std::shared_ptr GetFlag( + std::string const& key) const = 0; + + /** + * Get a segment from the store. + * + * @param key The key for the segment. + * @return Returns a shared_ptr to the SegmentDescriptor, or a nullptr if + * there is no such segment, or the segment was deleted. + */ + [[nodiscard]] virtual std::shared_ptr GetSegment( + std::string const& key) const = 0; + + /** + * Get all of the flags. + * + * @return Returns an unordered map of FlagDescriptors. + */ + [[nodiscard]] virtual std::unordered_map> + AllFlags() const = 0; + + /** + * Get all of the segments. + * + * @return Returns an unordered map of SegmentDescriptors. + */ + [[nodiscard]] virtual std::unordered_map> + AllSegments() const = 0; + + /** + * Check if the store is initialized. + * + * @return Returns true if the store is initialized. + */ + [[nodiscard]] virtual bool Initialized() const = 0; + + /** + * Get a description of the store. + * @return Returns a string containing a description of the store. + */ + [[nodiscard]] virtual std::string const& Description() const = 0; + + IDataStore(IDataStore const& item) = delete; + IDataStore(IDataStore&& item) = delete; + IDataStore& operator=(IDataStore const&) = delete; + IDataStore& operator=(IDataStore&&) = delete; + virtual ~IDataStore() = default; + + protected: + IDataStore() = default; +}; + +} // namespace launchdarkly::server_side::data_store diff --git a/libs/server-sdk/src/data_store/data_store_updater.cpp b/libs/server-sdk/src/data_store/data_store_updater.cpp new file mode 100644 index 000000000..973a7d585 --- /dev/null +++ b/libs/server-sdk/src/data_store/data_store_updater.cpp @@ -0,0 +1,76 @@ +#include "data_store_updater.hpp" + +#include +#include + +namespace launchdarkly::server_side::data_store { + +std::unique_ptr DataStoreUpdater::OnFlagChange( + launchdarkly::server_side::IChangeNotifier::ChangeHandler handler) { + std::lock_guard lock{signal_mutex_}; + + return std::make_unique( + signals_.connect(handler)); +} + +void DataStoreUpdater::Init(launchdarkly::data_model::SDKDataSet data_set) { + // Optional outside the HasListeners() scope, this allows for the changes + // to be calculated before the update and then the notification to be + // sent after the update completes. + std::optional change_notifications; + if (HasListeners()) { + DependencySet updated_items; + + CalculateChanges(DataKind::kFlag, store_.AllFlags(), data_set.flags, + updated_items); + CalculateChanges(DataKind::kSegment, store_.AllSegments(), + data_set.segments, updated_items); + change_notifications = updated_items; + } + + dependency_tracker_.Clear(); + for (auto const& flag : data_set.flags) { + dependency_tracker_.UpdateDependencies(flag.first, flag.second); + } + for (auto const& segment : data_set.segments) { + dependency_tracker_.UpdateDependencies(segment.first, segment.second); + } + // Data will move into the store, so we want to update dependencies before + // it is moved. + sink_.Init(std::move(data_set)); + // After updating the sink, let listeners know of changes. + if (change_notifications) { + NotifyChanges(std::move(*change_notifications)); + } +} + +void DataStoreUpdater::Upsert(std::string const& key, + data_store::FlagDescriptor flag) { + UpsertCommon(DataKind::kFlag, key, store_.GetFlag(key), std::move(flag)); +} + +void DataStoreUpdater::Upsert(std::string const& key, + data_store::SegmentDescriptor segment) { + UpsertCommon(DataKind::kSegment, key, store_.GetSegment(key), + std::move(segment)); +} + +bool DataStoreUpdater::HasListeners() const { + std::lock_guard lock{signal_mutex_}; + return !signals_.empty(); +} + +void DataStoreUpdater::NotifyChanges(DependencySet changes) { + std::lock_guard lock{signal_mutex_}; + auto flag_changes = changes.SetForKind(DataKind::kFlag); + // Only emit an event if there are changes. + if (!flag_changes.empty()) { + signals_(std::make_shared(std::move(flag_changes))); + } +} + +DataStoreUpdater::DataStoreUpdater(IDataSourceUpdateSink& sink, + IDataStore const& store) + : sink_(sink), store_(store) {} + +} // namespace launchdarkly::server_side::data_store diff --git a/libs/server-sdk/src/data_store/data_store_updater.hpp b/libs/server-sdk/src/data_store/data_store_updater.hpp new file mode 100644 index 000000000..0b5a2e153 --- /dev/null +++ b/libs/server-sdk/src/data_store/data_store_updater.hpp @@ -0,0 +1,121 @@ +#pragma once + +#include "../data_sources/data_source_update_sink.hpp" +#include "data_store.hpp" +#include "dependency_tracker.hpp" + +#include + +#include + +#include + +namespace launchdarkly::server_side::data_store { + +class DataStoreUpdater + : public launchdarkly::server_side::data_sources::IDataSourceUpdateSink, + public launchdarkly::server_side::IChangeNotifier { + public: + template + using Collection = data_model::SDKDataSet::Collection; + + template + using SharedItem = std::shared_ptr>; + + template + using SharedCollection = + std::unordered_map>; + + DataStoreUpdater(IDataSourceUpdateSink& sink, IDataStore const& store); + + std::unique_ptr OnFlagChange(ChangeHandler handler) override; + + void Init(launchdarkly::data_model::SDKDataSet data_set) override; + void Upsert(std::string const& key, FlagDescriptor flag) override; + void Upsert(std::string const& key, SegmentDescriptor segment) override; + ~DataStoreUpdater() override = default; + + DataStoreUpdater(DataStoreUpdater const& item) = delete; + DataStoreUpdater(DataStoreUpdater&& item) = delete; + DataStoreUpdater& operator=(DataStoreUpdater const&) = delete; + DataStoreUpdater& operator=(DataStoreUpdater&&) = delete; + + private: + bool HasListeners() const; + + template + void UpsertCommon( + DataKind kind, + std::string key, + SharedItem existing, + launchdarkly::data_model::ItemDescriptor updated) { + if (existing && (updated.version <= existing->version)) { + // Out of order update, ignore it. + return; + } + + dependency_tracker_.UpdateDependencies(key, updated); + + if (HasListeners()) { + auto updated_deps = DependencySet(); + dependency_tracker_.CalculateChanges(kind, key, updated_deps); + NotifyChanges(updated_deps); + } + + sink_.Upsert(key, updated); + } + + template + void CalculateChanges( + DataKind kind, + SharedCollection const& existing_flags_or_segments, + Collection const& new_flags_or_segments, + DependencySet& updated_items) { + for (auto const& old_flag_or_segment : existing_flags_or_segments) { + auto new_flag_or_segment = + new_flags_or_segments.find(old_flag_or_segment.first); + if (new_flag_or_segment != new_flags_or_segments.end() && + new_flag_or_segment->second.version <= + old_flag_or_segment.second->version) { + continue; + } + + // Deleted. + dependency_tracker_.CalculateChanges( + kind, old_flag_or_segment.first, updated_items); + } + + for (auto const& flag_or_segment : new_flags_or_segments) { + auto oldItem = + existing_flags_or_segments.find(flag_or_segment.first); + if (oldItem != existing_flags_or_segments.end() && + flag_or_segment.second.version <= oldItem->second->version) { + continue; + } + + // Updated or new. + dependency_tracker_.CalculateChanges(kind, flag_or_segment.first, + updated_items); + } + } + + void NotifyChanges(DependencySet changes); + + IDataSourceUpdateSink& sink_; + IDataStore const& store_; + + boost::signals2::signal)> signals_; + + // Recursive mutex so that has_listeners can non-conditionally lock + // the mutex. Otherwise, a pre-condition for the call would be holding + // the mutex, which is more difficult to keep consistent over the code + // lifetime. + // + // Signals themselves are thread-safe, and this mutex only allows us to + // prevent the addition of listeners between the listener check, calculation + // and dispatch of events. + mutable std::recursive_mutex signal_mutex_; + + DependencyTracker dependency_tracker_; +}; +} // namespace launchdarkly::server_side::data_store diff --git a/libs/server-sdk/src/data_store/dependency_tracker.cpp b/libs/server-sdk/src/data_store/dependency_tracker.cpp new file mode 100644 index 000000000..d401fe327 --- /dev/null +++ b/libs/server-sdk/src/data_store/dependency_tracker.cpp @@ -0,0 +1,197 @@ +#include "dependency_tracker.hpp" +#include "tagged_data.hpp" + +#include + +namespace launchdarkly::server_side::data_store { + +DependencySet::DependencySet() + : data_{ + TaggedData>(DataKind::kFlag), + TaggedData>(DataKind::kSegment), + } {} + +void DependencySet::Set(DataKind kind, std::string key) { + Data(kind).emplace(std::move(key)); +} + +void DependencySet::Remove(DataKind kind, std::string const& key) { + Data(kind).erase(key); +} + +bool DependencySet::Contains(DataKind kind, std::string const& key) const { + return Data(kind).count(key) != 0; +} + +std::size_t DependencySet::Size() const { + std::size_t size = 0; + for (auto data_kind : data_) { + size += data_kind.Data().size(); + } + return size; +} + +std::array>, 2>::const_iterator +DependencySet::begin() const { + return data_.begin(); +} + +std::array>, 2>::const_iterator +DependencySet::end() const { + return data_.end(); +} + +std::set const& DependencySet::SetForKind(DataKind kind) { + return Data(kind); +} + +std::set const& DependencySet::Data(DataKind kind) const { + return data_[static_cast>(kind)].Data(); +} + +std::set& DependencySet::Data(DataKind kind) { + return data_[static_cast>(kind)].Data(); +} + +DependencyMap::DependencyMap() + : data_{ + TaggedData>( + DataKind::kFlag), + TaggedData>( + DataKind::kSegment), + } {} + +void DependencyMap::Set(DataKind kind, std::string key, DependencySet val) { + data_[static_cast>(kind)].Data().emplace( + std::move(key), std::move(val)); +} + +std::optional DependencyMap::Get(DataKind kind, + std::string const& key) const { + auto const& scope = + data_[static_cast>(kind)].Data(); + auto found = scope.find(key); + if (found != scope.end()) { + return found->second; + } + return std::nullopt; +} + +void DependencyMap::Clear() { + for (auto& data_kind : data_) { + data_kind.Data().clear(); + } +} + +std::array>, + 2>::const_iterator +DependencyMap::begin() const { + return data_.begin(); +} + +std::array>, + 2>::const_iterator +DependencyMap::end() const { + return data_.end(); +} + +void DependencyTracker::UpdateDependencies( + std::string const& key, + DependencyTracker::FlagDescriptor const& flag) { + DependencySet dependencies; + if (flag.item) { + for (auto const& prereq : flag.item->prerequisites) { + dependencies.Set(DataKind::kFlag, prereq.key); + } + + for (auto const& rule : flag.item->rules) { + CalculateClauseDeps(dependencies, rule.clauses); + } + } + UpdateDependencies(DataKind::kFlag, key, dependencies); +} + +void DependencyTracker::UpdateDependencies( + std::string const& key, + DependencyTracker::SegmentDescriptor const& segment) { + DependencySet dependencies; + if (segment.item) { + for (auto const& rule : segment.item->rules) { + CalculateClauseDeps(dependencies, rule.clauses); + } + } + UpdateDependencies(DataKind::kSegment, key, dependencies); +} + +// Function intentionally uses recursion. +// NOLINTBEGIN misc-no-recursion + +void DependencyTracker::CalculateChanges(DataKind kind, + std::string const& key, + DependencySet& dependency_set) { + if (!dependency_set.Contains(kind, key)) { + dependency_set.Set(kind, key); + auto affected_items = dependencies_to_.Get(kind, key); + if (affected_items) { + for (auto& deps_by_kind : *affected_items) { + for (auto& dep : deps_by_kind.Data()) { + CalculateChanges(deps_by_kind.Kind(), dep, dependency_set); + } + } + } + } +} + +// NOLINTEND misc-no-recursion + +void DependencyTracker::UpdateDependencies(DataKind kind, + std::string const& key, + DependencySet const& deps) { + auto current_deps = dependencies_from_.Get(kind, key); + if (current_deps) { + for (auto const& deps_by_kind : *current_deps) { + auto kind_of_dep = deps_by_kind.Kind(); + for (auto const& dep : deps_by_kind.Data()) { + auto deps_to_this_dep = dependencies_to_.Get(kind_of_dep, dep); + if (deps_to_this_dep) { + deps_to_this_dep->Remove(kind_of_dep, key); + } + } + } + } + + dependencies_from_.Set(kind, key, deps); + for (auto const& deps_by_kind : deps) { + for (auto const& dep : deps_by_kind.Data()) { + auto deps_to_this_dep = + dependencies_to_.Get(deps_by_kind.Kind(), dep); + if (!deps_to_this_dep) { + auto new_deps_to_this_dep = DependencySet(); + new_deps_to_this_dep.Set(kind, key); + dependencies_to_.Set(deps_by_kind.Kind(), dep, + new_deps_to_this_dep); + } else { + deps_to_this_dep->Set(kind, key); + } + } + } +} + +void DependencyTracker::CalculateClauseDeps( + DependencySet& dependencies, + std::vector const& clauses) { + for (auto const& clause : clauses) { + if (clause.op == data_model::Clause::Op::kSegmentMatch) { + for (auto const& value : clause.values) { + dependencies.Set(DataKind::kSegment, value.AsString()); + } + } + } +} + +void DependencyTracker::Clear() { + dependencies_to_.Clear(); + dependencies_from_.Clear(); +} + +} // namespace launchdarkly::server_side::data_store diff --git a/libs/server-sdk/src/data_store/dependency_tracker.hpp b/libs/server-sdk/src/data_store/dependency_tracker.hpp new file mode 100644 index 000000000..d62ba2c7c --- /dev/null +++ b/libs/server-sdk/src/data_store/dependency_tracker.hpp @@ -0,0 +1,156 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "data_kind.hpp" +#include "tagged_data.hpp" + +namespace launchdarkly::server_side::data_store { + +/** + * Class used to maintain a set of dependencies. Each dependency may be either + * a flag or segment. + * For instance, if we have a flagA, which has a prerequisite of flagB, and + * a segmentMatch targeting segmentA, then its dependency set would be + * ``` + * [{DataKind::kFlag, "flagB"}, {DataKind::kSegment, "segmentA"}] + * ``` + */ +class DependencySet { + public: + DependencySet(); + using DataType = std::array>, + static_cast(DataKind::kKindCount)>; + void Set(DataKind kind, std::string key); + + void Remove(DataKind kind, std::string const& key); + + [[nodiscard]] bool Contains(DataKind kind, std::string const& key) const; + + [[nodiscard]] std::set const& SetForKind(DataKind kind); + + /** + * Return the size of all the data kind sets. + * @return The combined size of all the data kind sets. + */ + [[nodiscard]] std::size_t Size() const; + + [[nodiscard]] typename DataType::const_iterator begin() const; + + [[nodiscard]] typename DataType::const_iterator end() const; + + private: + [[nodiscard]] std::set const& Data(DataKind kind) const; + + [[nodiscard]] std::set& Data(DataKind kind); + + DataType data_; +}; + +/** + * Class used to map flag/segments to their set of dependencies. + * For instance, if we have a flagA, which has a prerequisite of flagB, and + * a segmentMatch targeting segmentA, then a dependency map, containing + * this set, would be: + * ``` + * {{DataKind::kFlag, "flagA"}, [{DataKind::kFlag, "flagB"}, + * {DataKind::kSegment, "segmentA"}]} + * ``` + */ +class DependencyMap { + public: + DependencyMap(); + using DataType = + std::array>, + static_cast(DataKind::kKindCount)>; + void Set(DataKind kind, std::string key, DependencySet val); + + [[nodiscard]] std::optional Get( + DataKind kind, + std::string const& key) const; + + void Clear(); + + [[nodiscard]] typename DataType::const_iterator begin() const; + + [[nodiscard]] typename DataType::const_iterator end() const; + + private: + DataType data_; +}; + +/** + * This class implements a mechanism of tracking dependencies of flags and + * segments. Both the forward dependencies (flag A depends on flag B) but also + * the reverse (flag B is depended on by flagA). + */ +class DependencyTracker { + public: + using FlagDescriptor = data_model::ItemDescriptor; + using SegmentDescriptor = data_model::ItemDescriptor; + + /** + * Update the dependency tracker with a new or updated flag. + * + * @param key The key for the flag. + * @param flag A descriptor for the flag. + */ + void UpdateDependencies(std::string const& key, FlagDescriptor const& flag); + + /** + * Update the dependency tracker with a new or updated segment. + * + * @param key The key for the segment. + * @param flag A descriptor for the segment. + */ + void UpdateDependencies(std::string const& key, + SegmentDescriptor const& segment); + + /** + * Given the current dependencies, determine what flags or segments may be + * impacted by a change to the given flag/segment. + * + * @param kind The kind of data. + * @param key The key for the data. + * @param dependency_set A dependency set, which dependencies are + * accumulated in. + */ + void CalculateChanges(DataKind kind, + std::string const& key, + DependencySet& dependency_set); + + /** + * Clear all existing dependencies. + */ + void Clear(); + + private: + /** + * Common logic for dependency updates used for both flags and segments. + */ + void UpdateDependencies(DataKind kind, + std::string const& key, + DependencySet const& deps); + + DependencyMap dependencies_from_; + DependencyMap dependencies_to_; + + /** + * Determine dependencies for a set of clauses. + * @param dependencies A set of dependencies to extend. + * @param clauses The clauses to determine dependencies for. + */ + static void CalculateClauseDeps( + DependencySet& dependencies, + std::vector const& clauses); +}; + +} // namespace launchdarkly::server_side::data_store diff --git a/libs/server-sdk/src/data_store/descriptors.hpp b/libs/server-sdk/src/data_store/descriptors.hpp new file mode 100644 index 000000000..075d3a31d --- /dev/null +++ b/libs/server-sdk/src/data_store/descriptors.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include +#include +#include + +namespace launchdarkly::server_side::data_store { +using FlagDescriptor = + launchdarkly::data_model::ItemDescriptor; +using SegmentDescriptor = + launchdarkly::data_model::ItemDescriptor; +} // namespace launchdarkly::server_side::data_store diff --git a/libs/server-sdk/src/data_store/memory_store.cpp b/libs/server-sdk/src/data_store/memory_store.cpp new file mode 100644 index 000000000..321c6249d --- /dev/null +++ b/libs/server-sdk/src/data_store/memory_store.cpp @@ -0,0 +1,75 @@ + + +#include "memory_store.hpp" + +namespace launchdarkly::server_side::data_store { + +std::shared_ptr MemoryStore::GetFlag( + std::string const& key) const { + std::lock_guard lock{data_mutex_}; + auto found = flags_.find(key); + if (found != flags_.end()) { + return found->second; + } + return nullptr; +} + +std::shared_ptr MemoryStore::GetSegment( + std::string const& key) const { + std::lock_guard lock{data_mutex_}; + auto found = segments_.find(key); + if (found != segments_.end()) { + return found->second; + } + return nullptr; +} + +std::unordered_map> +MemoryStore::AllFlags() const { + std::lock_guard lock{data_mutex_}; + return {flags_}; +} + +std::unordered_map> +MemoryStore::AllSegments() const { + std::lock_guard lock{data_mutex_}; + return {segments_}; +} + +bool MemoryStore::Initialized() const { + std::lock_guard lock{data_mutex_}; + return initialized_; +} + +std::string const& MemoryStore::Description() const { + return description_; +} + +void MemoryStore::Init(launchdarkly::data_model::SDKDataSet dataSet) { + std::lock_guard lock{data_mutex_}; + initialized_ = true; + flags_.clear(); + segments_.clear(); + for (auto flag : dataSet.flags) { + flags_.emplace(flag.first, std::make_shared( + std::move(flag.second))); + } + for (auto segment : dataSet.segments) { + segments_.emplace(segment.first, std::make_shared( + std::move(segment.second))); + } +} + +void MemoryStore::Upsert(std::string const& key, + data_store::FlagDescriptor flag) { + std::lock_guard lock{data_mutex_}; + flags_[key] = std::make_shared(std::move(flag)); +} + +void MemoryStore::Upsert(std::string const& key, + data_store::SegmentDescriptor segment) { + std::lock_guard lock{data_mutex_}; + segments_[key] = std::make_shared(std::move(segment)); +} + +} // namespace launchdarkly::server_side::data_store diff --git a/libs/server-sdk/src/data_store/memory_store.hpp b/libs/server-sdk/src/data_store/memory_store.hpp new file mode 100644 index 000000000..f8758e4a8 --- /dev/null +++ b/libs/server-sdk/src/data_store/memory_store.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include "../data_sources/data_source_update_sink.hpp" +#include "data_store.hpp" + +#include +#include +#include +#include + +namespace launchdarkly::server_side::data_store { + +class MemoryStore : public IDataStore, + public data_sources::IDataSourceUpdateSink { + public: + std::shared_ptr GetFlag( + std::string const& key) const override; + std::shared_ptr GetSegment( + std::string const& key) const override; + + std::unordered_map> AllFlags() + const override; + std::unordered_map> + AllSegments() const override; + + bool Initialized() const override; + std::string const& Description() const override; + + void Init(launchdarkly::data_model::SDKDataSet dataSet) override; + void Upsert(std::string const& key, FlagDescriptor flag) override; + void Upsert(std::string const& key, SegmentDescriptor segment) override; + + MemoryStore() = default; + ~MemoryStore() override = default; + + MemoryStore(MemoryStore const& item) = delete; + MemoryStore(MemoryStore&& item) = delete; + MemoryStore& operator=(MemoryStore const&) = delete; + MemoryStore& operator=(MemoryStore&&) = delete; + + private: + static inline const std::string description_ = "memory"; + std::unordered_map> flags_; + std::unordered_map> + segments_; + bool initialized_ = false; + mutable std::mutex data_mutex_; +}; + +} // namespace launchdarkly::server_side::data_store diff --git a/libs/server-sdk/src/data_store/persistent/expiration_tracker.cpp b/libs/server-sdk/src/data_store/persistent/expiration_tracker.cpp new file mode 100644 index 000000000..09e6142ef --- /dev/null +++ b/libs/server-sdk/src/data_store/persistent/expiration_tracker.cpp @@ -0,0 +1,152 @@ +#include "expiration_tracker.hpp" + +namespace launchdarkly::server_side::data_store::persistent { + +void ExpirationTracker::Add(std::string const& key, + ExpirationTracker::TimePoint expiration) { + unscoped_.insert({key, expiration}); +} + +void ExpirationTracker::Remove(std::string const& key) { + unscoped_.erase(key); +} + +ExpirationTracker::TrackState ExpirationTracker::State( + std::string const& key, + ExpirationTracker::TimePoint current_time) const { + auto item = unscoped_.find(key); + if (item != unscoped_.end()) { + return State(item->second, current_time); + } + + return ExpirationTracker::TrackState::kNotTracked; +} + +void ExpirationTracker::Add(data_store::DataKind kind, + std::string const& key, + ExpirationTracker::TimePoint expiration) { + scoped_.Set(kind, key, expiration); +} + +void ExpirationTracker::Remove(data_store::DataKind kind, + std::string const& key) { + scoped_.Remove(kind, key); +} + +ExpirationTracker::TrackState ExpirationTracker::State( + data_store::DataKind kind, + std::string const& key, + ExpirationTracker::TimePoint current_time) const { + auto expiration = scoped_.Get(kind, key); + if (expiration.has_value()) { + return State(expiration.value(), current_time); + } + return ExpirationTracker::TrackState::kNotTracked; +} + +void ExpirationTracker::Clear() { + scoped_.Clear(); + unscoped_.clear(); +} +std::vector, std::string>> +ExpirationTracker::Prune(ExpirationTracker::TimePoint current_time) { + std::vector, std::string>> pruned; + + // Determine everything to be pruned. + for (auto const& item : unscoped_) { + if (State(item.second, current_time) == + ExpirationTracker::TrackState::kStale) { + pruned.emplace_back(std::nullopt, item.first); + } + } + for (auto const& scope : scoped_) { + for (auto const& item : scope.Data()) { + if (State(item.second, current_time) == + ExpirationTracker::TrackState::kStale) { + pruned.emplace_back(scope.Kind(), item.first); + } + } + } + + // Do the actual prune. + for (auto const& item : pruned) { + if (item.first.has_value()) { + scoped_.Remove(item.first.value(), item.second); + } else { + unscoped_.erase(item.second); + } + } + return pruned; +} +ExpirationTracker::TrackState ExpirationTracker::State( + ExpirationTracker::TimePoint expiration, + ExpirationTracker::TimePoint current_time) { + if (expiration > current_time) { + return ExpirationTracker::TrackState::kFresh; + } + return ExpirationTracker::TrackState::kStale; +} + +void ExpirationTracker::ScopedTtls::Set( + DataKind kind, + std::string const& key, + ExpirationTracker::TimePoint expiration) { + data_[static_cast>(kind)].Data().insert( + {key, expiration}); +} + +void ExpirationTracker::ScopedTtls::Remove(DataKind kind, + std::string const& key) { + data_[static_cast>(kind)].Data().erase( + key); +} + +void ExpirationTracker::ScopedTtls::Clear() { + for (auto& scope : data_) { + scope.Data().clear(); + } +} + +std::optional ExpirationTracker::ScopedTtls::Get( + DataKind kind, + std::string const& key) const { + auto const& scope = + data_[static_cast>(kind)]; + auto found = scope.Data().find(key); + if (found != scope.Data().end()) { + return found->second; + } + return std::nullopt; +} +ExpirationTracker::ScopedTtls::ScopedTtls() + : data_{ + TaggedData(DataKind::kFlag), + TaggedData(DataKind::kSegment), + } {} + +std::array, 2>::iterator +ExpirationTracker::ScopedTtls::begin() { + return data_.begin(); +} + +std::array, 2>::iterator +ExpirationTracker::ScopedTtls::end() { + return data_.end(); +} + +std::ostream& operator<<(std::ostream& out, + ExpirationTracker::TrackState const& state) { + switch (state) { + case ExpirationTracker::TrackState::kFresh: + out << "FRESH"; + break; + case ExpirationTracker::TrackState::kStale: + out << "STALE"; + break; + case ExpirationTracker::TrackState::kNotTracked: + out << "NOT_TRACKED"; + break; + } + return out; +} +} // namespace launchdarkly::server_side::data_store::persistent diff --git a/libs/server-sdk/src/data_store/persistent/expiration_tracker.hpp b/libs/server-sdk/src/data_store/persistent/expiration_tracker.hpp new file mode 100644 index 000000000..219b660f8 --- /dev/null +++ b/libs/server-sdk/src/data_store/persistent/expiration_tracker.hpp @@ -0,0 +1,146 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "../../data_store/data_kind.hpp" +#include "../tagged_data.hpp" + +namespace launchdarkly::server_side::data_store::persistent { + +class ExpirationTracker { + public: + using TimePoint = std::chrono::time_point; + + /** + * The state of the key in the tracker. + */ + enum class TrackState { + /** + * The key is tracked and the key expiration is in the future. + */ + kFresh, + /** + * The key is tracked and the expiration is either now or in the past. + */ + kStale, + /** + * The key is not being tracked. + */ + kNotTracked + }; + + /** + * Add an unscoped key to the tracker. + * + * @param key The key to track. + * @param expiration The time that the key expires. + * used. + */ + void Add(std::string const& key, TimePoint expiration); + + /** + * Remove an unscoped key from the tracker. + * + * @param key The key to stop tracking. + */ + void Remove(std::string const& key); + + /** + * Check the state of an unscoped key. + * + * @param key The key to check. + * @param current_time The current time. + * @return The state of the key. + */ + TrackState State(std::string const& key, TimePoint current_time) const; + + /** + * Add a scoped key to the tracker. Will use the specified TTL for the kind. + * + * @param kind The scope (kind) of the key. + * @param key The key to track. + * @param expiration The time that the key expires. + */ + void Add(data_store::DataKind kind, + std::string const& key, + TimePoint expiration); + + /** + * Remove a scoped key from the tracker. + * + * @param kind The scope (kind) of the key. + * @param key The key to stop tracking. + */ + void Remove(data_store::DataKind kind, std::string const& key); + + /** + * Check the state of a scoped key. + * + * @param kind The scope (kind) of the key. + * @param key The key to check. + * @return The state of the key. + */ + TrackState State(data_store::DataKind kind, + std::string const& key, + TimePoint current_time) const; + + /** + * Stop tracking all keys. + */ + void Clear(); + + /** + * Prune expired keys from the tracker. + * @param current_time The current time. + * @return A list of all the kinds and associated keys that expired. + * Unscoped keys will have std::nullopt as the kind. + */ + std::vector, std::string>> Prune( + TimePoint current_time); + + private: + using TtlMap = std::unordered_map; + + TtlMap unscoped_; + + static ExpirationTracker::TrackState State( + ExpirationTracker::TimePoint expiration, + ExpirationTracker::TimePoint current_time); + + class ScopedTtls { + public: + ScopedTtls(); + + using DataType = + std::array, + static_cast>( + DataKind::kKindCount)>; + void Set(DataKind kind, std::string const& key, TimePoint expiration); + void Remove(DataKind kind, std::string const& key); + std::optional Get(DataKind kind, + std::string const& key) const; + void Clear(); + + [[nodiscard]] typename DataType::iterator begin(); + + [[nodiscard]] typename DataType::iterator end(); + + private: + DataType data_; + }; + + ScopedTtls scoped_; +}; + +std::ostream& operator<<(std::ostream& out, + ExpirationTracker::TrackState const& state); + +} // namespace launchdarkly::server_side::data_store::persistent diff --git a/libs/server-sdk/src/data_store/persistent/persistent_data_store.cpp b/libs/server-sdk/src/data_store/persistent/persistent_data_store.cpp new file mode 100644 index 000000000..706f78796 --- /dev/null +++ b/libs/server-sdk/src/data_store/persistent/persistent_data_store.cpp @@ -0,0 +1,3 @@ +#include "persistent_data_store.hpp" + +namespace launchdarkly::server_side::data_store::persistent {} diff --git a/libs/server-sdk/src/data_store/persistent/persistent_data_store.hpp b/libs/server-sdk/src/data_store/persistent/persistent_data_store.hpp new file mode 100644 index 000000000..523e09889 --- /dev/null +++ b/libs/server-sdk/src/data_store/persistent/persistent_data_store.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include "../../data_sources/data_source_update_sink.hpp" +#include "../data_store.hpp" +#include "../memory_store.hpp" +#include "expiration_tracker.hpp" + +#include + +#include +#include +#include +#include + +namespace launchdarkly::server_side::data_store::persistent { + +class PersistentStore : public IDataStore, + public data_sources::IDataSourceUpdateSink { + public: + std::shared_ptr GetFlag( + std::string const& key) const override; + std::shared_ptr GetSegment( + std::string const& key) const override; + + std::unordered_map> AllFlags() + const override; + std::unordered_map> + AllSegments() const override; + + bool Initialized() const override; + std::string const& Description() const override; + + void Init(launchdarkly::data_model::SDKDataSet dataSet) override; + void Upsert(std::string const& key, FlagDescriptor flag) override; + void Upsert(std::string const& key, SegmentDescriptor segment) override; + + PersistentStore() = default; + ~PersistentStore() override = default; + + PersistentStore(PersistentStore const& item) = delete; + PersistentStore(PersistentStore&& item) = delete; + PersistentStore& operator=(PersistentStore const&) = delete; + PersistentStore& operator=(PersistentStore&&) = delete; + + private: + MemoryStore memory_store_; + std::shared_ptr persistent_store_core_; + ExpirationTracker ttl_tracker_; +}; + +} // namespace launchdarkly::server_side::data_store::persistent diff --git a/libs/server-sdk/src/data_store/tagged_data.hpp b/libs/server-sdk/src/data_store/tagged_data.hpp new file mode 100644 index 000000000..6ff087860 --- /dev/null +++ b/libs/server-sdk/src/data_store/tagged_data.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include + +#include "data_kind.hpp" + +namespace launchdarkly::server_side::data_store { +/** + * Class which can be used to tag a collection with the DataKind that collection + * is for. This is primarily to decrease the complexity of iterating collections + * allowing for a kvp style iteration, but with an array storage container. + * @tparam Storage + */ +template +class TaggedData { + public: + explicit TaggedData(launchdarkly::server_side::data_store::DataKind kind) + : kind_(kind) {} + [[nodiscard]] launchdarkly::server_side::data_store::DataKind Kind() const { + return kind_; + } + [[nodiscard]] Storage const& Data() const { return storage_; } + + [[nodiscard]] Storage& Data() { return storage_; } + + private: + launchdarkly::server_side::data_store::DataKind kind_; + Storage storage_; +}; + +} diff --git a/libs/server-sdk/src/evaluation/bucketing.cpp b/libs/server-sdk/src/evaluation/bucketing.cpp new file mode 100644 index 000000000..1333ee1c0 --- /dev/null +++ b/libs/server-sdk/src/evaluation/bucketing.cpp @@ -0,0 +1,209 @@ +#include "bucketing.hpp" + +#include +#include +#include + +#include +#include + +namespace launchdarkly::server_side::evaluation { + +using namespace launchdarkly::data_model; + +double const kBucketHashScale = static_cast(0x0FFFFFFFFFFFFFFF); + +AttributeReference const& Key(); + +std::optional ContextHash(Value const& value, + BucketPrefix prefix); + +std::optional BucketValue(Value const& value); + +bool IsIntegral(double f); + +BucketPrefix::BucketPrefix(Seed seed) : prefix_(seed) {} + +BucketPrefix::BucketPrefix(std::string key, std::string salt) + : prefix_(KeyAndSalt{key, salt}) {} + +std::ostream& operator<<(std::ostream& os, BucketPrefix const& prefix) { + std::visit( + [&](auto&& arg) { + using T = std::decay_t; + if constexpr (std::is_same_v) { + os << arg.key << "." << arg.salt; + } else if constexpr (std::is_same_v) { + os << arg; + } + }, + prefix.prefix_); + return os; +} + +BucketResult::BucketResult(Flag::Rollout::WeightedVariation weighted_variation, + bool is_experiment) + : variation_index_(weighted_variation.variation), + in_experiment_(is_experiment && !weighted_variation.untracked) {} + +BucketResult::BucketResult(Flag::Variation variation, bool in_experiment) + : variation_index_(variation), in_experiment_(in_experiment) {} + +BucketResult::BucketResult(Flag::Variation variation) + : variation_index_(variation), in_experiment_(false) {} + +std::size_t BucketResult::VariationIndex() const { + return variation_index_; +} + +bool BucketResult::InExperiment() const { + return in_experiment_; +} + +tl::expected, Error> Bucket( + Context const& context, + AttributeReference const& by_attr, + BucketPrefix const& prefix, + bool is_experiment, + std::string const& context_kind) { + AttributeReference const& ref = is_experiment ? Key() : by_attr; + + if (!ref.Valid()) { + return tl::make_unexpected( + Error::InvalidAttributeReference(ref.RedactionName())); + } + + Value const& value = context.Get(context_kind, ref); + + bool is_bucketable = value.Type() == Value::Type::kNumber || + value.Type() == Value::Type::kString; + + if (is_bucketable) { + return std::make_pair(ContextHash(value, prefix).value_or(0.0), + RolloutKindLookup::kPresent); + } + + auto rollout_context_found = + std::count(context.Kinds().begin(), context.Kinds().end(), + context_kind) > 0; + + return std::make_pair(0.0, rollout_context_found + ? RolloutKindLookup::kPresent + : RolloutKindLookup::kAbsent); +} + +AttributeReference const& Key() { + static AttributeReference const key{"key"}; + LD_ASSERT(key.Valid()); + return key; +} + +std::optional ContextHash(Value const& value, BucketPrefix prefix) { + using namespace launchdarkly::encoding; + + std::optional id = BucketValue(value); + if (!id) { + return std::nullopt; + } + + std::stringstream input; + input << prefix << "." << *id; + + std::array const sha1hash = + Sha1String(input.str()); + + std::string const sha1hash_hexed = Base16Encode(sha1hash); + + std::string const sha1hash_hexed_first_15 = sha1hash_hexed.substr(0, 15); + + try { + unsigned long long as_number = + std::stoull(sha1hash_hexed_first_15.data(), nullptr, /* base */ 16); + + double as_double = static_cast(as_number); + return as_double / kBucketHashScale; + + } catch (std::invalid_argument) { + return std::nullopt; + } catch (std::out_of_range) { + return std::nullopt; + } +} + +std::optional BucketValue(Value const& value) { + switch (value.Type()) { + case Value::Type::kString: + return value.AsString(); + case Value::Type::kNumber: { + if (IsIntegral(value.AsDouble())) { + return std::to_string(value.AsInt()); + } + return std::nullopt; + } + default: + return std::nullopt; + } +} + +bool IsIntegral(double f) { + return std::trunc(f) == f; +} + +tl::expected Variation( + Flag::VariationOrRollout const& vr, + std::string const& flag_key, + Context const& context, + std::optional const& salt) { + if (!salt) { + return tl::make_unexpected(Error::MissingSalt(flag_key)); + } + return std::visit( + [&](auto&& arg) -> tl::expected { + using T = std::decay_t; + if constexpr (std::is_same_v>) { + if (!arg) { + return tl::make_unexpected( + Error::RolloutMissingVariations()); + } + return BucketResult(*arg); + } else if constexpr (std::is_same_v) { + if (arg.variations.empty()) { + return tl::make_unexpected( + Error::RolloutMissingVariations()); + } + + bool is_experiment = + arg.kind == Flag::Rollout::Kind::kExperiment; + + std::optional prefix = + arg.seed ? BucketPrefix(*arg.seed) + : BucketPrefix(flag_key, *salt); + + auto bucketing_result = Bucket(context, arg.bucketBy, *prefix, + is_experiment, arg.contextKind); + if (!bucketing_result) { + return tl::make_unexpected(bucketing_result.error()); + } + + auto [bucket, lookup] = *bucketing_result; + + double sum = 0.0; + + for (const auto& variation : arg.variations) { + sum += variation.weight / kBucketScale; + if (bucket < sum) { + return BucketResult( + variation, + is_experiment && + lookup == RolloutKindLookup::kPresent); + } + } + + return BucketResult( + arg.variations.back(), + is_experiment && lookup == RolloutKindLookup::kPresent); + } + }, + vr); +} +} // namespace launchdarkly::server_side::evaluation diff --git a/libs/server-sdk/src/evaluation/bucketing.hpp b/libs/server-sdk/src/evaluation/bucketing.hpp new file mode 100644 index 000000000..346374590 --- /dev/null +++ b/libs/server-sdk/src/evaluation/bucketing.hpp @@ -0,0 +1,123 @@ +#pragma once + +#include "evaluation_error.hpp" + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace launchdarkly::server_side::evaluation { + +double const kBucketScale = 100'000.0; + +enum RolloutKindLookup { + /* The rollout's context kind was found in the supplied evaluation context. + */ + kPresent, + /* The rollout's context kind was not found in the supplied evaluation + * context. */ + kAbsent +}; + +/** + * Bucketing is performed by hashing an input string. This string + * may be comprised of a seed (if the flag rule has a seed) or a combined + * key/salt pair. + */ +class BucketPrefix { + public: + struct KeyAndSalt { + std::string key; + std::string salt; + }; + + using Seed = std::int64_t; + + /** + * Constructs a BucketPrefix from a seed value. + * @param seed Value of the seed. + */ + explicit BucketPrefix(Seed seed); + + /** + * Constructs a BucketPrefix from a key and salt. + * @param key Key to use. + * @param salt Salt to use. + */ + BucketPrefix(std::string key, std::string salt); + + friend std::ostream& operator<<(std::ostream& os, + BucketPrefix const& prefix); + + private: + std::variant prefix_; +}; + +using ContextHashValue = double; + +/** + * Computes the context hash value for an attribute in the given context + * identified by the given attribute reference. The hash value is + * augmented with the supplied bucket prefix. + * + * @param context Context to query. + * @param by_attr Identifier of the attribute to hash. If is_experiment is true, + * then "key" will be used regardless of by_attr's value. + * @param prefix Prefix to use when hashing. + * @param is_experiment Whether this rollout is an experiment. + * @param context_kind Which kind to inspect in the context. + * @return A context hash value and indication of whether or not context_kind + * was found in the context. + */ +tl::expected, Error> Bucket( + Context const& context, + AttributeReference const& by_attr, + BucketPrefix const& prefix, + bool is_experiment, + std::string const& context_kind); + +class BucketResult { + public: + BucketResult( + data_model::Flag::Rollout::WeightedVariation weighted_variation, + bool is_experiment); + + BucketResult(data_model::Flag::Variation variation, bool in_experiment); + + BucketResult(data_model::Flag::Variation variation); + + [[nodiscard]] std::size_t VariationIndex() const; + + [[nodiscard]] bool InExperiment() const; + + private: + std::size_t variation_index_; + bool in_experiment_; +}; + +/** + * Given a variation or rollout and associated flag key, computes the proper + * variation index for the context. For a plain variation, this is simply the + * variation index. For a rollout, this utilizes the Bucket function. + * + * @param vr Variation or rollout. + * @param flag_key Key of flag. + * @param context Context to bucket. + * @param salt Salt to use when bucketing. + * @return A BucketResult on success, or an error if bucketing failed. + */ +tl::expected Variation( + data_model::Flag::VariationOrRollout const& vr, + std::string const& flag_key, + launchdarkly::Context const& context, + std::optional const& salt); + +} // namespace launchdarkly::server_side::evaluation diff --git a/libs/server-sdk/src/evaluation/detail/evaluation_stack.cpp b/libs/server-sdk/src/evaluation/detail/evaluation_stack.cpp new file mode 100644 index 000000000..7c971f510 --- /dev/null +++ b/libs/server-sdk/src/evaluation/detail/evaluation_stack.cpp @@ -0,0 +1,30 @@ +#include "evaluation_stack.hpp" + +namespace launchdarkly::server_side::evaluation::detail { + +Guard::Guard(std::unordered_set& set, std::string key) + : set_(set), key_(std::move(key)) { + set_.insert(key_); +} + +Guard::~Guard() { + set_.erase(key_); +} + +std::optional EvaluationStack::NoticePrerequisite( + std::string prerequisite_key) { + if (prerequisites_seen_.count(prerequisite_key) != 0) { + return std::nullopt; + } + return std::make_optional(prerequisites_seen_, + std::move(prerequisite_key)); +} + +std::optional EvaluationStack::NoticeSegment(std::string segment_key) { + if (segments_seen_.count(segment_key) != 0) { + return std::nullopt; + } + return std::make_optional(segments_seen_, std::move(segment_key)); +} + +} // namespace launchdarkly::server_side::evaluation::detail diff --git a/libs/server-sdk/src/evaluation/detail/evaluation_stack.hpp b/libs/server-sdk/src/evaluation/detail/evaluation_stack.hpp new file mode 100644 index 000000000..7022dc375 --- /dev/null +++ b/libs/server-sdk/src/evaluation/detail/evaluation_stack.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include + +namespace launchdarkly::server_side::evaluation::detail { + +/** + * Guard is an object used to track that a segment or flag key has been noticed. + * Upon destruction, the key is forgotten. + */ +struct Guard { + Guard(std::unordered_set& set, std::string key); + ~Guard(); + + Guard(Guard const&) = delete; + Guard& operator=(Guard const&) = delete; + + Guard(Guard&&) = delete; + Guard& operator=(Guard&&) = delete; + + private: + std::unordered_set& set_; + std::string const key_; +}; + +/** + * EvaluationStack is used to track which segments and flags have been noticed + * during evaluation in order to detect circular references. + */ +class EvaluationStack { + public: + EvaluationStack() = default; + + /** + * If the given prerequisite key has not been seen, marks it as seen + * and returns a Guard object. Otherwise, returns std::nullopt. + * + * @param prerequisite_key Key of the prerequisite. + * @return Guard object if not seen before, otherwise std::nullopt. + */ + [[nodiscard]] std::optional NoticePrerequisite( + std::string prerequisite_key); + + /** + * If the given segment key has not been seen, marks it as seen + * and returns a Guard object. Otherwise, returns std::nullopt. + * + * @param prerequisite_key Key of the segment. + * @return Guard object if not seen before, otherwise std::nullopt. + */ + [[nodiscard]] std::optional NoticeSegment(std::string segment_key); + + private: + std::unordered_set prerequisites_seen_; + std::unordered_set segments_seen_; +}; + +} // namespace launchdarkly::server_side::evaluation::detail diff --git a/libs/server-sdk/src/evaluation/detail/semver_operations.cpp b/libs/server-sdk/src/evaluation/detail/semver_operations.cpp new file mode 100644 index 000000000..1ecc471d7 --- /dev/null +++ b/libs/server-sdk/src/evaluation/detail/semver_operations.cpp @@ -0,0 +1,203 @@ +#include "semver_operations.hpp" + +#include + +#include +#include +#include + +#include + +namespace launchdarkly::server_side::evaluation::detail { + +/* + * Official SemVer 2.0 Regex + * https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string + * + * Modified for LaunchDarkly usage to allow missing minor and patch versions, + * i.e. "1" means "1.0.0" or "1.2" means "1.2.0". + */ +char const* const kSemVerRegex = + R"(^(?0|[1-9]\d*)(\.(?0|[1-9]\d*))?(\.(?0|[1-9]\d*))?(?:-(?(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$)"; + +/** + * Cache the parsed regex so it doesn't need to be rebuilt constantly. + * + * From Boost docs: + * Class basic_regex and its typedefs regex and wregex are thread safe, in that + * compiled regular expressions can safely be shared between threads. + */ +static boost::regex const& SemVerRegex() { + static boost::regex regex{kSemVerRegex, boost::regex_constants::no_except}; + LD_ASSERT(regex.status() == 0); + return regex; +} + +SemVer::SemVer(VersionType major, + VersionType minor, + VersionType patch, + std::vector prerelease) + : major_(major), minor_(minor), patch_(patch), prerelease_(prerelease) {} + +SemVer::SemVer(VersionType major, VersionType minor, VersionType patch) + : major_(major), minor_(minor), patch_(patch), prerelease_(std::nullopt) {} + +SemVer::SemVer() : SemVer(0, 0, 0) {} + +SemVer::VersionType SemVer::Major() const { + return major_; +} + +SemVer::VersionType SemVer::Minor() const { + return minor_; +} + +SemVer::VersionType SemVer::Patch() const { + return patch_; +} + +std::optional> const& SemVer::Prerelease() const { + return prerelease_; +} + +bool operator<(SemVer::Token const& lhs, SemVer::Token const& rhs) { + if (lhs.index() != rhs.index()) { + /* Numeric identifiers (index 0 of variant) always have lower precedence +than non-numeric identifiers. */ + return lhs.index() < rhs.index(); + } + if (lhs.index() == 0) { + return std::get<0>(lhs) < std::get<0>(rhs); + } + return std::get<1>(lhs) < std::get<1>(rhs); +} + +bool operator==(SemVer const& lhs, SemVer const& rhs) { + return lhs.Major() == rhs.Major() && lhs.Minor() == rhs.Minor() && + lhs.Patch() == rhs.Patch() && lhs.Prerelease() == rhs.Prerelease(); +} + +bool operator<(SemVer const& lhs, SemVer const& rhs) { + if (lhs.Major() < rhs.Major()) { + return true; + } + if (lhs.Major() > rhs.Major()) { + return false; + } + if (lhs.Minor() < rhs.Minor()) { + return true; + } + if (lhs.Minor() > rhs.Minor()) { + return false; + } + if (lhs.Patch() < rhs.Patch()) { + return true; + } + if (lhs.Patch() > rhs.Patch()) { + return false; + } + // At this point, lhs and rhs have equal major/minor/patch versions. + if (!lhs.Prerelease() && !rhs.Prerelease()) { + return false; + } + if (lhs.Prerelease() && !rhs.Prerelease()) { + return true; + } + if (!lhs.Prerelease() && rhs.Prerelease()) { + return false; + } + return *lhs.Prerelease() < *rhs.Prerelease(); +} + +bool operator>(SemVer const& lhs, SemVer const& rhs) { + return rhs < lhs; +} + +std::optional SemVer::Parse(std::string const& value) { + if (value.empty()) { + return std::nullopt; + } + + boost::regex const& semver_regex = SemVerRegex(); + boost::smatch match; + + try { + if (!boost::regex_match(value, match, semver_regex)) { + // Not a semantic version. + return std::nullopt; + } + } catch (std::runtime_error) { + /* std::runtime_error if the complexity of matching the expression + * against an N character string begins to exceed O(N2), or if the + * program runs out of stack space while matching the expression + * (if Boost.Regex is configured in recursive mode), or if the matcher + * exhausts its permitted memory allocation (if Boost.Regex + * is configured in non-recursive mode).*/ + return std::nullopt; + } + + SemVer::VersionType major = 0; + SemVer::VersionType minor = 0; + SemVer::VersionType patch = 0; + + try { + if (match["major"].matched) { + major = boost::lexical_cast(match["major"]); + } + if (match["minor"].matched) { + minor = boost::lexical_cast(match["minor"]); + } + if (match["patch"].matched) { + patch = boost::lexical_cast(match["patch"]); + } + } catch (boost::bad_lexical_cast) { + return std::nullopt; + } + + if (!match["prerelease"].matched) { + return SemVer{major, minor, patch}; + } + + std::vector tokens; + boost::split(tokens, match["prerelease"], boost::is_any_of(".")); + + std::vector prerelease; + + std::transform( + tokens.begin(), tokens.end(), std::back_inserter(prerelease), + [](std::string const& token) -> SemVer::Token { + try { + return boost::lexical_cast(token); + } catch (boost::bad_lexical_cast) { + return token; + } + }); + + return SemVer{major, minor, patch, prerelease}; +} + +std::ostream& operator<<(std::ostream& out, SemVer::Token const& token) { + if (token.index() == 0) { + out << std::get<0>(token); + } else { + out << std::get<1>(token); + } + return out; +} + +std::ostream& operator<<(std::ostream& out, SemVer const& ver) { + out << ver.Major() << "." << ver.Minor() << "." << ver.Patch(); + if (ver.Prerelease()) { + out << "-"; + + for (auto it = ver.Prerelease()->begin(); it != ver.Prerelease()->end(); + ++it) { + out << *it; + if (std::next(it) != ver.Prerelease()->end()) { + out << "."; + } + } + } + return out; +} +} // namespace launchdarkly::server_side::evaluation::detail diff --git a/libs/server-sdk/src/evaluation/detail/semver_operations.hpp b/libs/server-sdk/src/evaluation/detail/semver_operations.hpp new file mode 100644 index 000000000..85b371116 --- /dev/null +++ b/libs/server-sdk/src/evaluation/detail/semver_operations.hpp @@ -0,0 +1,88 @@ +#pragma once + +#include +#include +#include +#include + +namespace launchdarkly::server_side::evaluation::detail { + +/** + * Represents a LaunchDarkly-flavored Semantic Version v2. + * + * Semantic versions can be compared using ==, <, and >. + * + * The main difference from the official spec is that missing minor and patch + * versions are allowed, i.e. "1" means "1.0.0" and "1.2" means "1.2.0". + */ +class SemVer { + public: + using VersionType = std::uint64_t; + + using Token = std::variant; + + /** + * Constructs a SemVer representing "0.0.0". + */ + SemVer(); + + /** + * Constructs a SemVer from a major, minor, patch, and prerelease. The + * prerelease consists of list of string/nonzero-number tokens, e.g. + * ["alpha", 1"]. + * @param major Major version. + * @param minor Minor version. + * @param patch Patch version. + * @param prerelease Prerelease tokens. + */ + SemVer(VersionType major, + VersionType minor, + VersionType patch, + std::vector prerelease); + + /** + * Constructs a SemVer from a major, minor, and patch. + * @param major Major version. + * @param minor Minor version. + * @param patch Patch version. + */ + SemVer(VersionType major, VersionType minor, VersionType patch); + + [[nodiscard]] SemVer::VersionType Major() const; + [[nodiscard]] SemVer::VersionType Minor() const; + [[nodiscard]] SemVer::VersionType Patch() const; + + [[nodiscard]] std::optional> const& Prerelease() const; + + /** + * Attempts to parse a semantic version string, returning std::nullopt on + * failure. Build information is discarded. + * @param value Version string, e.g. "1.2.3-alpha.1". + * @return SemVer on success, or std::nullopt on failure. + */ + [[nodiscard]] static std::optional Parse(std::string const& value); + + private: + VersionType major_; + VersionType minor_; + VersionType patch_; + std::optional> prerelease_; +}; + +bool operator<(SemVer::Token const& lhs, SemVer::Token const& rhs); + +bool operator==(SemVer const& lhs, SemVer const& rhs); + +bool operator<(SemVer const& lhs, SemVer const& rhs); + +bool operator>(SemVer const& lhs, SemVer const& rhs); + +/** Prints a SemVer to an ostream. If the SemVer was parsed from a string + * containing a build string, it will not be present as this information + * is discarded when parsing. + */ +std::ostream& operator<<(std::ostream& out, SemVer const& sv); + +std::ostream& operator<<(std::ostream& out, SemVer::Token const& sv); + +} // namespace launchdarkly::server_side::evaluation::detail diff --git a/libs/server-sdk/src/evaluation/detail/timestamp_operations.cpp b/libs/server-sdk/src/evaluation/detail/timestamp_operations.cpp new file mode 100644 index 000000000..57389a6a6 --- /dev/null +++ b/libs/server-sdk/src/evaluation/detail/timestamp_operations.cpp @@ -0,0 +1,55 @@ +#include "timestamp_operations.hpp" + +#include "timestamp.h" + +#include +#include + +namespace launchdarkly::server_side::evaluation::detail { + +std::optional MillisecondsToTimepoint(double ms); + +std::optional RFC3339ToTimepoint(std::string const& timestamp); + +std::optional ToTimepoint(Value const& value) { + if (value.Type() == Value::Type::kNumber) { + double const epoch_ms = value.AsDouble(); + return MillisecondsToTimepoint(epoch_ms); + } + if (value.Type() == Value::Type::kString) { + std::string const& rfc3339_timestamp = value.AsString(); + return RFC3339ToTimepoint(rfc3339_timestamp); + } + return std::nullopt; +} + +std::optional MillisecondsToTimepoint(double ms) { + if (ms < 0.0) { + return std::nullopt; + } + if (std::trunc(ms) == ms) { + return std::chrono::system_clock::time_point{ + std::chrono::milliseconds{static_cast(ms)}}; + } + return std::nullopt; +} + +std::optional RFC3339ToTimepoint(std::string const& timestamp) { + if (timestamp.empty()) { + return std::nullopt; + } + + timestamp_t ts{}; + if (timestamp_parse(timestamp.c_str(), timestamp.size(), &ts)) { + return std::nullopt; + } + + Timepoint epoch{}; + epoch += std::chrono::seconds{ts.sec}; + epoch += + std::chrono::floor(std::chrono::nanoseconds{ts.nsec}); + + return epoch; +} + +} // namespace launchdarkly::server_side::evaluation::detail diff --git a/libs/server-sdk/src/evaluation/detail/timestamp_operations.hpp b/libs/server-sdk/src/evaluation/detail/timestamp_operations.hpp new file mode 100644 index 000000000..5c25b84dd --- /dev/null +++ b/libs/server-sdk/src/evaluation/detail/timestamp_operations.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include + +#include +#include +#include + +namespace launchdarkly::server_side::evaluation::detail { + +using Clock = std::chrono::system_clock; +using Timepoint = Clock::time_point; + +[[nodiscard]] std::optional ToTimepoint(Value const& value); + +} // namespace launchdarkly::server_side::evaluation::detail diff --git a/libs/server-sdk/src/evaluation/evaluation_error.cpp b/libs/server-sdk/src/evaluation/evaluation_error.cpp new file mode 100644 index 000000000..7f81c02f2 --- /dev/null +++ b/libs/server-sdk/src/evaluation/evaluation_error.cpp @@ -0,0 +1,62 @@ +#include "evaluation_error.hpp" +#include + +namespace launchdarkly::server_side::evaluation { + +Error::Error(char const* format, std::string arg) + : format_{format}, arg_{std::move(arg)} {} + +Error::Error(char const* format, std::int64_t arg) + : Error(format, std::to_string(arg)) {} + +Error::Error(char const* msg) : format_{msg}, arg_{std::nullopt} {} + +Error Error::CyclicSegmentReference(std::string segment_key) { + return { + "segment rule referencing segment \"%1%\" caused a circular " + "reference; this is probably a temporary condition due to an " + "incomplete update", + std::move(segment_key)}; +} + +Error Error::CyclicPrerequisiteReference(std::string prereq_key) { + return { + "prerequisite relationship to \"%1%\" caused a circular " + "reference; this is probably a temporary condition due to an " + "incomplete update", + std::move(prereq_key)}; +} + +Error Error::MissingSalt(std::string key) { + return {"\"%1%\" is missing a salt", std::move(key)}; +} + +Error Error::RolloutMissingVariations() { + return {"rollout or experiment with no variations"}; +} + +Error Error::InvalidAttributeReference(std::string ref) { + return {"invalid attribute reference: \"%1%\"", std::move(ref)}; +} + +Error Error::NonexistentVariationIndex(std::int64_t index) { + return { + "rule, fallthrough, or target referenced a nonexistent variation index " + "(%1%)", + index}; +} + +std::ostream& operator<<(std::ostream& out, Error const& err) { + if (err.arg_ == std::nullopt) { + out << err.format_; + } else { + out << boost::format(err.format_) % *err.arg_; + } + return out; +} + +bool operator==(Error const& lhs, Error const& rhs) { + return lhs.format_ == rhs.format_ && lhs.arg_ == rhs.arg_; +} + +} // namespace launchdarkly::server_side::evaluation diff --git a/libs/server-sdk/src/evaluation/evaluation_error.hpp b/libs/server-sdk/src/evaluation/evaluation_error.hpp new file mode 100644 index 000000000..69aa5ef51 --- /dev/null +++ b/libs/server-sdk/src/evaluation/evaluation_error.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include +#include +#include + +namespace launchdarkly::server_side::evaluation { + +class Error { + public: + static Error CyclicSegmentReference(std::string segment_key); + static Error CyclicPrerequisiteReference(std::string prereq_key); + static Error InvalidAttributeReference(std::string ref); + static Error RolloutMissingVariations(); + static Error NonexistentVariationIndex(std::int64_t index); + static Error MissingSalt(std::string item_key); + + friend std::ostream& operator<<(std::ostream& out, Error const& arr); + friend bool operator==(Error const& lhs, Error const& rhs); + + private: + Error(char const* format, std::string arg); + Error(char const* format, std::int64_t arg); + Error(char const* msg); + + char const* format_; + std::optional arg_; +}; + +} // namespace launchdarkly::server_side::evaluation diff --git a/libs/server-sdk/src/evaluation/evaluator.cpp b/libs/server-sdk/src/evaluation/evaluator.cpp new file mode 100644 index 000000000..97057fbd2 --- /dev/null +++ b/libs/server-sdk/src/evaluation/evaluator.cpp @@ -0,0 +1,225 @@ +#include "evaluator.hpp" +#include "rules.hpp" + +#include +#include + +#include +#include + +namespace launchdarkly::server_side::evaluation { + +using namespace data_model; + +std::optional AnyTargetMatchVariation( + launchdarkly::Context const& context, + Flag const& flag); + +std::optional TargetMatchVariation( + launchdarkly::Context const& context, + Flag::Target const& target); + +Evaluator::Evaluator(Logger& logger, data_store::IDataStore const& store) + : logger_(logger), store_(store), stack_() {} + +EvaluationDetail Evaluator::Evaluate( + data_model::Flag const& flag, + launchdarkly::Context const& context) { + return Evaluate(flag, context, EventScope{}); +} + +EvaluationDetail Evaluator::Evaluate( + Flag const& flag, + launchdarkly::Context const& context, + EventScope const& event_scope) { + return Evaluate(std::nullopt, flag, context, event_scope); +} + +EvaluationDetail Evaluator::Evaluate( + std::optional parent_key, + Flag const& flag, + launchdarkly::Context const& context, + EventScope const& event_scope) { + if (auto guard = stack_.NoticePrerequisite(flag.key)) { + if (!flag.on) { + return OffValue(flag, EvaluationReason::Off()); + } + + for (Flag::Prerequisite const& p : flag.prerequisites) { + std::shared_ptr maybe_flag = + store_.GetFlag(p.key); + + if (!maybe_flag) { + return OffValue(flag, + EvaluationReason::PrerequisiteFailed(p.key)); + } + + data_store::FlagDescriptor const& descriptor = *maybe_flag; + + if (!descriptor.item) { + // This flag existed at some point, but has since been deleted. + return OffValue(flag, + EvaluationReason::PrerequisiteFailed(p.key)); + } + + // Recursive call; cycles are detected by the guard. + EvaluationDetail detailed_evaluation = + Evaluate(flag.key, *descriptor.item, context, event_scope); + + if (detailed_evaluation.IsError()) { + return detailed_evaluation; + } + + std::optional variation_index = + detailed_evaluation.VariationIndex(); + + event_scope.Send([&](EventFactory const& factory) { + return factory.Eval(p.key, context, *descriptor.item, + detailed_evaluation, Value::Null(), + flag.key); + }); + + if (!descriptor.item->on || variation_index != p.variation) { + return OffValue(flag, + EvaluationReason::PrerequisiteFailed(p.key)); + } + } + } else { + LogError(parent_key.value_or("(no parent)"), + Error::CyclicPrerequisiteReference(flag.key)); + return EvaluationReason::MalformedFlag(); + } + + // If the flag is on, all prerequisites are on and valid, then + // determine if the context matches any targets. + // + // This happens before rule evaluation to ensure targets always have + // priority. + + if (auto variation_index = AnyTargetMatchVariation(context, flag)) { + return FlagVariation(flag, *variation_index, + EvaluationReason::TargetMatch()); + } + + for (std::size_t rule_index = 0; rule_index < flag.rules.size(); + rule_index++) { + auto const& rule = flag.rules[rule_index]; + + tl::expected rule_match = + Match(rule, context, store_, stack_); + + if (!rule_match) { + LogError(flag.key, rule_match.error()); + return EvaluationReason::MalformedFlag(); + } + + if (!(rule_match.value())) { + continue; + } + + tl::expected result = + Variation(rule.variationOrRollout, flag.key, context, flag.salt); + + if (!result) { + LogError(flag.key, result.error()); + return EvaluationReason::MalformedFlag(); + } + + EvaluationReason reason = EvaluationReason::RuleMatch( + rule_index, rule.id, result->InExperiment()); + + return FlagVariation(flag, result->VariationIndex(), std::move(reason)); + } + + // If there were no rule matches, then return the fallthrough variation. + + tl::expected result = + Variation(flag.fallthrough, flag.key, context, flag.salt); + + if (!result) { + LogError(flag.key, result.error()); + return EvaluationReason::MalformedFlag(); + } + + EvaluationReason reason = + EvaluationReason::Fallthrough(result->InExperiment()); + + return FlagVariation(flag, result->VariationIndex(), std::move(reason)); +} + +EvaluationDetail Evaluator::FlagVariation( + Flag const& flag, + Flag::Variation variation_index, + EvaluationReason reason) const { + if (variation_index < 0 || variation_index >= flag.variations.size()) { + LogError(flag.key, Error::NonexistentVariationIndex(variation_index)); + return EvaluationReason::MalformedFlag(); + } + + return {flag.variations.at(variation_index), variation_index, + std::move(reason)}; +} + +EvaluationDetail Evaluator::OffValue(Flag const& flag, + EvaluationReason reason) const { + if (flag.offVariation) { + return FlagVariation(flag, *flag.offVariation, std::move(reason)); + } + + return reason; +} + +std::optional AnyTargetMatchVariation( + launchdarkly::Context const& context, + Flag const& flag) { + if (flag.contextTargets.empty()) { + for (auto const& target : flag.targets) { + if (auto index = TargetMatchVariation(context, target)) { + return index; + } + } + return std::nullopt; + } + + for (auto const& context_target : flag.contextTargets) { + if (IsUser(context_target.contextKind) && + context_target.values.empty()) { + for (auto const& target : flag.targets) { + if (target.variation == context_target.variation) { + if (auto index = TargetMatchVariation(context, target)) { + return index; + } + } + } + } else if (auto index = TargetMatchVariation(context, context_target)) { + return index; + } + } + + return std::nullopt; +} + +std::optional TargetMatchVariation( + launchdarkly::Context const& context, + Flag::Target const& target) { + Value const& key = context.Get(target.contextKind, "key"); + if (key.IsNull()) { + return std::nullopt; + } + + for (auto const& value : target.values) { + if (value == key) { + return target.variation; + } + } + + return std::nullopt; +} + +void Evaluator::LogError(std::string const& key, Error const& error) const { + LD_LOG(logger_, LogLevel::kError) + << "Invalid flag configuration detected in flag \"" << key + << "\": " << error; +} + +} // namespace launchdarkly::server_side::evaluation diff --git a/libs/server-sdk/src/evaluation/evaluator.hpp b/libs/server-sdk/src/evaluation/evaluator.hpp new file mode 100644 index 000000000..cf7a15468 --- /dev/null +++ b/libs/server-sdk/src/evaluation/evaluator.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "../data_store/data_store.hpp" +#include "../events/event_scope.hpp" +#include "bucketing.hpp" +#include "detail/evaluation_stack.hpp" +#include "evaluation_error.hpp" + +#include + +namespace launchdarkly::server_side::evaluation { + +class Evaluator { + public: + Evaluator(Logger& logger, data_store::IDataStore const& store); + + /** + * Evaluates a flag for a given context. + * Warning: not thread safe. + * + * @param flag The flag to evaluate. + * @param context The context to evaluate the flag against. + * @param event_scope The event scope used for recording prerequisite + * events. + */ + [[nodiscard]] EvaluationDetail Evaluate( + data_model::Flag const& flag, + launchdarkly::Context const& context, + EventScope const& event_scope); + + /** + * Evaluates a flag for a given context. Does not record prerequisite + * events. Warning: not thread safe. + * + * @param flag The flag to evaluate. + * @param context The context to evaluate the flag against. + */ + [[nodiscard]] EvaluationDetail Evaluate( + data_model::Flag const& flag, + launchdarkly::Context const& context); + + private: + [[nodiscard]] EvaluationDetail Evaluate( + std::optional parent_key, + data_model::Flag const& flag, + launchdarkly::Context const& context, + EventScope const& event_scope); + + [[nodiscard]] EvaluationDetail FlagVariation( + data_model::Flag const& flag, + data_model::Flag::Variation variation_index, + EvaluationReason reason) const; + + [[nodiscard]] EvaluationDetail OffValue( + data_model::Flag const& flag, + EvaluationReason reason) const; + + void LogError(std::string const& key, Error const& error) const; + + Logger& logger_; + data_store::IDataStore const& store_; + detail::EvaluationStack stack_; +}; +} // namespace launchdarkly::server_side::evaluation diff --git a/libs/server-sdk/src/evaluation/operators.cpp b/libs/server-sdk/src/evaluation/operators.cpp new file mode 100644 index 000000000..31ceb960f --- /dev/null +++ b/libs/server-sdk/src/evaluation/operators.cpp @@ -0,0 +1,146 @@ +#include "operators.hpp" +#include "detail/semver_operations.hpp" +#include "detail/timestamp_operations.hpp" + +#include + +namespace launchdarkly::server_side::evaluation::operators { + +template +bool StringOp(Value const& context_value, + Value const& clause_value, + Callable&& op) { + if (!(context_value.Type() == Value::Type::kString && + clause_value.Type() == Value::Type::kString)) { + return false; + } + return op(context_value.AsString(), clause_value.AsString()); +} + +template +bool SemverOp(Value const& context_value, + Value const& clause_value, + Callable&& op) { + return StringOp(context_value, clause_value, + [op = std::move(op)](std::string const& context, + std::string const& clause) { + auto context_semver = detail::SemVer::Parse(context); + if (!context_semver) { + return false; + } + + auto clause_semver = detail::SemVer::Parse(clause); + if (!clause_semver) { + return false; + } + + return op(*context_semver, *clause_semver); + }); +} + +template +bool TimeOp(Value const& context_value, + Value const& clause_value, + Callable&& op) { + auto context_tp = detail::ToTimepoint(context_value); + if (!context_tp) { + return false; + } + auto clause_tp = detail::ToTimepoint(clause_value); + if (!clause_tp) { + return false; + } + return op(*context_tp, *clause_tp); +} + +bool StartsWith(std::string const& context_value, + std::string const& clause_value) { + return clause_value.size() <= context_value.size() && + std::equal(clause_value.begin(), clause_value.end(), + context_value.begin()); +} + +bool EndsWith(std::string const& context_value, + std::string const& clause_value) { + return clause_value.size() <= context_value.size() && + std::equal(clause_value.rbegin(), clause_value.rend(), + context_value.rbegin()); +} + +bool Contains(std::string const& context_value, + std::string const& clause_value) { + return context_value.find(clause_value) != std::string::npos; +} + +/* RegexMatch uses boost::regex instead of std::regex, because the former + * appears to be significantly more performant according to boost benchmarks. + * For more information, see here: + * https://www.boost.org/doc/libs/1_82_0/libs/regex/doc/html/boost_regex/background/performance.html + */ +bool RegexMatch(std::string const& context_value, + std::string const& clause_value) { + /* See here for FAQ on boost::regex exceptions: + * https:// + * www.boost.org/doc/libs/1_82_0/libs/regex/doc/html/boost_regex/background/faq.html + * */ + try { + return boost::regex_search(context_value, boost::regex(clause_value)); + } catch (boost::bad_expression) { + // boost::bad_expression can be thrown by basic_regex when compiling a + // regular expression. + return false; + } catch (std::runtime_error) { + // std::runtime_error can be thrown when a call + // to regex_search results in an "everlasting" search + return false; + } +} + +bool Match(data_model::Clause::Op op, + Value const& context_value, + Value const& clause_value) { + switch (op) { + case data_model::Clause::Op::kIn: + return context_value == clause_value; + case data_model::Clause::Op::kStartsWith: + return StringOp(context_value, clause_value, StartsWith); + case data_model::Clause::Op::kEndsWith: + return StringOp(context_value, clause_value, EndsWith); + case data_model::Clause::Op::kMatches: + return StringOp(context_value, clause_value, RegexMatch); + case data_model::Clause::Op::kContains: + return StringOp(context_value, clause_value, Contains); + case data_model::Clause::Op::kLessThan: + return context_value < clause_value; + case data_model::Clause::Op::kLessThanOrEqual: + return context_value <= clause_value; + case data_model::Clause::Op::kGreaterThan: + return context_value > clause_value; + case data_model::Clause::Op::kGreaterThanOrEqual: + return context_value >= clause_value; + case data_model::Clause::Op::kBefore: + return TimeOp( + context_value, clause_value, + [](auto const& lhs, auto const& rhs) { return lhs < rhs; }); + case data_model::Clause::Op::kAfter: + return TimeOp( + context_value, clause_value, + [](auto const& lhs, auto const& rhs) { return lhs > rhs; }); + case data_model::Clause::Op::kSemVerEqual: + return SemverOp( + context_value, clause_value, + [](auto const& lhs, auto const& rhs) { return lhs == rhs; }); + case data_model::Clause::Op::kSemVerLessThan: + return SemverOp( + context_value, clause_value, + [](auto const& lhs, auto const& rhs) { return lhs < rhs; }); + case data_model::Clause::Op::kSemVerGreaterThan: + return SemverOp( + context_value, clause_value, + [](auto const& lhs, auto const& rhs) { return lhs > rhs; }); + default: + return false; + } +} + +} // namespace launchdarkly::server_side::evaluation::operators diff --git a/libs/server-sdk/src/evaluation/operators.hpp b/libs/server-sdk/src/evaluation/operators.hpp new file mode 100644 index 000000000..159c1d614 --- /dev/null +++ b/libs/server-sdk/src/evaluation/operators.hpp @@ -0,0 +1,10 @@ +#pragma once +#include + +namespace launchdarkly::server_side::evaluation::operators { + +bool Match(data_model::Clause::Op op, + Value const& context_value, + Value const& clause_value); + +} // namespace launchdarkly::server_side::evaluation::operators diff --git a/libs/server-sdk/src/evaluation/rules.cpp b/libs/server-sdk/src/evaluation/rules.cpp new file mode 100644 index 000000000..313cc44b1 --- /dev/null +++ b/libs/server-sdk/src/evaluation/rules.cpp @@ -0,0 +1,214 @@ +#include "rules.hpp" +#include "bucketing.hpp" +#include "operators.hpp" + +namespace launchdarkly::server_side::evaluation { + +using namespace data_model; + +bool MaybeNegate(Clause const& clause, bool value) { + if (clause.negate) { + return !value; + } + return value; +} + +tl::expected Match(Flag::Rule const& rule, + launchdarkly::Context const& context, + data_store::IDataStore const& store, + detail::EvaluationStack& stack) { + for (Clause const& clause : rule.clauses) { + tl::expected result = Match(clause, context, store, stack); + if (!result) { + return result; + } + if (!(result.value())) { + return false; + } + } + return true; +} + +tl::expected Match(Segment::Rule const& rule, + Context const& context, + data_store::IDataStore const& store, + detail::EvaluationStack& stack, + std::string const& key, + std::string const& salt) { + for (Clause const& clause : rule.clauses) { + auto maybe_match = Match(clause, context, store, stack); + if (!maybe_match) { + return tl::make_unexpected(maybe_match.error()); + } + if (!(maybe_match.value())) { + return false; + } + } + + if (rule.weight && rule.weight >= 0.0) { + BucketPrefix prefix(key, salt); + auto maybe_bucket = Bucket(context, rule.bucketBy, prefix, false, + rule.rolloutContextKind); + if (!maybe_bucket) { + return tl::make_unexpected(maybe_bucket.error()); + } + auto [bucket, ignored] = *maybe_bucket; + return bucket < (*rule.weight / kBucketScale); + } + + return true; +} + +tl::expected Match(Clause const& clause, + launchdarkly::Context const& context, + data_store::IDataStore const& store, + detail::EvaluationStack& stack) { + if (clause.op == Clause::Op::kSegmentMatch) { + return MatchSegment(clause, context, store, stack); + } + return MatchNonSegment(clause, context); +} + +tl::expected MatchSegment(Clause const& clause, + launchdarkly::Context const& context, + data_store::IDataStore const& store, + detail::EvaluationStack& stack) { + for (Value const& value : clause.values) { + // A segment key represented as a Value is a string; non-strings are + // ignored. + if (value.Type() != Value::Type::kString) { + continue; + } + + std::string const& segment_key = value.AsString(); + + std::shared_ptr segment_ptr = + store.GetSegment(segment_key); + + if (!segment_ptr || !segment_ptr->item) { + // Segments that don't exist are ignored. + continue; + } + + auto maybe_contains = + Contains(*segment_ptr->item, context, store, stack); + + if (!maybe_contains) { + return tl::make_unexpected(maybe_contains.error()); + } + + if (maybe_contains.value()) { + return MaybeNegate(clause, true); + } + } + + return MaybeNegate(clause, false); +} + +tl::expected MatchNonSegment( + Clause const& clause, + launchdarkly::Context const& context) { + if (!clause.attribute.Valid()) { + return tl::make_unexpected( + Error::InvalidAttributeReference(clause.attribute.RedactionName())); + } + + if (clause.attribute.IsKind()) { + for (auto const& clause_value : clause.values) { + for (auto const& kind : context.Kinds()) { + if (operators::Match(clause.op, kind, clause_value)) { + return MaybeNegate(clause, true); + } + } + } + return MaybeNegate(clause, false); + } + + Value const& attribute = context.Get(clause.contextKind, clause.attribute); + if (attribute.IsNull()) { + return false; + } + + if (attribute.IsArray()) { + for (Value const& clause_value : clause.values) { + for (Value const& context_value : attribute.AsArray()) { + if (operators::Match(clause.op, context_value, clause_value)) { + return MaybeNegate(clause, true); + } + } + } + return MaybeNegate(clause, false); + } + + if (std::any_of(clause.values.begin(), clause.values.end(), + [&](Value const& clause_value) { + return operators::Match(clause.op, attribute, + clause_value); + })) { + return MaybeNegate(clause, true); + } + + return MaybeNegate(clause, false); +} + +tl::expected Contains(Segment const& segment, + Context const& context, + data_store::IDataStore const& store, + detail::EvaluationStack& stack) { + auto guard = stack.NoticeSegment(segment.key); + if (!guard) { + return tl::make_unexpected(Error::CyclicSegmentReference(segment.key)); + } + + if (segment.unbounded) { + // TODO(sc209881): set big segment status to NOT_CONFIGURED. + return false; + } + + if (IsTargeted(context, segment.included, segment.includedContexts)) { + return true; + } + + if (IsTargeted(context, segment.excluded, segment.excludedContexts)) { + return false; + } + + for (auto const& rule : segment.rules) { + if (!segment.salt) { + return tl::make_unexpected(Error::MissingSalt(segment.key)); + } + tl::expected maybe_match = + Match(rule, context, store, stack, segment.key, *segment.salt); + if (!maybe_match) { + return tl::make_unexpected(maybe_match.error()); + } + if (maybe_match.value()) { + return true; + } + } + + return false; +} + +bool IsTargeted(Context const& context, + std::vector const& user_keys, + std::vector const& context_targets) { + for (auto const& target : context_targets) { + Value const& key = context.Get(target.contextKind, "key"); + if (!key.IsString()) { + continue; + } + if (std::find(target.values.begin(), target.values.end(), key) != + target.values.end()) { + return true; + } + } + + if (auto key = context.Get("user", "key"); !key.IsNull()) { + return std::find(user_keys.begin(), user_keys.end(), key.AsString()) != + user_keys.end(); + } + + return false; +} +} // namespace launchdarkly::server_side::evaluation diff --git a/libs/server-sdk/src/evaluation/rules.hpp b/libs/server-sdk/src/evaluation/rules.hpp new file mode 100644 index 000000000..5ca9a974e --- /dev/null +++ b/libs/server-sdk/src/evaluation/rules.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include + +#include "../data_store/data_store.hpp" +#include "detail/evaluation_stack.hpp" +#include "evaluation_error.hpp" + +#include + +#include + +namespace launchdarkly::server_side::evaluation { + +[[nodiscard]] tl::expected Match( + data_model::Flag::Rule const&, + Context const&, + data_store::IDataStore const& store, + detail::EvaluationStack& stack); + +[[nodiscard]] tl::expected Match(data_model::Clause const&, + Context const&, + data_store::IDataStore const&, + detail::EvaluationStack&); + +[[nodiscard]] tl::expected Match( + data_model::Segment::Rule const& rule, + Context const& context, + data_store::IDataStore const& store, + detail::EvaluationStack& stack, + std::string const& key, + std::string const& salt); + +[[nodiscard]] tl::expected MatchSegment( + data_model::Clause const&, + Context const&, + data_store::IDataStore const&, + detail::EvaluationStack& stack); + +[[nodiscard]] tl::expected MatchNonSegment( + data_model::Clause const&, + Context const&); + +[[nodiscard]] tl::expected Contains( + data_model::Segment const&, + Context const&, + data_store::IDataStore const& store, + detail::EvaluationStack& stack); + +[[nodiscard]] bool MaybeNegate(data_model::Clause const& clause, bool value); + +[[nodiscard]] bool IsTargeted(Context const&, + std::vector const&, + std::vector const&); + +[[nodiscard]] bool IsUser(Context const& context); + +} // namespace launchdarkly::server_side::evaluation diff --git a/libs/server-sdk/src/events/event_factory.cpp b/libs/server-sdk/src/events/event_factory.cpp new file mode 100644 index 000000000..2be43f6fe --- /dev/null +++ b/libs/server-sdk/src/events/event_factory.cpp @@ -0,0 +1,94 @@ +#include "event_factory.hpp" + +#include +namespace launchdarkly::server_side { + +EventFactory::EventFactory( + launchdarkly::server_side::EventFactory::ReasonPolicy reason_policy) + : reason_policy_(reason_policy), + now_([]() { return events::Date{std::chrono::system_clock::now()}; }) {} + +EventFactory EventFactory::WithReasons() { + return {ReasonPolicy::Require}; +} + +EventFactory EventFactory::WithoutReasons() { + return {ReasonPolicy::Default}; +} + +events::InputEvent EventFactory::UnknownFlag( + std::string const& key, + launchdarkly::Context const& ctx, + EvaluationDetail detail, + launchdarkly::Value default_val) const { + return FeatureRequest(key, ctx, std::nullopt, detail, default_val, + std::nullopt); +} + +events::InputEvent EventFactory::Eval( + std::string const& key, + Context const& ctx, + std::optional const& flag, + EvaluationDetail detail, + Value default_value, + std::optional prereq_of) const { + return FeatureRequest(key, ctx, flag, detail, default_value, prereq_of); +} + +events::InputEvent EventFactory::Identify(launchdarkly::Context ctx) const { + return events::IdentifyEventParams{now_(), std::move(ctx)}; +} + +events::InputEvent EventFactory::Custom( + Context const& ctx, + std::string event_name, + std::optional data, + std::optional metric_value) const { + return events::ServerTrackEventParams{ + {now_(), std::move(event_name), ctx.KindsToKeys(), std::move(data), + metric_value}, + ctx}; +} + +events::InputEvent EventFactory::FeatureRequest( + std::string const& key, + launchdarkly::Context const& context, + std::optional const& flag, + EvaluationDetail detail, + launchdarkly::Value default_val, + std::optional prereq_of) const { + bool flag_track_events = false; + bool require_experiment_data = false; + std::optional debug_events_until_date; + + if (flag.has_value()) { + flag_track_events = flag->trackEvents; + require_experiment_data = + flag->IsExperimentationEnabled(detail.Reason()); + if (flag->debugEventsUntilDate) { + debug_events_until_date = + events::Date{std::chrono::system_clock::time_point{ + std::chrono::milliseconds(*flag->debugEventsUntilDate)}}; + } + } + + std::optional reason; + if (reason_policy_ == ReasonPolicy::Require || require_experiment_data) { + reason = detail.Reason(); + } + + return events::FeatureEventParams{ + now_(), + key, + context, + detail.Value(), + default_val, + flag.has_value() ? std::make_optional(flag->version) : std::nullopt, + detail.VariationIndex(), + reason, + flag_track_events || require_experiment_data, + debug_events_until_date, + prereq_of}; +} + +} // namespace launchdarkly::server_side diff --git a/libs/server-sdk/src/events/event_factory.hpp b/libs/server-sdk/src/events/event_factory.hpp new file mode 100644 index 000000000..e9a10eec3 --- /dev/null +++ b/libs/server-sdk/src/events/event_factory.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +namespace launchdarkly::server_side { + +class EventFactory { + enum class ReasonPolicy { + Default = 0, + Require = 1, + }; + + public: + [[nodiscard]] static EventFactory WithReasons(); + [[nodiscard]] static EventFactory WithoutReasons(); + + [[nodiscard]] events::InputEvent UnknownFlag(std::string const& key, + Context const& ctx, + EvaluationDetail detail, + Value default_val) const; + + [[nodiscard]] events::InputEvent Eval( + std::string const& key, + Context const& ctx, + std::optional const& flag, + EvaluationDetail detail, + Value default_value, + std::optional prereq_of) const; + + [[nodiscard]] events::InputEvent Identify(Context ctx) const; + + [[nodiscard]] events::InputEvent Custom( + Context const& ctx, + std::string event_name, + std::optional data, + std::optional metric_value) const; + + private: + EventFactory(ReasonPolicy reason_policy); + events::InputEvent FeatureRequest( + std::string const& key, + Context const& ctx, + std::optional const& flag, + EvaluationDetail detail, + Value default_val, + std::optional prereq_of) const; + + ReasonPolicy const reason_policy_; + std::function now_; +}; +} // namespace launchdarkly::server_side diff --git a/libs/server-sdk/src/events/event_scope.hpp b/libs/server-sdk/src/events/event_scope.hpp new file mode 100644 index 000000000..2eb32ade4 --- /dev/null +++ b/libs/server-sdk/src/events/event_scope.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include + +#include "event_factory.hpp" + +namespace launchdarkly::server_side { + +/** + * EventScope is responsible for forwarding events to an + * IEventProcessor. If the given interface is nullptr, then events will not + * be forwarded at all. + */ +class EventScope { + public: + /** + * Constructs an EventScope with a non-owned IEventProcessor and factory. + * When Send is called, the factory will be passed to the caller, which must + * return a constructed event. + * @param processor The event processor to forward events to. + * @param factory The factory used for generating events. + */ + EventScope(events::IEventProcessor* processor, EventFactory factory) + : processor_(processor), factory_(std::move(factory)) {} + + /** + * Default constructs an EventScope which will not forward events. + */ + EventScope() : EventScope(nullptr, EventFactory::WithoutReasons()) {} + + /** + * Sends an event created by the given callable. The callable will be + * passed an EventFactory. + * @param callable Returns an InputEvent. + */ + template + void Send(Callable&& callable) const { + if (processor_) { + processor_->SendAsync(callable(factory_)); + } + } + + private: + events::IEventProcessor* processor_; + EventFactory const factory_; +}; + +} // namespace launchdarkly::server_side diff --git a/libs/server-sdk/tests/CMakeLists.txt b/libs/server-sdk/tests/CMakeLists.txt new file mode 100644 index 000000000..707abb839 --- /dev/null +++ b/libs/server-sdk/tests/CMakeLists.txt @@ -0,0 +1,20 @@ +cmake_minimum_required(VERSION 3.10) +include(GoogleTest) + +include_directories("${PROJECT_SOURCE_DIR}/include") +include_directories("${PROJECT_SOURCE_DIR}/src") + +file(GLOB tests "${PROJECT_SOURCE_DIR}/tests/*.cpp") + +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}) + +# Get things in the same directory on windows. +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}../") +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}../") + +add_executable(gtest_${LIBNAME} + ${tests} + ) +target_link_libraries(gtest_${LIBNAME} launchdarkly::server launchdarkly::internal launchdarkly::sse timestamp GTest::gtest_main) + +gtest_discover_tests(gtest_${LIBNAME}) diff --git a/libs/server-sdk/tests/all_flags_state_test.cpp b/libs/server-sdk/tests/all_flags_state_test.cpp new file mode 100644 index 000000000..c08a4fe54 --- /dev/null +++ b/libs/server-sdk/tests/all_flags_state_test.cpp @@ -0,0 +1,150 @@ +#include + +#include "all_flags_state/all_flags_state_builder.hpp" + +#include + +using namespace launchdarkly; +using namespace launchdarkly::server_side; + +TEST(AllFlagsTest, Empty) { + AllFlagsStateBuilder builder{AllFlagsState::Options::Default}; + auto state = builder.Build(); + ASSERT_TRUE(state.Valid()); + ASSERT_TRUE(state.States().empty()); + ASSERT_TRUE(state.Values().empty()); +} + +TEST(AllFlagsTest, DefaultOptions) { + AllFlagsStateBuilder builder{AllFlagsState::Options::Default}; + + builder.AddFlag( + "myFlag", true, + AllFlagsState::State{42, 1, std::nullopt, false, false, std::nullopt}); + + auto state = builder.Build(); + ASSERT_TRUE(state.Valid()); + + auto expected = boost::json::parse(R"({ + "myFlag": true, + "$flagsState": { + "myFlag": { + "version": 42, + "variation": 1 + } + }, + "$valid": true + })"); + + auto got = boost::json::value_from(state); + ASSERT_EQ(got, expected); +} + +TEST(AllFlagsTest, DetailsOnlyForTrackedFlags) { + AllFlagsStateBuilder builder{ + AllFlagsState::Options::DetailsOnlyForTrackedFlags}; + builder.AddFlag( + "myFlagTracked", true, + AllFlagsState::State{42, 1, EvaluationReason::Fallthrough(false), true, + true, std::nullopt}); + builder.AddFlag( + "myFlagUntracked", true, + AllFlagsState::State{42, 1, EvaluationReason::Fallthrough(false), false, + false, std::nullopt}); + + auto state = builder.Build(); + ASSERT_TRUE(state.Valid()); + + auto expected = boost::json::parse(R"({ + "myFlagTracked" : true, + "myFlagUntracked" : true, + "$flagsState": { + "myFlagTracked": { + "version": 42, + "variation": 1, + "reason":{ + "kind" : "FALLTHROUGH" + }, + "trackReason" : true, + "trackEvents" : true + }, + "myFlagUntracked" : { + "variation" : 1 + } + }, + "$valid": true + })"); + + auto got = boost::json::value_from(state); + ASSERT_EQ(got, expected); +} + +TEST(AllFlagsTest, IncludeReasons) { + AllFlagsStateBuilder builder{AllFlagsState::Options::IncludeReasons}; + builder.AddFlag( + "myFlag", true, + AllFlagsState::State{42, 1, EvaluationReason::Fallthrough(false), false, + false, std::nullopt}); + auto state = builder.Build(); + ASSERT_TRUE(state.Valid()); + + auto expected = boost::json::parse(R"({ + "myFlag": true, + "$flagsState": { + "myFlag": { + "version": 42, + "variation": 1, + "reason" : { + "kind": "FALLTHROUGH" + } + } + }, + "$valid": true + })"); + + auto got = boost::json::value_from(state); + ASSERT_EQ(got, expected); +} + +TEST(AllFlagsTest, FlagValues) { + AllFlagsStateBuilder builder{AllFlagsState::Options::Default}; + + const std::size_t kNumFlags = 10; + + for (std::size_t i = 0; i < kNumFlags; i++) { + builder.AddFlag("myFlag" + std::to_string(i), "value", + AllFlagsState::State{42, 1, std::nullopt, false, false, + std::nullopt}); + } + + auto state = builder.Build(); + + auto const& vals = state.Values(); + + ASSERT_EQ(vals.size(), kNumFlags); + + ASSERT_TRUE(std::all_of(vals.begin(), vals.end(), [](auto const& kvp) { + return kvp.second.AsString() == "value"; + })); +} + +TEST(AllFlagsTest, FlagState) { + AllFlagsStateBuilder builder{AllFlagsState::Options::Default}; + + const std::size_t kNumFlags = 10; + + AllFlagsState::State state{42, 1, std::nullopt, false, false, std::nullopt}; + for (std::size_t i = 0; i < kNumFlags; i++) { + builder.AddFlag("myFlag" + std::to_string(i), "value", state); + } + + auto all_flags_state = builder.Build(); + + auto const& states = all_flags_state.States(); + + ASSERT_EQ(states.size(), kNumFlags); + + ASSERT_TRUE(std::all_of(states.begin(), states.end(), [&](auto const& kvp) { + return kvp.second == state; + })); +} diff --git a/libs/server-sdk/tests/bucketing_tests.cpp b/libs/server-sdk/tests/bucketing_tests.cpp new file mode 100644 index 000000000..23d63b4ae --- /dev/null +++ b/libs/server-sdk/tests/bucketing_tests.cpp @@ -0,0 +1,367 @@ +#include "evaluation/bucketing.hpp" +#include "evaluation/evaluator.hpp" + +#include +#include + +using namespace launchdarkly; +using namespace launchdarkly::data_model; +using namespace launchdarkly::server_side::evaluation; +using WeightedVariation = Flag::Rollout::WeightedVariation; + +/** + * Note: These tests are meant to be exact duplicates of tests + * in other SDKs. Do not change any of the values unless they + * are also changed in other SDKs. These are not traditional behavioral + * tests so much as consistency tests to guarantee that the implementation + * is identical across SDKs. + * + * Tests in this file may derive from BucketingConsistencyTests to gain access + * to shared constants. + */ +class BucketingConsistencyTests : public ::testing::Test { + public: + // Bucket results must be no more than this distance from the expected + // value. + double const kBucketTolerance = 0.0000001; + const std::string kHashKey = "hashKey"; + const std::string kSalt = "saltyA"; +}; + +TEST_F(BucketingConsistencyTests, BucketContextByKey) { + const BucketPrefix kPrefix{kHashKey, kSalt}; + + auto tests = + std::vector>{{"userKeyA", 0.42157587}, + {"userKeyB", 0.6708485}, + {"userKeyC", 0.10343106}}; + + for (auto [key, bucket] : tests) { + auto context = ContextBuilder().Kind("user", key).Build(); + auto result = Bucket(context, "key", kPrefix, false, "user"); + ASSERT_TRUE(result) + << key << " should be bucketed but got " << result.error(); + + ASSERT_NEAR(result->first, bucket, kBucketTolerance); + } +} + +TEST_F(BucketingConsistencyTests, BucketContextByKeyWithSeed) { + const BucketPrefix kPrefix{61}; + + auto tests = + std::vector>{{"userKeyA", 0.09801207}, + {"userKeyB", 0.14483777}, + {"userKeyC", 0.9242641}}; + + for (auto [key, bucket] : tests) { + auto context = ContextBuilder().Kind("user", key).Build(); + auto result = Bucket(context, "key", kPrefix, false, "user"); + ASSERT_TRUE(result) + << key << " should be bucketed but got " << result.error(); + + ASSERT_NEAR(result->first, bucket, kBucketTolerance); + + auto result_different_seed = + Bucket(context, "key", BucketPrefix{60}, false, "user"); + ASSERT_TRUE(result_different_seed) + << key << " should be bucketed but got " + << result_different_seed.error(); + + ASSERT_NE(result_different_seed->first, result->first); + } +} + +TEST_F(BucketingConsistencyTests, BucketContextByInvalidReference) { + const BucketPrefix kPrefix{kHashKey, kSalt}; + const AttributeReference kInvalidRef; + + ASSERT_FALSE(kInvalidRef.Valid()); + + auto context = ContextBuilder().Kind("user", "userKeyA").Build(); + auto result = Bucket(context, kInvalidRef, kPrefix, false, "user"); + + ASSERT_FALSE(result); + ASSERT_EQ(result.error(), + Error::InvalidAttributeReference(kInvalidRef.RedactionName())); +} + +TEST_F(BucketingConsistencyTests, BucketContextByIntAttribute) { + const std::string kUserKey = "userKeyD"; + const BucketPrefix kPrefix{kHashKey, kSalt}; + + auto context = + ContextBuilder().Kind("user", kUserKey).Set("intAttr", 33'333).Build(); + auto result = Bucket(context, "intAttr", kPrefix, false, "user"); + + ASSERT_TRUE(result) << kUserKey << " should be bucketed but got " + << result.error(); + ASSERT_NEAR(result->first, 0.54771423, kBucketTolerance); +} + +TEST_F(BucketingConsistencyTests, BucketContextByStringifiedIntAttribute) { + const std::string kUserKey = "userKeyD"; + const BucketPrefix kPrefix{kHashKey, kSalt}; + + auto context = ContextBuilder() + .Kind("user", kUserKey) + .Set("stringAttr", "33333") + .Build(); + auto result = Bucket(context, "stringAttr", kPrefix, false, "user"); + ASSERT_TRUE(result) << kUserKey << " should be bucketed but got " + << result.error(); + ASSERT_NEAR(result->first, 0.54771423, kBucketTolerance); +} + +TEST_F(BucketingConsistencyTests, BucketContextByFloatAttributeNotAllowed) { + const std::string kUserKey = "userKeyE"; + const BucketPrefix kPrefix{kHashKey, kSalt}; + + auto context = ContextBuilder() + .Kind("user", kUserKey) + .Set("floatAttr", 999.999) + .Build(); + auto result = Bucket(context, "floatAttr", kPrefix, false, "user"); + + ASSERT_TRUE(result) << kUserKey << " should be bucketed but got " + << result.error(); + ASSERT_NEAR(result->first, 0.0, kBucketTolerance); +} + +TEST_F(BucketingConsistencyTests, BucketContextByFloatAttributeThatIsInteger) { + const std::string kUserKey = "userKeyE"; + const BucketPrefix kPrefix{kHashKey, kSalt}; + + auto context = ContextBuilder() + .Kind("user", kUserKey) + .Set("floatAttr", 33333.0) + .Build(); + auto result = Bucket(context, "floatAttr", kPrefix, false, "user"); + + ASSERT_TRUE(result) << kUserKey << " should be bucketed but got " + << result.error(); + ASSERT_NEAR(result->first, 0.54771423, kBucketTolerance); +} + +// Parameterized tests may be instantiated with one or more BucketTests for +// convenience. +struct BucketTest { + // Context key. + std::string key; + // Expected bucket value as a string; this is only used for printing on + // error. + std::string expectedBucket; + // Expected computed variation index. + Flag::Variation expectedVariation; + // Whether the context was determined to be in an experiment. + bool expectedInExperiment; + // The rollout used for the test, which may be a percent rollout or an + // experiment. + Flag::Rollout rollout; +}; + +#define IN_EXPERIMENT true +#define NOT_IN_EXPERIMENT false + +class BucketVariationTest : public BucketingConsistencyTests, + public ::testing::WithParamInterface {}; + +static Flag::Rollout PercentRollout() { + WeightedVariation wv0(0, 60'000); + WeightedVariation wv1(1, 40'000); + return Flag::Rollout({wv0, wv1}); +} + +static Flag::Rollout ExperimentRollout() { + auto wv0 = WeightedVariation(0, 10'000); + auto wv1 = WeightedVariation(1, 20'000); + auto wv0_untracked = WeightedVariation::Untracked(0, 70'000); + Flag::Rollout rollout({wv0, wv1, wv0_untracked}); + rollout.kind = Flag::Rollout::Kind::kExperiment; + rollout.seed = 61; + return rollout; +} + +static Flag::Rollout IncompleteWeighting() { + WeightedVariation wv0(0, 1); + WeightedVariation wv1(1, 2); + WeightedVariation wv2(2, 3); + + return Flag::Rollout({wv0, wv1, wv2}); +} + +TEST_P(BucketVariationTest, VariationIndexForContext) { + auto const& param = GetParam(); + + auto result = + Variation(param.rollout, kHashKey, + ContextBuilder().Kind("user", param.key).Build(), kSalt); + + ASSERT_TRUE(result) << param.key + << " should be assigned a bucket result, but got: " + << result.error(); + + ASSERT_EQ(result->VariationIndex(), param.expectedVariation) + << param.key << " (bucket " << param.expectedBucket + << ") should get variation " << param.expectedVariation << ", but got " + << result->VariationIndex(); + + ASSERT_EQ(result->InExperiment(), param.expectedInExperiment) + << param.key << " " + << (param.expectedInExperiment ? "should" : "should not") + << " be in experiment"; +} + +INSTANTIATE_TEST_SUITE_P( + PercentRollout, + BucketVariationTest, + ::testing::ValuesIn({BucketTest{"userKeyA", "0.42157587", 0, + NOT_IN_EXPERIMENT, PercentRollout()}, + BucketTest{"userKeyB", "0.6708485", 1, + NOT_IN_EXPERIMENT, PercentRollout()}, + BucketTest{"userKeyC", "0.10343106", 0, + NOT_IN_EXPERIMENT, PercentRollout()}})); + +INSTANTIATE_TEST_SUITE_P(ExperimentRollout, + BucketVariationTest, + ::testing::ValuesIn({ + BucketTest{"userKeyA", "0.09801207", 0, + IN_EXPERIMENT, ExperimentRollout()}, + BucketTest{"userKeyB", "0.14483777", 1, + IN_EXPERIMENT, ExperimentRollout()}, + BucketTest{"userKeyC", "0.9242641", 0, + NOT_IN_EXPERIMENT, ExperimentRollout()}, + })); + +INSTANTIATE_TEST_SUITE_P(IncompleteWeightingDefaultsToLastVariation, + BucketVariationTest, + ::testing::ValuesIn({ + BucketTest{"userKeyD", "0.7816281", 2, + NOT_IN_EXPERIMENT, + IncompleteWeighting()}, + })); + +#undef IN_EXPERIMENT +#undef NOT_IN_EXPERIMENT + +TEST_F(BucketingConsistencyTests, VariationIndexForContextWithCustomAttribute) { + WeightedVariation wv0(0, 60'000); + WeightedVariation wv1(1, 40'000); + + Flag::Rollout rollout({wv0, wv1}); + rollout.bucketBy = "intAttr"; + + auto tests = std::vector>{ + {33'333, 0, "0.54771423"}, {99'999, 1, "0.7309658"}}; + + for (auto [bucketAttr, expectedVariation, expectedBucket] : tests) { + auto result = Variation(rollout, kHashKey, + ContextBuilder() + .Kind("user", "userKeyA") + .Set("intAttr", bucketAttr) + .Build(), + kSalt); + + ASSERT_TRUE(result) + << "userKeyA should be assigned a bucket result, but got: " + << result.error(); + + ASSERT_EQ(result->VariationIndex(), expectedVariation) + << "userKeyA (bucket " << expectedBucket + << ") should get variation " << expectedVariation << ", but got " + << result->VariationIndex(); + + ASSERT_EQ(result->InExperiment(), false) + << "userKeyA should not be in experiment"; + } +} + +struct ExperimentBucketTest { + std::optional seed; + std::string key; + Flag::Variation expectedVariationIndex; +}; + +class ExperimentBucketingTests + : public BucketingConsistencyTests, + public ::testing::WithParamInterface {}; + +TEST_P(ExperimentBucketingTests, VariationIndexForExperiment) { + auto const& params = GetParam(); + + WeightedVariation wv0(0, 10'000); + WeightedVariation wv1(1, 20'000); + WeightedVariation wv2(2, 70'000); + + Flag::Rollout rollout({wv0, wv1, wv2}); + rollout.bucketBy = "numberAttr"; + rollout.kind = Flag::Rollout::Kind::kExperiment; + rollout.seed = params.seed; + + auto result = Variation(rollout, kHashKey, + ContextBuilder() + .Kind("user", params.key) + .Set("numberAttr", 0.6708485) + .Build(), + kSalt); + + ASSERT_TRUE(result); + + ASSERT_EQ(result->VariationIndex(), params.expectedVariationIndex) + << params.key << " with seed " + << (params.seed ? std::to_string(*params.seed) : "(none)") + << " should get variation " << params.expectedVariationIndex; +} + +INSTANTIATE_TEST_SUITE_P( + VariationsForSeedsAndKeys, + ExperimentBucketingTests, + ::testing::ValuesIn({ + ExperimentBucketTest{std::nullopt, "userKeyA", 2}, // 0.42157587, + ExperimentBucketTest{std::nullopt, "userKeyB", 2}, // 0.6708485, + ExperimentBucketTest{std::nullopt, "userKeyC", 1}, // 0.10343106, + ExperimentBucketTest{61, "userKeyA", 0}, // 0.09801207, + ExperimentBucketTest{61, "userKeyB", 1}, // 0.14483777, + ExperimentBucketTest{61, "userKeyC", 2} // 0.9242641, + })); + +TEST_F(BucketingConsistencyTests, + BucketValueBeyondLastBucketIsPinnedToLastBucket) { + WeightedVariation wv0(0, 5'000); + WeightedVariation wv1(1, 5'000); + + Flag::Rollout rollout({wv0, wv1}); + rollout.seed = 61; + + auto context = ContextBuilder() + .Kind("user", "userKeyD") + .Set("intAttr", 99'999) + .Build(); + + auto result = Variation(rollout, kHashKey, context, kSalt); + + ASSERT_TRUE(result); + ASSERT_EQ(result->VariationIndex(), 1); + ASSERT_FALSE(result->InExperiment()); +} + +TEST_F(BucketingConsistencyTests, + BucketValueBeyongLastBucketIsPinnedToLastBucketForExperiment) { + WeightedVariation wv0(0, 5'000); + WeightedVariation wv1(1, 5'000); + + Flag::Rollout rollout({wv0, wv1}); + rollout.seed = 61; + rollout.kind = Flag::Rollout::Kind::kExperiment; + + auto context = ContextBuilder() + .Kind("user", "userKeyD") + .Set("intAttr", 99'999) + .Build(); + + auto result = Variation(rollout, kHashKey, context, kSalt); + + ASSERT_TRUE(result); + ASSERT_EQ(result->VariationIndex(), 1); + ASSERT_TRUE(result->InExperiment()); +} diff --git a/libs/server-sdk/tests/client_test.cpp b/libs/server-sdk/tests/client_test.cpp new file mode 100644 index 000000000..73d0c0dd7 --- /dev/null +++ b/libs/server-sdk/tests/client_test.cpp @@ -0,0 +1,79 @@ +#include +#include +#include +#include + +using namespace launchdarkly; +using namespace launchdarkly::server_side; + +class ClientTest : public ::testing::Test { + protected: + ClientTest() + : client_(ConfigBuilder("sdk-123").Build().value()), + context_(ContextBuilder().Kind("cat", "shadow").Build()) {} + + Client client_; + Context const context_; +}; + +TEST_F(ClientTest, ClientConstructedWithMinimalConfigAndContextT) { + char const* version = client_.Version(); + ASSERT_TRUE(version); + ASSERT_STREQ(version, "0.1.0"); // {x-release-please-version} +} + +TEST_F(ClientTest, BoolVariationDefaultPassesThrough) { + const std::string flag = "extra-cat-food"; + std::vector values = {true, false}; + for (auto const& v : values) { + ASSERT_EQ(client_.BoolVariation(context_, flag, v), v); + ASSERT_EQ(*client_.BoolVariationDetail(context_, flag, v), v); + } +} + +TEST_F(ClientTest, StringVariationDefaultPassesThrough) { + const std::string flag = "treat"; + std::vector values = {"chicken", "fish", "cat-grass"}; + for (auto const& v : values) { + ASSERT_EQ(client_.StringVariation(context_, flag, v), v); + ASSERT_EQ(*client_.StringVariationDetail(context_, flag, v), v); + } +} + +TEST_F(ClientTest, IntVariationDefaultPassesThrough) { + const std::string flag = "weight"; + std::vector values = {0, 12, 13, 24, 1000}; + for (auto const& v : values) { + ASSERT_EQ(client_.IntVariation(context_, flag, v), v); + ASSERT_EQ(*client_.IntVariationDetail(context_, flag, v), v); + } +} + +TEST_F(ClientTest, DoubleVariationDefaultPassesThrough) { + const std::string flag = "weight"; + std::vector values = {0.0, 12.0, 13.0, 24.0, 1000.0}; + for (auto const& v : values) { + ASSERT_EQ(client_.DoubleVariation(context_, flag, v), v); + ASSERT_EQ(*client_.DoubleVariationDetail(context_, flag, v), v); + } +} + +TEST_F(ClientTest, JsonVariationDefaultPassesThrough) { + const std::string flag = "assorted-values"; + std::vector values = { + Value({"running", "jumping"}), Value(3), Value(1.0), Value(true), + Value(std::map{{"weight", 20}})}; + for (auto const& v : values) { + ASSERT_EQ(client_.JsonVariation(context_, flag, v), v); + ASSERT_EQ(*client_.JsonVariationDetail(context_, flag, v), v); + } +} + +TEST_F(ClientTest, AllFlagsStateNotValid) { + // Since we don't have any ability to insert into the data store, assert + // only that the state is not valid. + auto flags = client_.AllFlagsState( + context_, AllFlagsState::Options::IncludeReasons | + AllFlagsState::Options::ClientSideOnly); + ASSERT_FALSE(flags.Valid()); +} diff --git a/libs/server-sdk/tests/data_source_event_handler_test.cpp b/libs/server-sdk/tests/data_source_event_handler_test.cpp new file mode 100644 index 000000000..4092c78a2 --- /dev/null +++ b/libs/server-sdk/tests/data_source_event_handler_test.cpp @@ -0,0 +1,202 @@ +#include + +#include +#include "data_sources/data_source_event_handler.hpp" +#include "data_store/memory_store.hpp" + +using namespace launchdarkly; +using namespace launchdarkly::server_side; +using namespace launchdarkly::server_side::data_sources; +using namespace launchdarkly::server_side::data_store; + +TEST(DataSourceEventHandlerTests, HandlesEmptyPutMessage) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + + auto res = event_handler.HandleMessage("put", R"({"path":"/", "data":{}})"); + + ASSERT_EQ(DataSourceEventHandler::MessageStatus::kMessageHandled, res); + ASSERT_TRUE(store->Initialized()); + EXPECT_EQ(0, store->AllFlags().size()); + EXPECT_EQ(0, store->AllSegments().size()); + EXPECT_EQ(DataSourceStatus::DataSourceState::kValid, + manager.Status().State()); +} + +TEST(DataSourceEventHandlerTests, HandlesInvalidPut) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + + auto res = event_handler.HandleMessage("put", "{sorry"); + + ASSERT_EQ(DataSourceEventHandler::MessageStatus::kInvalidMessage, res); + ASSERT_FALSE(store->Initialized()); + EXPECT_EQ(0, store->AllFlags().size()); + EXPECT_EQ(0, store->AllSegments().size()); + EXPECT_EQ(DataSourceStatus::DataSourceState::kInitializing, + manager.Status().State()); +} + +TEST(DataSourceEventHandlerTests, HandlesInvalidPatch) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + + auto res = event_handler.HandleMessage("put", "{sorry"); + + ASSERT_EQ(DataSourceEventHandler::MessageStatus::kInvalidMessage, res); + ASSERT_FALSE(store->Initialized()); + EXPECT_EQ(0, store->AllFlags().size()); + EXPECT_EQ(0, store->AllSegments().size()); + EXPECT_EQ(DataSourceStatus::DataSourceState::kInitializing, + manager.Status().State()); +} + +TEST(DataSourceEventHandlerTests, HandlesPatchForUnknownPath) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + + auto res = event_handler.HandleMessage( + "patch", R"({"path":"potato", "data": "SPUD"})"); + + ASSERT_EQ(DataSourceEventHandler::MessageStatus::kMessageHandled, res); + EXPECT_EQ(DataSourceStatus::DataSourceState::kInitializing, + manager.Status().State()); +} + +TEST(DataSourceEventHandlerTests, HandlesPutForUnknownPath) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + + auto res = event_handler.HandleMessage( + "put", R"({"path":"potato", "data": "SPUD"})"); + + ASSERT_EQ(DataSourceEventHandler::MessageStatus::kMessageHandled, res); + EXPECT_EQ(DataSourceStatus::DataSourceState::kInitializing, + manager.Status().State()); +} + +TEST(DataSourceEventHandlerTests, HandlesInvalidDelete) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + + auto res = event_handler.HandleMessage("put", "{sorry"); + + ASSERT_EQ(DataSourceEventHandler::MessageStatus::kInvalidMessage, res); + ASSERT_FALSE(store->Initialized()); + EXPECT_EQ(0, store->AllFlags().size()); + EXPECT_EQ(0, store->AllSegments().size()); + EXPECT_EQ(DataSourceStatus::DataSourceState::kInitializing, + manager.Status().State()); +} + +TEST(DataSourceEventHandlerTests, HandlesPayloadWithFlagAndSegment) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + auto payload = + R"({"path":"/","data":{"segments":{"special":{"key":"special","included":["bob"], + "version":2}},"flags":{"HasBob":{"key":"HasBob","on":true,"fallthrough": + {"variation":1},"variations":[true,false],"version":4}}}})"; + auto res = event_handler.HandleMessage("put", payload); + + ASSERT_EQ(DataSourceEventHandler::MessageStatus::kMessageHandled, res); + ASSERT_TRUE(store->Initialized()); + EXPECT_EQ(1, store->AllFlags().size()); + EXPECT_EQ(1, store->AllSegments().size()); + EXPECT_TRUE(store->GetFlag("HasBob")); + EXPECT_TRUE(store->GetSegment("special")); + EXPECT_EQ(DataSourceStatus::DataSourceState::kValid, + manager.Status().State()); +} + +TEST(DataSourceEventHandlerTests, HandlesValidFlagPatch) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + + event_handler.HandleMessage("put", "{}"); + + auto patch_res = event_handler.HandleMessage( + "patch", + R"({"path": "/flags/flagA", "data":{"key": "flagA", "version":2}})"); + + ASSERT_EQ(DataSourceEventHandler::MessageStatus::kMessageHandled, + patch_res); + + EXPECT_EQ(1, store->AllFlags().size()); +} + +TEST(DataSourceEventHandlerTests, HandlesValidSegmentPatch) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + + event_handler.HandleMessage("put", "{}"); + + auto patch_res = event_handler.HandleMessage( + "patch", + R"({"path": "/segments/segmentA", "data":{"key": "segmentA", "version":2}})"); + + ASSERT_EQ(DataSourceEventHandler::MessageStatus::kMessageHandled, + patch_res); + + EXPECT_EQ(1, store->AllSegments().size()); +} + +TEST(DataSourceEventHandlerTests, HandlesDeleteFlag) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + + event_handler.HandleMessage( + "put", R"({"path":"/","data":{"segments":{})" + R"(, "flags":{"flagA": {"key":"flagA", "version": 0}}}})"); + + ASSERT_TRUE(store->GetFlag("flagA")->item); + + auto patch_res = event_handler.HandleMessage( + "delete", R"({"path": "/flags/flagA", "version": 1})"); + + ASSERT_EQ(DataSourceEventHandler::MessageStatus::kMessageHandled, + patch_res); + + ASSERT_FALSE(store->GetFlag("flagA")->item); +} + +TEST(DataSourceEventHandlerTests, HandlesDeleteSegment) { + auto logger = launchdarkly::logging::NullLogger(); + auto store = std::make_shared(); + DataSourceStatusManager manager; + DataSourceEventHandler event_handler(*store, logger, manager); + + event_handler.HandleMessage( + "put", + R"({"path":"/","data":{"flags":{})" + R"(, "segments":{"segmentA": {"key":"segmentA", "version": 0}}}})"); + + ASSERT_TRUE(store->GetSegment("segmentA")->item); + + auto patch_res = event_handler.HandleMessage( + "delete", R"({"path": "/segments/segmentA", "version": 1})"); + + ASSERT_EQ(DataSourceEventHandler::MessageStatus::kMessageHandled, + patch_res); + + ASSERT_FALSE(store->GetSegment("segmentA")->item); +} diff --git a/libs/server-sdk/tests/data_store_updater_test.cpp b/libs/server-sdk/tests/data_store_updater_test.cpp new file mode 100644 index 000000000..87fc139b9 --- /dev/null +++ b/libs/server-sdk/tests/data_store_updater_test.cpp @@ -0,0 +1,430 @@ +#include + +#include "data_store/data_store_updater.hpp" +#include "data_store/descriptors.hpp" +#include "data_store/memory_store.hpp" + +using launchdarkly::data_model::SDKDataSet; +using launchdarkly::server_side::data_store::DataStoreUpdater; +using launchdarkly::server_side::data_store::FlagDescriptor; +using launchdarkly::server_side::data_store::IDataStore; +using launchdarkly::server_side::data_store::MemoryStore; +using launchdarkly::server_side::data_store::SegmentDescriptor; + +using launchdarkly::Value; +using launchdarkly::data_model::Flag; +using launchdarkly::data_model::Segment; + +TEST(DataStoreUpdaterTest, DoesNotInitializeStoreUntilInit) { + MemoryStore store; + DataStoreUpdater updater(store, store); + EXPECT_FALSE(store.Initialized()); +} + +TEST(DataStoreUpdaterTest, InitializesStore) { + MemoryStore store; + DataStoreUpdater updater(store, store); + updater.Init(SDKDataSet()); + EXPECT_TRUE(store.Initialized()); +} + +TEST(DataStoreUpdaterTest, InitPropagatesData) { + MemoryStore store; + DataStoreUpdater updater(store, store); + Flag flag; + flag.version = 1; + flag.key = "flagA"; + flag.on = true; + flag.variations = std::vector{true, false}; + Flag::Variation variation = 0; + flag.fallthrough = variation; + + auto segment = Segment(); + segment.version = 1; + segment.key = "segmentA"; + + updater.Init(SDKDataSet{ + std::unordered_map{ + {"flagA", FlagDescriptor(flag)}}, + std::unordered_map{ + {"segmentA", SegmentDescriptor(segment)}}, + }); + + auto fetched_flag = store.GetFlag("flagA"); + EXPECT_TRUE(fetched_flag); + EXPECT_TRUE(fetched_flag->item); + EXPECT_EQ("flagA", fetched_flag->item->key); + EXPECT_EQ(1, fetched_flag->item->version); + EXPECT_EQ(fetched_flag->version, fetched_flag->item->version); + + auto fetched_segment = store.GetSegment("segmentA"); + EXPECT_TRUE(fetched_segment); + EXPECT_TRUE(fetched_segment->item); + EXPECT_EQ("segmentA", fetched_segment->item->key); + EXPECT_EQ(1, fetched_segment->item->version); + EXPECT_EQ(fetched_segment->version, fetched_segment->item->version); +} + +TEST(DataStoreUpdaterTest, SecondInitProducesChanges) { + MemoryStore store; + DataStoreUpdater updater(store, store); + Flag flag_a_v1; + flag_a_v1.version = 1; + flag_a_v1.key = "flagA"; + flag_a_v1.on = true; + flag_a_v1.variations = std::vector{true, false}; + Flag::Variation variation = 0; + flag_a_v1.fallthrough = variation; + + Flag flag_b_v1; + flag_b_v1.version = 1; + flag_b_v1.key = "flagA"; + flag_b_v1.on = true; + flag_b_v1.variations = std::vector{true, false}; + flag_b_v1.fallthrough = variation; + + Flag flab_c_v1; + flab_c_v1.version = 1; + flab_c_v1.key = "flagA"; + flab_c_v1.on = true; + flab_c_v1.variations = std::vector{true, false}; + flab_c_v1.fallthrough = variation; + + updater.Init(SDKDataSet{ + std::unordered_map{ + {"flagA", FlagDescriptor(flag_a_v1)}, + {"flagB", FlagDescriptor(flag_b_v1)}}, + std::unordered_map(), + }); + + Flag flag_a_v2; + flag_a_v2.version = 2; + flag_a_v2.key = "flagA"; + flag_a_v2.on = true; + flag_a_v2.variations = std::vector{true, false}; + flag_a_v2.fallthrough = variation; + + // Not updated. + Flag flag_c_v1_second; + flag_c_v1_second.version = 1; + flag_c_v1_second.key = "flagC"; + flag_c_v1_second.on = true; + flag_c_v1_second.variations = std::vector{true, false}; + flag_c_v1_second.fallthrough = variation; + + // New flag + Flag flag_d; + flag_d.version = 2; + flag_d.key = "flagD"; + flag_d.on = true; + flag_d.variations = std::vector{true, false}; + flag_d.fallthrough = variation; + + std::atomic got_event(false); + updater.OnFlagChange( + [&got_event](std::shared_ptr> changeset) { + got_event = true; + std::vector diff; + auto expectedSet = std::set{"flagA", "flagB", "flagD"}; + std::set_difference(expectedSet.begin(), expectedSet.end(), + changeset->begin(), changeset->end(), + std::inserter(diff, diff.begin())); + EXPECT_EQ(0, diff.size()); + }); + + // Updated flag A, deleted flag B, added flag C. + updater.Init(SDKDataSet{ + std::unordered_map{ + {"flagA", FlagDescriptor(flag_a_v2)}, + {"flagD", FlagDescriptor(flag_d)}, + {"flagC", FlagDescriptor(flag_c_v1_second)}}, + std::unordered_map(), + }); + + EXPECT_TRUE(got_event); +} + +TEST(DataStoreUpdaterTest, CanUpsertNewFlag) { + MemoryStore store; + DataStoreUpdater updater(store, store); + + Flag flag_a; + flag_a.version = 1; + flag_a.key = "flagA"; + + updater.Init(SDKDataSet{ + std::unordered_map(), + std::unordered_map(), + }); + updater.Upsert("flagA", FlagDescriptor(flag_a)); + + auto fetched_flag = store.GetFlag("flagA"); + EXPECT_TRUE(fetched_flag); + EXPECT_TRUE(fetched_flag->item); + EXPECT_EQ("flagA", fetched_flag->item->key); + EXPECT_EQ(1, fetched_flag->item->version); + EXPECT_EQ(fetched_flag->version, fetched_flag->item->version); +} + +TEST(DataStoreUpdaterTest, CanUpsertExitingFlag) { + Flag flag_a; + flag_a.version = 1; + flag_a.key = "flagA"; + + MemoryStore store; + DataStoreUpdater updater(store, store); + + updater.Init(SDKDataSet{ + std::unordered_map{ + {"flagA", FlagDescriptor(flag_a)}}, + std::unordered_map(), + }); + + Flag flag_a_2; + flag_a_2.version = 2; + flag_a_2.key = "flagA"; + + updater.Upsert("flagA", FlagDescriptor(flag_a_2)); + + auto fetched_flag = store.GetFlag("flagA"); + EXPECT_TRUE(fetched_flag); + EXPECT_TRUE(fetched_flag->item); + EXPECT_EQ("flagA", fetched_flag->item->key); + EXPECT_EQ(2, fetched_flag->item->version); + EXPECT_EQ(fetched_flag->version, fetched_flag->item->version); +} + +TEST(DataStoreUpdaterTest, OldVersionIsDiscardedOnUpsertFlag) { + Flag flag_a; + flag_a.version = 2; + flag_a.key = "flagA"; + flag_a.variations = std::vector{"potato", "ham"}; + + MemoryStore store; + DataStoreUpdater updater(store, store); + + updater.Init(SDKDataSet{ + std::unordered_map{ + {"flagA", FlagDescriptor(flag_a)}}, + std::unordered_map(), + }); + + Flag flag_a_2; + flag_a_2.version = 1; + flag_a_2.key = "flagA"; + flag_a.variations = std::vector{"potato"}; + + updater.Upsert("flagA", FlagDescriptor(flag_a_2)); + + auto fetched_flag = store.GetFlag("flagA"); + EXPECT_TRUE(fetched_flag); + EXPECT_TRUE(fetched_flag->item); + EXPECT_EQ("flagA", fetched_flag->item->key); + EXPECT_EQ(2, fetched_flag->item->version); + EXPECT_EQ(fetched_flag->version, fetched_flag->item->version); + EXPECT_EQ(2, fetched_flag->item->variations.size()); + EXPECT_EQ(std::string("potato"), + fetched_flag->item->variations[0].AsString()); + EXPECT_EQ(std::string("ham"), fetched_flag->item->variations[1].AsString()); +} + +TEST(DataStoreUpdaterTest, CanUpsertNewSegment) { + Segment segment_a; + segment_a.version = 1; + segment_a.key = "segmentA"; + + MemoryStore store; + DataStoreUpdater updater(store, store); + + updater.Init(SDKDataSet{ + std::unordered_map(), + std::unordered_map(), + }); + updater.Upsert("segmentA", SegmentDescriptor(segment_a)); + + auto fetched_segment = store.GetSegment("segmentA"); + EXPECT_TRUE(fetched_segment); + EXPECT_TRUE(fetched_segment->item); + EXPECT_EQ("segmentA", fetched_segment->item->key); + EXPECT_EQ(1, fetched_segment->item->version); + EXPECT_EQ(fetched_segment->version, fetched_segment->item->version); +} + +TEST(DataStoreUpdaterTest, CanUpsertExitingSegment) { + Segment segment_a; + segment_a.version = 1; + segment_a.key = "segmentA"; + + MemoryStore store; + DataStoreUpdater updater(store, store); + + updater.Init(SDKDataSet{ + std::unordered_map(), + std::unordered_map{ + {"segmentA", SegmentDescriptor(segment_a)}}, + }); + + Segment segment_a_2; + segment_a_2.version = 2; + segment_a_2.key = "segmentA"; + + updater.Upsert("segmentA", SegmentDescriptor(segment_a_2)); + + auto fetched_segment = store.GetSegment("segmentA"); + EXPECT_TRUE(fetched_segment); + EXPECT_TRUE(fetched_segment->item); + EXPECT_EQ("segmentA", fetched_segment->item->key); + EXPECT_EQ(2, fetched_segment->item->version); + EXPECT_EQ(fetched_segment->version, fetched_segment->item->version); +} + +TEST(DataStoreUpdaterTest, OldVersionIsDiscardedOnUpsertSegment) { + Segment segment_a; + segment_a.version = 2; + segment_a.key = "segmentA"; + + MemoryStore store; + DataStoreUpdater updater(store, store); + + updater.Init(SDKDataSet{ + std::unordered_map(), + std::unordered_map{ + {"segmentA", SegmentDescriptor(segment_a)}}, + }); + + Segment segment_a_2; + segment_a_2.version = 1; + segment_a_2.key = "segmentA"; + + updater.Upsert("segmentA", SegmentDescriptor(segment_a_2)); + + auto fetched_segment = store.GetSegment("segmentA"); + EXPECT_TRUE(fetched_segment); + EXPECT_TRUE(fetched_segment->item); + EXPECT_EQ("segmentA", fetched_segment->item->key); + EXPECT_EQ(2, fetched_segment->item->version); + EXPECT_EQ(fetched_segment->version, fetched_segment->item->version); +} + +TEST(DataStoreUpdaterTest, ProducesChangeEventsOnUpsert) { + Flag flag_a; + Flag flag_b; + + flag_a.key = "flagA"; + flag_a.version = 1; + + flag_b.key = "flagB"; + flag_b.version = 1; + + flag_b.prerequisites.push_back(Flag::Prerequisite{"flagA", 0}); + + MemoryStore store; + DataStoreUpdater updater(store, store); + + updater.Init(SDKDataSet{ + std::unordered_map{ + {"flagA", FlagDescriptor(flag_a)}, + {"flagB", FlagDescriptor(flag_b)}}, + std::unordered_map(), + }); + + Flag flag_a_2; + flag_a_2.key = "flagA"; + flag_a_2.version = 2; + + std::atomic got_event(false); + updater.OnFlagChange( + [&got_event](std::shared_ptr> changeset) { + got_event = true; + std::vector diff; + auto expectedSet = std::set{"flagA", "flagB"}; + std::set_difference(expectedSet.begin(), expectedSet.end(), + changeset->begin(), changeset->end(), + std::inserter(diff, diff.begin())); + EXPECT_EQ(0, diff.size()); + }); + + updater.Upsert("flagA", FlagDescriptor(flag_a_2)); + + EXPECT_EQ(true, got_event); +} + +TEST(DataStoreUpdaterTest, ProducesNoEventIfNoFlagChanged) { + Flag flag_a; + Flag flag_b; + + flag_a.key = "flagA"; + flag_a.version = 1; + + flag_b.key = "flagB"; + flag_b.version = 1; + + flag_b.prerequisites.push_back(Flag::Prerequisite{"flagA", 0}); + + MemoryStore store; + DataStoreUpdater updater(store, store); + + Segment segment_a; + segment_a.version = 1; + segment_a.key = "segmentA"; + + updater.Init(SDKDataSet{ + std::unordered_map{ + {"flagA", FlagDescriptor(flag_a)}, + {"flagB", FlagDescriptor(flag_b)}}, + std::unordered_map{ + {"segmentA", SegmentDescriptor(segment_a)}, + }, + }); + + Segment segment_a_2; + segment_a_2.key = "flagA"; + segment_a_2.version = 2; + + std::atomic got_event(false); + updater.OnFlagChange( + [&got_event](std::shared_ptr> changeset) { + got_event = true; + }); + + updater.Upsert("segmentA", SegmentDescriptor(segment_a_2)); + + EXPECT_EQ(false, got_event); +} + +TEST(DataStoreUpdaterTest, NoEventOnDiscardedUpsert) { + Flag flag_a; + Flag flag_b; + + flag_a.key = "flagA"; + flag_a.version = 1; + + flag_b.key = "flagB"; + flag_b.version = 1; + + flag_b.prerequisites.push_back(Flag::Prerequisite{"flagA", 0}); + + MemoryStore store; + DataStoreUpdater updater(store, store); + + updater.Init(SDKDataSet{ + std::unordered_map{ + {"flagA", FlagDescriptor(flag_a)}, + {"flagB", FlagDescriptor(flag_b)}}, + std::unordered_map(), + }); + + Flag flag_a_2; + flag_a_2.key = "flagA"; + flag_a_2.version = 1; + + std::atomic got_event(false); + updater.OnFlagChange( + [&got_event](std::shared_ptr> changeset) { + got_event = true; + }); + + updater.Upsert("flagA", FlagDescriptor(flag_a_2)); + + EXPECT_EQ(false, got_event); +} diff --git a/libs/server-sdk/tests/dependency_tracker_test.cpp b/libs/server-sdk/tests/dependency_tracker_test.cpp new file mode 100644 index 000000000..bbffbc09b --- /dev/null +++ b/libs/server-sdk/tests/dependency_tracker_test.cpp @@ -0,0 +1,298 @@ +#include + +#include "data_store/dependency_tracker.hpp" +#include "data_store/descriptors.hpp" + +using launchdarkly::server_side::data_store::DataKind; +using launchdarkly::server_side::data_store::DependencyMap; +using launchdarkly::server_side::data_store::DependencySet; +using launchdarkly::server_side::data_store::DependencyTracker; +using launchdarkly::server_side::data_store::FlagDescriptor; +using launchdarkly::server_side::data_store::SegmentDescriptor; + +using launchdarkly::AttributeReference; +using launchdarkly::Value; +using launchdarkly::data_model::Clause; +using launchdarkly::data_model::ContextKind; +using launchdarkly::data_model::Flag; +using launchdarkly::data_model::ItemDescriptor; +using launchdarkly::data_model::Segment; + +TEST(ScopedSetTest, CanAddItem) { + DependencySet set; + set.Set(DataKind::kFlag, "flagA"); +} + +TEST(ScopedSetTest, CanCheckIfContains) { + DependencySet set; + set.Set(DataKind::kFlag, "flagA"); + + EXPECT_TRUE(set.Contains(DataKind::kFlag, "flagA")); + EXPECT_FALSE(set.Contains(DataKind::kFlag, "flagB")); + EXPECT_FALSE(set.Contains(DataKind::kSegment, "flagA")); +} + +TEST(ScopedSetTest, CanRemoveItem) { + DependencySet set; + set.Set(DataKind::kFlag, "flagA"); + set.Remove(DataKind::kFlag, "flagA"); + EXPECT_FALSE(set.Contains(DataKind::kFlag, "flagA")); +} + +TEST(ScopedSetTest, CanIterate) { + DependencySet set; + set.Set(DataKind::kFlag, "flagA"); + set.Set(DataKind::kFlag, "flagB"); + set.Set(DataKind::kSegment, "segmentA"); + set.Set(DataKind::kSegment, "segmentB"); + + auto count = 0; + auto expectations = + std::vector{"flagA", "flagB", "segmentA", "segmentB"}; + + for (auto& ns : set) { + if (count == 0) { + EXPECT_EQ(DataKind::kFlag, ns.Kind()); + } else { + EXPECT_EQ(DataKind::kSegment, ns.Kind()); + } + for (auto val : ns.Data()) { + EXPECT_EQ(expectations[count], val); + count++; + } + } + EXPECT_EQ(4, count); +} + +TEST(ScopedMapTest, CanAddItem) { + DependencyMap map; + DependencySet deps; + deps.Set(DataKind::kSegment, "segmentA"); + + map.Set(DataKind::kFlag, "flagA", deps); +} + +TEST(ScopedMapTest, CanGetItem) { + DependencyMap map; + DependencySet deps; + deps.Set(DataKind::kSegment, "segmentA"); + + map.Set(DataKind::kFlag, "flagA", deps); + + EXPECT_TRUE(map.Get(DataKind::kFlag, "flagA") + ->Contains(DataKind::kSegment, "segmentA")); +} + +TEST(ScopedMapTest, CanIterate) { + DependencyMap map; + + DependencySet dep_flags; + dep_flags.Set(DataKind::kSegment, "segmentA"); + dep_flags.Set(DataKind::kFlag, "flagB"); + + DependencySet depSegments; + depSegments.Set(DataKind::kSegment, "segmentB"); + + map.Set(DataKind::kFlag, "flagA", dep_flags); + map.Set(DataKind::kSegment, "segmentA", depSegments); + + auto expectationKeys = + std::set{"segmentA", "flagB", "segmentB"}; + auto expectationKinds = std::vector{ + DataKind::kFlag, DataKind::kSegment, DataKind::kSegment}; + + auto count = 0; + for (auto& ns : map) { + if (count == 0) { + EXPECT_EQ(DataKind::kFlag, ns.Kind()); + } else { + EXPECT_EQ(DataKind::kSegment, ns.Kind()); + } + for (auto const& depSet : ns.Data()) { + for (auto const& deps : depSet.second) { + for (auto& dep : deps.Data()) { + EXPECT_EQ(expectationKinds[count], deps.Kind()); + EXPECT_TRUE(expectationKeys.count(dep) != 0); + expectationKeys.erase(dep); + count++; + } + } + } + } + EXPECT_EQ(3, count); +} + +TEST(ScopedMapTest, CanClear) { + DependencyMap map; + + DependencySet dep_flags; + dep_flags.Set(DataKind::kSegment, "segmentA"); + dep_flags.Set(DataKind::kFlag, "flagB"); + + DependencySet dep_segments; + dep_segments.Set(DataKind::kSegment, "segmentB"); + + map.Set(DataKind::kFlag, "flagA", dep_flags); + map.Set(DataKind::kSegment, "segmentA", dep_segments); + map.Clear(); + + for (auto& ns : map) { + for ([[maybe_unused]] auto& set_set : ns.Data()) { + GTEST_FAIL(); + } + } +} + +TEST(DependencyTrackerTest, TreatsPrerequisitesAsDependencies) { + DependencyTracker tracker; + + Flag flag_a; + Flag flag_b; + Flag flag_c; + + flag_a.key = "flagA"; + flag_a.version = 1; + + flag_b.key = "flagB"; + flag_b.version = 1; + + // Unused, to make sure not everything is just included in the dependencies. + flag_c.key = "flagC"; + flag_c.version = 1; + + flag_b.prerequisites.push_back(Flag::Prerequisite{"flagA", 0}); + + tracker.UpdateDependencies("flagA", FlagDescriptor(flag_a)); + tracker.UpdateDependencies("flagB", FlagDescriptor(flag_b)); + tracker.UpdateDependencies("flagC", FlagDescriptor(flag_c)); + + DependencySet changes; + tracker.CalculateChanges(DataKind::kFlag, "flagA", changes); + + EXPECT_TRUE(changes.Contains(DataKind::kFlag, "flagB")); + EXPECT_TRUE(changes.Contains(DataKind::kFlag, "flagA")); + EXPECT_EQ(2, changes.Size()); +} + +TEST(DependencyTrackerTest, UsesSegmentRulesToCalculateDependencies) { + DependencyTracker tracker; + + Flag flag_a; + Segment segment_a; + + Flag flag_b; + Segment segment_b; + + flag_a.key = "flagA"; + flag_a.version = 1; + + segment_a.key = "segmentA"; + segment_a.version = 1; + + // flagB and segmentB are unused. + flag_b.key = "flagB"; + flag_b.version = 1; + + segment_b.key = "segmentB"; + segment_b.version = 1; + + flag_a.rules.push_back(Flag::Rule{std::vector{ + Clause{Clause::Op::kSegmentMatch, std::vector{"segmentA"}, false, + ContextKind("user"), AttributeReference()}}}); + + tracker.UpdateDependencies("flagA", FlagDescriptor(flag_a)); + tracker.UpdateDependencies("segmentA", SegmentDescriptor(segment_a)); + + tracker.UpdateDependencies("flagB", FlagDescriptor(flag_b)); + tracker.UpdateDependencies("segmentB", SegmentDescriptor(segment_b)); + + DependencySet changes; + tracker.CalculateChanges(DataKind::kSegment, "segmentA", changes); + + EXPECT_TRUE(changes.Contains(DataKind::kFlag, "flagA")); + EXPECT_TRUE(changes.Contains(DataKind::kSegment, "segmentA")); + EXPECT_EQ(2, changes.Size()); +} + +TEST(DependencyTrackerTest, TracksSegmentDependencyOfPrerequisite) { + DependencyTracker tracker; + + Flag flag_a; + Flag flag_b; + Segment segment_a; + + flag_a.key = "flagA"; + flag_a.version = 1; + + flag_b.key = "flagB"; + flag_b.version = 1; + + segment_a.key = "segmentA"; + segment_a.version = 1; + + flag_a.rules.push_back(Flag::Rule{std::vector{ + Clause{Clause::Op::kSegmentMatch, std::vector{"segmentA"}, false, + ContextKind(""), AttributeReference()}}}); + + flag_b.prerequisites.push_back(Flag::Prerequisite{"flagA", 0}); + + tracker.UpdateDependencies("flagA", FlagDescriptor(flag_a)); + tracker.UpdateDependencies("flagB", FlagDescriptor(flag_b)); + tracker.UpdateDependencies("segmentA", SegmentDescriptor(segment_a)); + + DependencySet changes; + tracker.CalculateChanges(DataKind::kSegment, "segmentA", changes); + + // The segment itself was changed. + EXPECT_TRUE(changes.Contains(DataKind::kSegment, "segmentA")); + // flagA has a rule which depends on segmentA. + EXPECT_TRUE(changes.Contains(DataKind::kFlag, "flagA")); + // flagB has a prerequisite of flagA. + EXPECT_TRUE(changes.Contains(DataKind::kFlag, "flagB")); + EXPECT_EQ(3, changes.Size()); +} + +TEST(DependencyTrackerTest, HandlesSegmentsDependentOnOtherSegments) { + DependencyTracker tracker; + + Segment segment_a; + Segment segment_b; + Segment segment_c; + + segment_a.key = "segmentA"; + segment_a.version = 1; + + segment_b.key = "segmentB"; + segment_b.version = 1; + + segment_c.key = "segmentC"; + segment_c.version = 1; + + segment_b.rules.push_back(Segment::Rule{ + std::vector{Clause{Clause::Op::kSegmentMatch, + std::vector{"segmentA"}, false, + ContextKind("user"), AttributeReference()}}, + std::nullopt, std::nullopt, ContextKind(""), AttributeReference()}); + + tracker.UpdateDependencies("segmentA", SegmentDescriptor(segment_a)); + tracker.UpdateDependencies("segmentB", SegmentDescriptor(segment_b)); + tracker.UpdateDependencies("segmentC", SegmentDescriptor(segment_c)); + + DependencySet changes; + tracker.CalculateChanges(DataKind::kSegment, "segmentA", changes); + + EXPECT_TRUE(changes.Contains(DataKind::kSegment, "segmentB")); + EXPECT_TRUE(changes.Contains(DataKind::kSegment, "segmentA")); + EXPECT_EQ(2, changes.Size()); +} + +TEST(DependencyTrackerTest, HandlesUpdateForSomethingThatDoesNotExist) { + // This shouldn't happen, but it should also not break. + DependencyTracker tracker; + + DependencySet changes; + tracker.CalculateChanges(DataKind::kFlag, "potato", changes); + + EXPECT_EQ(1, changes.Size()); + EXPECT_TRUE(changes.Contains(DataKind::kFlag, "potato")); +} diff --git a/libs/server-sdk/tests/evaluation_stack_test.cpp b/libs/server-sdk/tests/evaluation_stack_test.cpp new file mode 100644 index 000000000..23193c8df --- /dev/null +++ b/libs/server-sdk/tests/evaluation_stack_test.cpp @@ -0,0 +1,53 @@ +#include + +#include "evaluation/detail/evaluation_stack.hpp" + +using namespace launchdarkly::server_side::evaluation::detail; + +TEST(EvalStackTests, SegmentIsNoticed) { + EvaluationStack stack; + auto g1 = stack.NoticeSegment("foo"); + ASSERT_TRUE(g1); + ASSERT_FALSE(stack.NoticeSegment("foo")); +} + +TEST(EvalStackTests, PrereqIsNoticed) { + EvaluationStack stack; + auto g1 = stack.NoticePrerequisite("foo"); + ASSERT_TRUE(g1); + ASSERT_FALSE(stack.NoticePrerequisite("foo")); +} + +TEST(EvalStackTests, NestedScopes) { + EvaluationStack stack; + { + auto g1 = stack.NoticeSegment("foo"); + ASSERT_TRUE(g1); + ASSERT_FALSE(stack.NoticeSegment("foo")); + { + auto g2 = stack.NoticeSegment("bar"); + ASSERT_TRUE(g2); + ASSERT_FALSE(stack.NoticeSegment("bar")); + ASSERT_FALSE(stack.NoticeSegment("foo")); + } + ASSERT_TRUE(stack.NoticeSegment("bar")); + } + ASSERT_TRUE(stack.NoticeSegment("foo")); + ASSERT_TRUE(stack.NoticeSegment("bar")); +} + +TEST(EvalStackTests, SegmentAndPrereqHaveSeparateCaches) { + EvaluationStack stack; + auto g1 = stack.NoticeSegment("foo"); + ASSERT_TRUE(g1); + auto g2 = stack.NoticePrerequisite("foo"); + ASSERT_TRUE(g2); +} + +TEST(EvalStackTests, ImmediateDestructionOfGuard) { + EvaluationStack stack; + + ASSERT_TRUE(stack.NoticeSegment("foo")); + ASSERT_TRUE(stack.NoticeSegment("foo")); + ASSERT_TRUE(stack.NoticeSegment("foo")); +} diff --git a/libs/server-sdk/tests/evaluator_tests.cpp b/libs/server-sdk/tests/evaluator_tests.cpp new file mode 100644 index 000000000..602a8dfe8 --- /dev/null +++ b/libs/server-sdk/tests/evaluator_tests.cpp @@ -0,0 +1,248 @@ +#include "evaluation/evaluator.hpp" +#include "test_store.hpp" + +#include +#include + +#include "spy_logger.hpp" + +#include + +using namespace launchdarkly; +using namespace launchdarkly::server_side; + +/** + * Use if the test does not require inspecting log messages. + */ +class EvaluatorTests : public ::testing::Test { + public: + EvaluatorTests() + : logger_(logging::NullLogger()), + store_(test_store::TestData()), + eval_(logger_, *store_) {} + + private: + Logger logger_; + + protected: + std::unique_ptr store_; + evaluation::Evaluator eval_; +}; + +/** + * Use if the test requires making assertions based on log messages generated + * during evaluation. + */ +class EvaluatorTestsWithLogs : public ::testing::Test { + public: + EvaluatorTestsWithLogs() + : messages_(std::make_shared()), + logger_(messages_), + store_(test_store::TestData()), + eval_(logger_, *store_) {} + + protected: + std::shared_ptr messages_; + + private: + Logger logger_; + + protected: + std::unique_ptr store_; + evaluation::Evaluator eval_; +}; + +TEST_F(EvaluatorTests, BasicChanges) { + auto alice = ContextBuilder().Kind("user", "alice").Build(); + auto bob = ContextBuilder().Kind("user", "bob").Build(); + + auto flag = store_->GetFlag("flagWithTarget")->item.value(); + + ASSERT_FALSE(flag.on); + + auto detail = eval_.Evaluate(flag, alice); + + ASSERT_TRUE(detail); + ASSERT_EQ(*detail, Value(false)); + ASSERT_EQ(detail.VariationIndex(), 0); + ASSERT_EQ(detail.Reason(), EvaluationReason::Off()); + + // flip off variation + flag.offVariation = 1; + detail = eval_.Evaluate(flag, alice); + ASSERT_TRUE(detail); + ASSERT_EQ(detail.VariationIndex(), 1); + ASSERT_EQ(*detail, Value(true)); + + // off variation unspecified + flag.offVariation = std::nullopt; + detail = eval_.Evaluate(flag, alice); + ASSERT_TRUE(detail); + ASSERT_EQ(detail.VariationIndex(), std::nullopt); + ASSERT_EQ(*detail, Value::Null()); + + // flip targeting on + flag.on = true; + detail = eval_.Evaluate(flag, alice); + ASSERT_TRUE(detail); + ASSERT_EQ(detail.VariationIndex(), 1); + ASSERT_EQ(*detail, Value(true)); + ASSERT_EQ(detail.Reason(), EvaluationReason::Fallthrough(false)); + + detail = eval_.Evaluate(flag, bob); + ASSERT_TRUE(detail); + ASSERT_EQ(detail.VariationIndex(), 0); + ASSERT_EQ(*detail, Value(false)); + ASSERT_EQ(detail.Reason(), EvaluationReason::TargetMatch()); + + // flip default variation + flag.fallthrough = data_model::Flag::Variation{0}; + detail = eval_.Evaluate(flag, alice); + ASSERT_TRUE(detail); + ASSERT_EQ(detail.VariationIndex(), 0); + ASSERT_EQ(*detail, Value(false)); + + // bob's reason should still be TargetMatch even though his value is now the + // default + detail = eval_.Evaluate(flag, bob); + ASSERT_TRUE(detail); + ASSERT_EQ(detail.VariationIndex(), 0); + ASSERT_EQ(*detail, Value(false)); + ASSERT_EQ(detail.Reason(), EvaluationReason::TargetMatch()); +} + +TEST_F(EvaluatorTests, EvaluateWithMatchesOpGroups) { + auto alice = ContextBuilder().Kind("user", "alice").Build(); + auto bob = ContextBuilder() + .Kind("user", "bob") + .Set("groups", {"my-group"}) + .Build(); + + auto flag = store_->GetFlag("flagWithMatchesOpOnGroups")->item.value(); + + auto detail = eval_.Evaluate(flag, alice); + ASSERT_TRUE(detail); + ASSERT_EQ(*detail, Value(true)); + ASSERT_EQ(detail.Reason(), EvaluationReason::Fallthrough(false)); + + detail = eval_.Evaluate(flag, bob); + ASSERT_TRUE(detail); + ASSERT_EQ(*detail, Value(false)); + ASSERT_EQ(detail.VariationIndex(), 0); + ASSERT_EQ(detail.Reason(), + EvaluationReason::RuleMatch( + 0, "6a7755ac-e47a-40ea-9579-a09dd5f061bd", false)); +} + +TEST_F(EvaluatorTests, EvaluateWithMatchesOpKinds) { + auto alice = ContextBuilder().Kind("user", "alice").Build(); + auto bob = ContextBuilder().Kind("company", "bob").Build(); + + auto flag = store_->GetFlag("flagWithMatchesOpOnKinds")->item.value(); + + auto detail = eval_.Evaluate(flag, alice); + ASSERT_TRUE(detail); + ASSERT_EQ(*detail, Value(false)); + ASSERT_EQ(detail.VariationIndex(), 0); + ASSERT_EQ(detail.Reason(), + EvaluationReason::RuleMatch( + 0, "6a7755ac-e47a-40ea-9579-a09dd5f061bd", false)); + + detail = eval_.Evaluate(flag, bob); + ASSERT_TRUE(detail); + ASSERT_EQ(*detail, Value(true)); + ASSERT_EQ(detail.Reason(), EvaluationReason::Fallthrough(false)); + + auto new_bob = ContextBuilder().Kind("org", "bob").Build(); + detail = eval_.Evaluate(flag, new_bob); + ASSERT_TRUE(detail); + ASSERT_EQ(*detail, Value(false)); + ASSERT_EQ(detail.VariationIndex(), 0); + ASSERT_EQ(detail.Reason(), + EvaluationReason::RuleMatch( + 0, "6a7755ac-e47a-40ea-9579-a09dd5f061bd", false)); +} + +TEST_F(EvaluatorTestsWithLogs, PrerequisiteCycle) { + auto alice = ContextBuilder().Kind("user", "alice").Build(); + + auto flag = store_->GetFlag("cycleFlagA")->item.value(); + + auto detail = eval_.Evaluate(flag, alice); + ASSERT_FALSE(detail); + ASSERT_EQ(detail.Reason(), EvaluationReason::MalformedFlag()); + ASSERT_TRUE(messages_->Count(1)); + ASSERT_TRUE(messages_->Contains(0, LogLevel::kError, "circular reference")); +} + +TEST_F(EvaluatorTests, FlagWithSegment) { + auto alice = ContextBuilder().Kind("user", "alice").Build(); + auto bob = ContextBuilder().Kind("user", "bob").Build(); + + auto flag = store_->GetFlag("flagWithSegmentMatchRule")->item.value(); + + auto detail = eval_.Evaluate(flag, alice); + ASSERT_TRUE(detail); + ASSERT_EQ(*detail, Value(false)); + ASSERT_EQ(detail.Reason(), + EvaluationReason::RuleMatch(0, "match-rule", false)); + + detail = eval_.Evaluate(flag, bob); + ASSERT_TRUE(detail); + ASSERT_EQ(*detail, Value(true)); + ASSERT_EQ(detail.Reason(), EvaluationReason::Fallthrough(false)); +} + +TEST_F(EvaluatorTests, FlagWithSegmentContainingRules) { + auto alice = ContextBuilder().Kind("user", "alice").Build(); + auto bob = ContextBuilder().Kind("user", "bob").Build(); + + auto flag = + store_->GetFlag("flagWithSegmentMatchesUserAlice")->item.value(); + + auto detail = eval_.Evaluate(flag, alice); + ASSERT_TRUE(detail); + ASSERT_EQ(detail.Reason(), + EvaluationReason::RuleMatch(0, "match-rule", false)); + ASSERT_EQ(*detail, Value(false)); + + detail = eval_.Evaluate(flag, bob); + ASSERT_TRUE(detail); + ASSERT_EQ(detail.Reason(), EvaluationReason::Fallthrough(false)); + ASSERT_EQ(*detail, Value(true)); +} + +TEST_F(EvaluatorTests, FlagWithExperiment) { + auto user_a = ContextBuilder().Kind("user", "userKeyA").Build(); + auto user_b = ContextBuilder().Kind("user", "userKeyB").Build(); + auto user_c = ContextBuilder().Kind("user", "userKeyC").Build(); + + auto flag = store_->GetFlag("flagWithExperiment")->item.value(); + + auto detail = eval_.Evaluate(flag, user_a); + ASSERT_TRUE(detail); + ASSERT_EQ(*detail, Value(false)); + ASSERT_TRUE(detail.Reason()->InExperiment()); + + detail = eval_.Evaluate(flag, user_b); + ASSERT_TRUE(detail); + ASSERT_EQ(*detail, Value(true)); + ASSERT_TRUE(detail.Reason()->InExperiment()); + + detail = eval_.Evaluate(flag, user_c); + ASSERT_TRUE(detail); + ASSERT_EQ(*detail, Value(false)); + ASSERT_FALSE(detail.Reason()->InExperiment()); +} + +TEST_F(EvaluatorTests, FlagWithExperimentTargetingMissingContext) { + auto flag = + store_->GetFlag("flagWithExperimentTargetingContext")->item.value(); + + auto user_a = ContextBuilder().Kind("user", "userKeyA").Build(); + + auto detail = eval_.Evaluate(flag, user_a); + ASSERT_TRUE(detail); + ASSERT_EQ(*detail, Value(false)); + ASSERT_EQ(detail.Reason(), EvaluationReason::Fallthrough(false)); +} diff --git a/libs/server-sdk/tests/event_factory_tests.cpp b/libs/server-sdk/tests/event_factory_tests.cpp new file mode 100644 index 000000000..11eccd1e8 --- /dev/null +++ b/libs/server-sdk/tests/event_factory_tests.cpp @@ -0,0 +1,42 @@ +#include + +#include +#include +#include + +#include "events/event_factory.hpp" + +using namespace launchdarkly; +using namespace launchdarkly::server_side; + +class EventFactoryTests : public testing::Test { + public: + EventFactoryTests() + : context_(ContextBuilder().Kind("cat", "shadow").Build()) {} + Context context_; +}; + +TEST_F(EventFactoryTests, IncludesReasonIfInExperiment) { + auto factory = EventFactory::WithoutReasons(); + auto event = + factory.Eval("flag", context_, data_model::Flag{}, + EvaluationReason::Fallthrough(true), false, std::nullopt); + ASSERT_TRUE(std::get(event).reason.has_value()); +} + +TEST_F(EventFactoryTests, DoesNotIncludeReasonIfNotInExperiment) { + auto factory = EventFactory::WithoutReasons(); + auto event = + factory.Eval("flag", context_, data_model::Flag{}, + EvaluationReason::Fallthrough(false), false, std::nullopt); + ASSERT_FALSE( + std::get(event).reason.has_value()); +} + +TEST_F(EventFactoryTests, IncludesReasonIfForcedByFactory) { + auto factory = EventFactory::WithReasons(); + auto event = + factory.Eval("flag", context_, data_model::Flag{}, + EvaluationReason::Fallthrough(false), false, std::nullopt); + ASSERT_TRUE(std::get(event).reason.has_value()); +} diff --git a/libs/server-sdk/tests/event_scope_test.cpp b/libs/server-sdk/tests/event_scope_test.cpp new file mode 100644 index 000000000..4497d6bb3 --- /dev/null +++ b/libs/server-sdk/tests/event_scope_test.cpp @@ -0,0 +1,73 @@ +#include + +#include + +#include "spy_event_processor.hpp" + +#include "events/event_scope.hpp" + +using namespace launchdarkly; +using namespace launchdarkly::server_side; + +TEST(EventScope, DefaultConstructedScopeHasNoObservableEffects) { + EventScope default_scope; + default_scope.Send([](EventFactory const& factory) { + return factory.Identify(ContextBuilder().Kind("cat", "shadow").Build()); + }); +} + +TEST(EventScope, SendWithNullProcessorHasNoObservableEffects) { + EventScope scope(nullptr, EventFactory::WithoutReasons()); + scope.Send([](EventFactory const& factory) { + return factory.Identify(ContextBuilder().Kind("cat", "shadow").Build()); + }); +} + +TEST(EventScope, ForwardsEvents) { + SpyEventProcessor processor; + EventScope scope(&processor, EventFactory::WithoutReasons()); + + const std::size_t kEventCount = 10; + + for (std::size_t i = 0; i < kEventCount; ++i) { + scope.Send([](EventFactory const& factory) { + return factory.Identify( + ContextBuilder().Kind("cat", "shadow").Build()); + }); + } + + ASSERT_TRUE(processor.Count(kEventCount)); +} + +TEST(EventScope, ForwardsCorrectEventTypes) { + SpyEventProcessor processor; + EventScope scope(&processor, EventFactory::WithoutReasons()); + + scope.Send([](EventFactory const& factory) { + return factory.Identify(ContextBuilder().Kind("cat", "shadow").Build()); + }); + + scope.Send([](EventFactory const& factory) { + return factory.UnknownFlag( + "flag", ContextBuilder().Kind("cat", "shadow").Build(), + EvaluationReason::Fallthrough(false), true); + }); + + scope.Send([](EventFactory const& factory) { + return factory.Eval("flag", + ContextBuilder().Kind("cat", "shadow").Build(), + std::nullopt, EvaluationReason::Fallthrough(false), + false, std::nullopt); + }); + + scope.Send([](EventFactory const& factory) { + return factory.Custom(ContextBuilder().Kind("cat", "shadow").Build(), + "event", std::nullopt, std::nullopt); + }); + + ASSERT_TRUE(processor.Count(4)); + ASSERT_TRUE(processor.Kind(0)); + ASSERT_TRUE(processor.Kind(1)); + ASSERT_TRUE(processor.Kind(2)); + ASSERT_TRUE(processor.Kind(3)); +} diff --git a/libs/server-sdk/tests/expiration_tracker_test.cpp b/libs/server-sdk/tests/expiration_tracker_test.cpp new file mode 100644 index 000000000..a8d17f99c --- /dev/null +++ b/libs/server-sdk/tests/expiration_tracker_test.cpp @@ -0,0 +1,122 @@ +#include + +#include "data_store/persistent/expiration_tracker.hpp" + +using launchdarkly::server_side::data_store::DataKind; +using launchdarkly::server_side::data_store::persistent::ExpirationTracker; + +ExpirationTracker::TimePoint Second(uint64_t second) { + return std::chrono::steady_clock::time_point{std::chrono::seconds{second}}; +} + +TEST(ExpirationTrackerTest, CanTrackUnscopedItem) { + ExpirationTracker tracker; + tracker.Add("Potato", Second(10)); + EXPECT_EQ(ExpirationTracker::TrackState::kFresh, + tracker.State("Potato", Second(0))); + + EXPECT_EQ(ExpirationTracker::TrackState::kStale, + tracker.State("Potato", Second(11))); +} + +TEST(ExpirationTrackerTest, CanGetStateOfUntrackedUnscopedItem) { + ExpirationTracker tracker; + EXPECT_EQ(ExpirationTracker::TrackState::kNotTracked, + tracker.State("Potato", Second(0))); +} + +TEST(ExpirationTrackerTest, CanTrackScopedItem) { + ExpirationTracker tracker; + tracker.Add(DataKind::kFlag, "Potato", Second(10)); + + EXPECT_EQ(ExpirationTracker::TrackState::kFresh, + tracker.State(DataKind::kFlag, "Potato", Second(0))); + + EXPECT_EQ(ExpirationTracker::TrackState::kStale, + tracker.State(DataKind::kFlag, "Potato", Second(11))); + + // Is not considered unscoped. + EXPECT_EQ(ExpirationTracker::TrackState::kNotTracked, + tracker.State("Potato", Second(11))); + + // The wrong scope is not tracked. + EXPECT_EQ(ExpirationTracker::TrackState::kNotTracked, + tracker.State(DataKind::kSegment, "Potato", Second(11))); +} + +TEST(ExpirationTrackerTest, CanTrackSameKeyInMultipleScopes) { + ExpirationTracker tracker; + tracker.Add("Potato", Second(0)); + tracker.Add(DataKind::kFlag, "Potato", Second(10)); + tracker.Add(DataKind::kSegment, "Potato", Second(20)); + + EXPECT_EQ(ExpirationTracker::TrackState::kStale, + tracker.State("Potato", Second(9))); + + EXPECT_EQ(ExpirationTracker::TrackState::kFresh, + tracker.State(DataKind::kFlag, "Potato", Second(9))); + + EXPECT_EQ(ExpirationTracker::TrackState::kFresh, + tracker.State(DataKind::kSegment, "Potato", Second(10))); + + EXPECT_EQ(ExpirationTracker::TrackState::kStale, + tracker.State(DataKind::kFlag, "Potato", Second(11))); + + EXPECT_EQ(ExpirationTracker::TrackState::kFresh, + tracker.State(DataKind::kSegment, "Potato", Second(11))); +} + +TEST(ExpirationTrackerTest, CanClear) { + ExpirationTracker tracker; + tracker.Add("Potato", Second(0)); + tracker.Add(DataKind::kFlag, "Potato", Second(10)); + tracker.Add(DataKind::kSegment, "Potato", Second(20)); + + tracker.Clear(); + + EXPECT_EQ(ExpirationTracker::TrackState::kNotTracked, + tracker.State("Potato", Second(0))); + + EXPECT_EQ(ExpirationTracker::TrackState::kNotTracked, + tracker.State(DataKind::kFlag, "Potato", Second(0))); + + EXPECT_EQ(ExpirationTracker::TrackState::kNotTracked, + tracker.State(DataKind::kSegment, "Potato", Second(0))); +} + +TEST(ExpirationTrackerTest, CanPrune) { + ExpirationTracker tracker; + tracker.Add("freshUnscoped", Second(100)); + tracker.Add(DataKind::kFlag, "freshFlag", Second(100)); + tracker.Add(DataKind::kSegment, "freshSegment", Second(100)); + + tracker.Add("staleUnscoped", Second(50)); + tracker.Add(DataKind::kFlag, "staleFlag", Second(50)); + tracker.Add(DataKind::kSegment, "staleSegment", Second(50)); + + auto pruned = tracker.Prune(Second(80)); + EXPECT_EQ(3, pruned.size()); + std::vector, std::string>> + expected_pruned{{std::nullopt, "staleUnscoped"}, + {DataKind::kFlag, "staleFlag"}, + {DataKind::kSegment, "staleSegment"}}; + EXPECT_EQ(expected_pruned, pruned); + + EXPECT_EQ(ExpirationTracker::TrackState::kNotTracked, + tracker.State("staleUnscoped", Second(80))); + + EXPECT_EQ(ExpirationTracker::TrackState::kNotTracked, + tracker.State(DataKind::kFlag, "staleFlag", Second(80))); + + EXPECT_EQ(ExpirationTracker::TrackState::kNotTracked, + tracker.State(DataKind::kSegment, "staleSegment", Second(80))); + + EXPECT_EQ(ExpirationTracker::TrackState::kFresh, + tracker.State("freshUnscoped", Second(80))); + + EXPECT_EQ(ExpirationTracker::TrackState::kFresh, + tracker.State(DataKind::kFlag, "freshFlag", Second(80))); + + EXPECT_EQ(ExpirationTracker::TrackState::kFresh, + tracker.State(DataKind::kSegment, "freshSegment", Second(80))); +} diff --git a/libs/server-sdk/tests/memory_store_test.cpp b/libs/server-sdk/tests/memory_store_test.cpp new file mode 100644 index 000000000..852d112dc --- /dev/null +++ b/libs/server-sdk/tests/memory_store_test.cpp @@ -0,0 +1,286 @@ +#include + +#include "data_store/descriptors.hpp" +#include "data_store/memory_store.hpp" + +using launchdarkly::data_model::SDKDataSet; +using launchdarkly::server_side::data_store::FlagDescriptor; +using launchdarkly::server_side::data_store::IDataStore; +using launchdarkly::server_side::data_store::MemoryStore; +using launchdarkly::server_side::data_store::SegmentDescriptor; + +using launchdarkly::Value; +using launchdarkly::data_model::Flag; +using launchdarkly::data_model::Segment; + +TEST(MemoryStoreTest, StartsUninitialized) { + MemoryStore store; + EXPECT_FALSE(store.Initialized()); +} + +TEST(MemoryStoreTest, IsInitializedAfterInit) { + MemoryStore store; + store.Init(SDKDataSet()); + EXPECT_TRUE(store.Initialized()); +} + +TEST(MemoryStoreTest, HasDescription) { + MemoryStore store; + EXPECT_EQ(std::string("memory"), store.Description()); +} + +TEST(MemoryStoreTest, CanGetFlag) { + MemoryStore store; + Flag flag; + flag.version = 1; + flag.key = "flagA"; + flag.on = true; + flag.variations = std::vector{true, false}; + Flag::Variation variation = 0; + flag.fallthrough = variation; + store.Init(SDKDataSet{ + std::unordered_map{ + {"flagA", FlagDescriptor(flag)}}, + std::unordered_map(), + }); + + auto fetched_flag = store.GetFlag("flagA"); + EXPECT_TRUE(fetched_flag); + EXPECT_TRUE(fetched_flag->item); + EXPECT_EQ("flagA", fetched_flag->item->key); + EXPECT_EQ(1, fetched_flag->item->version); + EXPECT_EQ(fetched_flag->version, fetched_flag->item->version); +} + +TEST(MemoryStoreTest, CanGetAllFlags) { + Flag flag_a; + flag_a.version = 1; + flag_a.key = "flagA"; + + Flag flag_b; + flag_b.version = 2; + flag_b.key = "flagB"; + + MemoryStore store; + store.Init(SDKDataSet{ + std::unordered_map{ + {"flagA", FlagDescriptor(flag_a)}, + {"flagB", FlagDescriptor(flag_b)}}, + std::unordered_map(), + }); + + auto fetched = store.AllFlags(); + EXPECT_EQ(2, fetched.size()); + + EXPECT_EQ(std::string("flagA"), fetched["flagA"]->item->key); + EXPECT_EQ(std::string("flagB"), fetched["flagB"]->item->key); +} + +TEST(MemoryStoreTest, CanGetAllFlagsWhenThereAreNoFlags) { + MemoryStore store; + store.Init(SDKDataSet{ + std::unordered_map(), + std::unordered_map(), + }); + + auto fetched = store.AllFlags(); + EXPECT_EQ(0, fetched.size()); +} + +TEST(MemoryStoreTest, CanGetSegment) { + MemoryStore store; + auto segment = Segment(); + segment.version = 1; + segment.key = "segmentA"; + store.Init(SDKDataSet{ + std::unordered_map(), + std::unordered_map{ + {"segmentA", SegmentDescriptor(segment)}}, + }); + + auto fetched_segment = store.GetSegment("segmentA"); + EXPECT_TRUE(fetched_segment); + EXPECT_TRUE(fetched_segment->item); + EXPECT_EQ("segmentA", fetched_segment->item->key); + EXPECT_EQ(1, fetched_segment->item->version); + EXPECT_EQ(fetched_segment->version, fetched_segment->item->version); +} + +TEST(MemoryStoreTest, CanGetAllSegments) { + auto segment_a = Segment(); + segment_a.version = 1; + segment_a.key = "segmentA"; + + auto segment_b = Segment(); + segment_b.version = 2; + segment_b.key = "segmentB"; + + MemoryStore store; + store.Init(SDKDataSet{ + std::unordered_map(), + std::unordered_map{ + {"segmentA", SegmentDescriptor(segment_a)}, + {"segmentB", SegmentDescriptor(segment_b)}}, + }); + + auto fetched = store.AllSegments(); + EXPECT_EQ(2, fetched.size()); + + EXPECT_EQ(std::string("segmentA"), fetched["segmentA"]->item->key); + EXPECT_EQ(std::string("segmentB"), fetched["segmentB"]->item->key); +} + +TEST(MemoryStoreTest, CanGetAllSegmentsWhenThereAreNoSegments) { + MemoryStore store; + store.Init(SDKDataSet{ + std::unordered_map(), + std::unordered_map(), + }); + + auto fetched = store.AllSegments(); + EXPECT_EQ(0, fetched.size()); +} + +TEST(MemoryStoreTest, GetMissingFlagOrSegment) { + MemoryStore store; + auto fetched_flag = store.GetFlag("flagA"); + EXPECT_FALSE(fetched_flag); + auto fetched_segment = store.GetSegment("segmentA"); + EXPECT_FALSE(fetched_segment); +} + +TEST(MemoryStoreTest, CanUpsertNewFlag) { + Flag flag_a; + flag_a.version = 1; + flag_a.key = "flagA"; + + MemoryStore store; + store.Init(SDKDataSet{ + std::unordered_map(), + std::unordered_map(), + }); + store.Upsert("flagA", FlagDescriptor(flag_a)); + + auto fetched_flag = store.GetFlag("flagA"); + EXPECT_TRUE(fetched_flag); + EXPECT_TRUE(fetched_flag->item); + EXPECT_EQ("flagA", fetched_flag->item->key); + EXPECT_EQ(1, fetched_flag->item->version); + EXPECT_EQ(fetched_flag->version, fetched_flag->item->version); +} + +TEST(MemoryStoreTest, CanUpsertExitingFlag) { + Flag flag_a; + flag_a.version = 1; + flag_a.key = "flagA"; + + MemoryStore store; + store.Init(SDKDataSet{ + std::unordered_map{ + {"flagA", FlagDescriptor(flag_a)}}, + std::unordered_map(), + }); + + Flag flag_a_2; + flag_a_2.version = 2; + flag_a_2.key = "flagA"; + + store.Upsert("flagA", FlagDescriptor(flag_a_2)); + + auto fetched_flag = store.GetFlag("flagA"); + EXPECT_TRUE(fetched_flag); + EXPECT_TRUE(fetched_flag->item); + EXPECT_EQ("flagA", fetched_flag->item->key); + EXPECT_EQ(2, fetched_flag->item->version); + EXPECT_EQ(fetched_flag->version, fetched_flag->item->version); +} + +TEST(MemoryStoreTest, CanUpsertNewSegment) { + Segment segment_a; + segment_a.version = 1; + segment_a.key = "segmentA"; + + MemoryStore store; + store.Init(SDKDataSet{ + std::unordered_map(), + std::unordered_map(), + }); + store.Upsert("segmentA", SegmentDescriptor(segment_a)); + + auto fetched_segment = store.GetSegment("segmentA"); + EXPECT_TRUE(fetched_segment); + EXPECT_TRUE(fetched_segment->item); + EXPECT_EQ("segmentA", fetched_segment->item->key); + EXPECT_EQ(1, fetched_segment->item->version); + EXPECT_EQ(fetched_segment->version, fetched_segment->item->version); +} + +TEST(MemoryStoreTest, CanUpsertExitingSegment) { + Segment segment_a; + segment_a.version = 1; + segment_a.key = "segmentA"; + + MemoryStore store; + store.Init(SDKDataSet{ + std::unordered_map(), + std::unordered_map{ + {"segmentA", SegmentDescriptor(segment_a)}}, + }); + + Segment segment_a_2; + segment_a_2.version = 2; + segment_a_2.key = "segmentA"; + + store.Upsert("segmentA", SegmentDescriptor(segment_a_2)); + + auto fetched_segment = store.GetSegment("segmentA"); + EXPECT_TRUE(fetched_segment); + EXPECT_TRUE(fetched_segment->item); + EXPECT_EQ("segmentA", fetched_segment->item->key); + EXPECT_EQ(2, fetched_segment->item->version); + EXPECT_EQ(fetched_segment->version, fetched_segment->item->version); +} + +TEST(MemoryStoreTest, OriginalFlagValidAfterUpsertOfFlag) { + Flag flag_a; + flag_a.version = 1; + flag_a.key = "flagA"; + flag_a.variations = std::vector{"potato", "ham"}; + + MemoryStore store; + store.Init(SDKDataSet{ + std::unordered_map{ + {"flagA", FlagDescriptor(flag_a)}}, + std::unordered_map(), + }); + auto fetched_flag_before = store.GetFlag("flagA"); + + Flag flag_a_2; + flag_a_2.version = 2; + flag_a_2.key = "flagA"; + flag_a_2.variations = std::vector{"potato"}; + + store.Upsert("flagA", FlagDescriptor(flag_a_2)); + + auto fetched_flag_after = store.GetFlag("flagA"); + + EXPECT_TRUE(fetched_flag_before); + EXPECT_TRUE(fetched_flag_before->item); + EXPECT_EQ("flagA", fetched_flag_before->item->key); + EXPECT_EQ(1, fetched_flag_before->item->version); + EXPECT_EQ(fetched_flag_before->version, fetched_flag_before->item->version); + EXPECT_EQ(2, fetched_flag_before->item->variations.size()); + EXPECT_EQ(std::string("potato"), + fetched_flag_before->item->variations[0].AsString()); + EXPECT_EQ(std::string("ham"), + fetched_flag_before->item->variations[1].AsString()); + + EXPECT_TRUE(fetched_flag_after); + EXPECT_TRUE(fetched_flag_after->item); + EXPECT_EQ("flagA", fetched_flag_after->item->key); + EXPECT_EQ(2, fetched_flag_after->item->version); + EXPECT_EQ(fetched_flag_after->version, fetched_flag_after->item->version); + EXPECT_EQ(1, fetched_flag_after->item->variations.size()); + EXPECT_EQ(std::string("potato"), + fetched_flag_after->item->variations[0].AsString()); +} diff --git a/libs/server-sdk/tests/operator_tests.cpp b/libs/server-sdk/tests/operator_tests.cpp new file mode 100644 index 000000000..e804c22e3 --- /dev/null +++ b/libs/server-sdk/tests/operator_tests.cpp @@ -0,0 +1,260 @@ +#include + +#include + +#include "evaluation/evaluator.hpp" +#include "evaluation/operators.hpp" +#include "evaluation/rules.hpp" + +using namespace launchdarkly::server_side::evaluation::operators; +using namespace launchdarkly::data_model; +using namespace launchdarkly; + +TEST(OpTests, StartsWith) { + EXPECT_TRUE(Match(Clause::Op::kStartsWith, "", "")); + EXPECT_TRUE(Match(Clause::Op::kStartsWith, "a", "")); + EXPECT_TRUE(Match(Clause::Op::kStartsWith, "a", "a")); + + EXPECT_TRUE(Match(Clause::Op::kStartsWith, "food", "foo")); + EXPECT_FALSE(Match(Clause::Op::kStartsWith, "foo", "food")); + + EXPECT_FALSE(Match(Clause::Op::kStartsWith, "Food", "foo")); +} + +TEST(OpTests, EndsWith) { + EXPECT_TRUE(Match(Clause::Op::kEndsWith, "", "")); + EXPECT_TRUE(Match(Clause::Op::kEndsWith, "a", "")); + EXPECT_TRUE(Match(Clause::Op::kEndsWith, "a", "a")); + + EXPECT_TRUE(Match(Clause::Op::kEndsWith, "food", "ood")); + EXPECT_FALSE(Match(Clause::Op::kEndsWith, "ood", "food")); + + EXPECT_FALSE(Match(Clause::Op::kEndsWith, "FOOD", "ood")); +} + +TEST(OpTests, NumericComparisons) { + EXPECT_TRUE(Match(Clause::Op::kLessThan, 0, 1)); + EXPECT_FALSE(Match(Clause::Op::kLessThan, 1, 0)); + EXPECT_FALSE(Match(Clause::Op::kLessThan, 0, 0)); + + EXPECT_TRUE(Match(Clause::Op::kGreaterThan, 1, 0)); + EXPECT_FALSE(Match(Clause::Op::kGreaterThan, 0, 1)); + EXPECT_FALSE(Match(Clause::Op::kGreaterThan, 0, 0)); + + EXPECT_TRUE(Match(Clause::Op::kLessThanOrEqual, 0, 1)); + EXPECT_TRUE(Match(Clause::Op::kLessThanOrEqual, 0, 0)); + EXPECT_FALSE(Match(Clause::Op::kLessThanOrEqual, 1, 0)); + + EXPECT_TRUE(Match(Clause::Op::kGreaterThanOrEqual, 1, 0)); + EXPECT_TRUE(Match(Clause::Op::kGreaterThanOrEqual, 0, 0)); + EXPECT_FALSE(Match(Clause::Op::kGreaterThanOrEqual, 0, 1)); +} + +// We can only support microsecond precision due to resolution of the +// system_clock::time_point. +// +// The spec says we should support no more than 9 digits +// (nanoseconds.) This test attempts to verify that microsecond precision +// differences are handled. +TEST(OpTests, DateComparisonMicrosecondPrecision) { + auto dates = std::vector>{ + // Using Zulu suffix. + {"2023-10-08T02:00:00.000001Z", "2023-10-08T02:00:00.000002Z"}, + // Using offset suffix. + {"2023-10-08T02:00:00.000001+00:00", + "2023-10-08T02:00:00.000002+00:00"}}; + + for (auto const& [date1, date2] : dates) { + EXPECT_TRUE(Match(Clause::Op::kBefore, date1, date2)) + << date1 << " < " << date2; + + EXPECT_FALSE(Match(Clause::Op::kAfter, date1, date2)) + << date1 << " not > " << date2; + + EXPECT_FALSE(Match(Clause::Op::kBefore, date2, date1)) + << date2 << " not < " << date1; + + EXPECT_TRUE(Match(Clause::Op::kAfter, date2, date1)) + << date2 << " > " << date1; + } +} + +// This test is meant to verify that platforms with > microsecond precision +// still compare dates correctly. If the platform doesn't support > microsecond +// precision, then we try to verify that the reverse comparison is also false +// (if we had an equal operator we'd use that instead.) +TEST(OpTests, DateComparisonWithMoreThanMicrosecondPrecision) { + auto dates = std::vector>{ + // Using Zulu suffix. + {"2023-10-08T02:00:00.000001Z", "2023-10-08T02:00:00.0000011Z"}, + // Using offset suffix. + {"2023-10-08T02:00:00.0000000001+00:00", + "2023-10-08T02:00:00.00000000011+00:00"}}; + + for (auto const& [date1, date2] : dates) { + bool date1_before_date2 = Match(Clause::Op::kBefore, date1, date2); + if (date1_before_date2) { + // Platform seems to support > microsecond precision. + EXPECT_TRUE(Match(Clause::Op::kAfter, date2, date1)) + << date1 << " > " << date2; + } else { + EXPECT_FALSE(Match(Clause::Op::kBefore, date2, date1)) + << date2 << " not < " << date1; + } + } +} + +// Because RFC3339 timestamps may use 'Z' to indicate a 00:00 offset, +// we should ensure these timestamps can be compared to timestamps using normal +// offsets. +TEST(OpTests, AcceptsZuluAndNormalTimezoneOffsets) { + const std::string kDate1 = "1985-04-12T23:20:50Z"; + const std::string kDate2 = "1986-04-12T23:20:50-01:00"; + + EXPECT_TRUE(Match(Clause::Op::kBefore, kDate1, kDate2)); + EXPECT_FALSE(Match(Clause::Op::kAfter, kDate1, kDate2)); + + EXPECT_FALSE(Match(Clause::Op::kBefore, kDate2, kDate1)); + EXPECT_TRUE(Match(Clause::Op::kAfter, kDate2, kDate1)); +} + +TEST(OpTests, InvalidDates) { + EXPECT_FALSE(Match(Clause::Op::kBefore, "2021-01-08T02:00:00-00:00", + "2021-12345-08T02:00:00-00:00")); + + EXPECT_FALSE(Match(Clause::Op::kAfter, "2021-12345-08T02:00:00-00:00", + "2021-01-08T02:00:00-00:00")); + + EXPECT_FALSE(Match(Clause::Op::kBefore, "foo", "bar")); + + EXPECT_FALSE(Match(Clause::Op::kAfter, "foo", "bar")); + + EXPECT_FALSE(Match(Clause::Op::kBefore, "", "bar")); + EXPECT_FALSE(Match(Clause::Op::kAfter, "", "bar")); + + EXPECT_FALSE(Match(Clause::Op::kBefore, "foo", "")); + EXPECT_FALSE(Match(Clause::Op::kAfter, "foo", "")); +} + +struct RegexTest { + std::string input; + std::string regex; + bool shouldMatch; +}; + +class RegexTests : public ::testing::TestWithParam {}; + +TEST_P(RegexTests, Matches) { + auto const& param = GetParam(); + auto const result = Match(Clause::Op::kMatches, param.input, param.regex); + + EXPECT_EQ(result, param.shouldMatch) + << "input: (" << (param.input.empty() ? "empty string" : param.input) + << ")\nregex: (" << (param.regex.empty() ? "empty string" : param.regex) + << ")"; +} + +#define MATCH true +#define NO_MATCH false + +INSTANTIATE_TEST_SUITE_P(RegexComparisons, + RegexTests, + ::testing::ValuesIn({ + RegexTest{"", "", MATCH}, + RegexTest{"a", "", MATCH}, + RegexTest{"a", "a", MATCH}, + RegexTest{"a", ".", MATCH}, + RegexTest{"hello world", "hello.*rld", MATCH}, + RegexTest{"hello world", "hello.*orl", MATCH}, + RegexTest{"hello world", "l+", MATCH}, + RegexTest{"hello world", "(world|planet)", MATCH}, + RegexTest{"", ".", NO_MATCH}, + RegexTest{"", R"(\)", NO_MATCH}, + RegexTest{"hello world", "aloha", NO_MATCH}, + RegexTest{"hello world", "***bad regex", NO_MATCH}, + })); + +#define SEMVER_NOT_EQUAL "!=" +#define SEMVER_EQUAL "==" +#define SEMVER_GREATER ">" +#define SEMVER_LESS "<" + +struct SemVerTest { + std::string lhs; + std::string rhs; + std::string op; + bool shouldMatch; +}; + +class SemVerTests : public ::testing::TestWithParam {}; + +TEST_P(SemVerTests, Matches) { + auto const& param = GetParam(); + + bool result = false; + bool swapped = false; + + if (param.op == SEMVER_EQUAL) { + result = Match(Clause::Op::kSemVerEqual, param.lhs, param.rhs); + swapped = Match(Clause::Op::kSemVerEqual, param.rhs, param.lhs); + } else if (param.op == SEMVER_NOT_EQUAL) { + result = !Match(Clause::Op::kSemVerEqual, param.lhs, param.rhs); + swapped = !Match(Clause::Op::kSemVerEqual, param.rhs, param.lhs); + } else if (param.op == SEMVER_GREATER) { + result = Match(Clause::Op::kSemVerGreaterThan, param.lhs, param.rhs); + swapped = Match(Clause::Op::kSemVerLessThan, param.rhs, param.lhs); + } else if (param.op == SEMVER_LESS) { + result = Match(Clause::Op::kSemVerLessThan, param.lhs, param.rhs); + swapped = Match(Clause::Op::kSemVerGreaterThan, param.rhs, param.lhs); + } else { + FAIL() << "Invalid operator: " << param.op; + } + + EXPECT_EQ(result, param.shouldMatch) + << param.lhs << " " << param.op << " " << param.rhs << " should be " + << (param.shouldMatch ? "true" : "false"); + + EXPECT_EQ(result, swapped) + << "commutative property invalid for " << param.lhs << " " << param.op + << " " << param.rhs; +} + +INSTANTIATE_TEST_SUITE_P( + SemVerComparisons, + SemVerTests, + ::testing::ValuesIn( + {SemVerTest{"2.0.0", "2.0.0", SEMVER_EQUAL, MATCH}, + SemVerTest{"2.0", "2.0.0", SEMVER_EQUAL, MATCH}, + SemVerTest{"2", "2.0.0", SEMVER_EQUAL, MATCH}, + SemVerTest{"2", "2.0.0+123", SEMVER_EQUAL, MATCH}, + SemVerTest{"2+456", "2.0.0+123", SEMVER_EQUAL, MATCH}, + SemVerTest{"2.0.0", "3.0.0", SEMVER_NOT_EQUAL, MATCH}, + SemVerTest{"2.0.0", "2.1.0", SEMVER_NOT_EQUAL, MATCH}, + SemVerTest{"2.0.0", "2.0.1", SEMVER_NOT_EQUAL, MATCH}, + SemVerTest{"3.0.0", "2.0.0", SEMVER_GREATER, MATCH}, + SemVerTest{"2.1.0", "2.0.0", SEMVER_GREATER, MATCH}, + SemVerTest{"2.0.1", "2.0.0", SEMVER_GREATER, MATCH}, + SemVerTest{"2.0.0", "2.0.0", SEMVER_GREATER, NO_MATCH}, + SemVerTest{"1.9.0", "2.0.0", SEMVER_GREATER, NO_MATCH}, + SemVerTest{"2.0.0-rc", "2.0.0", SEMVER_GREATER, NO_MATCH}, + SemVerTest{"2.0.0+build", "2.0.0", SEMVER_GREATER, NO_MATCH}, + SemVerTest{"2.0.0+build", "2.0.0", SEMVER_EQUAL, MATCH}, + SemVerTest{"2.0.0", "200", SEMVER_EQUAL, NO_MATCH}, + SemVerTest{"2.0.0-rc.10.green", "2.0.0-rc.2.green", SEMVER_GREATER, + MATCH}, + SemVerTest{"2.0.0-rc.2.red", "2.0.0-rc.2.green", SEMVER_GREATER, + MATCH}, + SemVerTest{"2.0.0-rc.2.green.1", "2.0.0-rc.2.green", SEMVER_GREATER, + MATCH}, + SemVerTest{"2.0.0-rc.1.very.long.prerelease.version.1234567.keeps." + "going+123124", + "2.0.0", SEMVER_LESS, MATCH}, + SemVerTest{"1", "2", SEMVER_LESS, MATCH}, + SemVerTest{"0", "1", SEMVER_LESS, MATCH}})); + +#undef SEMVER_NOT_EQUAL +#undef SEMVER_EQUAL +#undef SEMVER_GREATER +#undef SEMVER_LESS +#undef MATCH +#undef NO_MATCH diff --git a/libs/server-sdk/tests/rule_tests.cpp b/libs/server-sdk/tests/rule_tests.cpp new file mode 100644 index 000000000..56d9d0f1b --- /dev/null +++ b/libs/server-sdk/tests/rule_tests.cpp @@ -0,0 +1,251 @@ +#include + +#include + +#include "evaluation/evaluator.hpp" +#include "evaluation/rules.hpp" + +#include "test_store.hpp" + +using namespace launchdarkly::data_model; +using namespace launchdarkly::server_side; +using namespace launchdarkly; + +struct ClauseTest { + Clause::Op op; + launchdarkly::Value contextValue; + launchdarkly::Value clauseValue; + bool expected; +}; + +class AllOperatorsTest : public ::testing::TestWithParam { + public: + const static std::string DATE_STR1; + const static std::string DATE_STR2; + const static int DATE_MS1; + const static int DATE_MS2; + const static int DATE_MS_NEGATIVE; + const static std::string INVALID_DATE; +}; + +const std::string AllOperatorsTest::DATE_STR1 = "2017-12-06T00:00:00.000-07:00"; +const std::string AllOperatorsTest::DATE_STR2 = "2017-12-06T00:01:01.000-07:00"; +int const AllOperatorsTest::DATE_MS1 = 10000000; +int const AllOperatorsTest::DATE_MS2 = 10000001; +int const AllOperatorsTest::DATE_MS_NEGATIVE = -10000; +const std::string AllOperatorsTest::INVALID_DATE = "hey what's this?"; + +TEST_P(AllOperatorsTest, Matches) { + using namespace launchdarkly::server_side::evaluation::detail; + using namespace launchdarkly; + + auto const& param = GetParam(); + + std::vector clauseValues; + + if (param.clauseValue.IsArray()) { + auto const& as_array = param.clauseValue.AsArray(); + clauseValues = std::vector{as_array.begin(), as_array.end()}; + } else { + clauseValues.push_back(param.clauseValue); + } + + Clause clause{param.op, std::move(clauseValues), false, ContextKind("user"), + "attr"}; + + auto context = launchdarkly::ContextBuilder() + .Kind("user", "key") + .Set("attr", param.contextValue) + .Build(); + ASSERT_TRUE(context.Valid()); + + EvaluationStack stack; + + auto store = test_store::Empty(); + + auto result = launchdarkly::server_side::evaluation::Match(clause, context, + *store, stack); + ASSERT_EQ(result, param.expected) + << context.Get("user", "attr") << " " << clause.op << " " + << Value(clause.values) << " should be " << param.expected; +} + +#define MATCH true +#define NO_MATCH false + +INSTANTIATE_TEST_SUITE_P( + NumericClauses, + AllOperatorsTest, + ::testing::ValuesIn({ + ClauseTest{Clause::Op::kIn, 99, 99, MATCH}, + ClauseTest{Clause::Op::kIn, 99.0, 99, MATCH}, + ClauseTest{Clause::Op::kIn, 99, 99.0, MATCH}, + ClauseTest{Clause::Op::kIn, 99, + std::vector{99, 98, 97, 96}, MATCH}, + ClauseTest{Clause::Op::kIn, 99.0001, 99.0001, MATCH}, + ClauseTest{Clause::Op::kIn, 99.0001, + std::vector{99.0001, 98.0, 97.0, 96.0}, + MATCH}, + ClauseTest{Clause::Op::kLessThan, 1, 1.99999, MATCH}, + ClauseTest{Clause::Op::kLessThan, 1.99999, 1, NO_MATCH}, + ClauseTest{Clause::Op::kLessThan, 1, 2, MATCH}, + ClauseTest{Clause::Op::kLessThanOrEqual, 1, 1.0, MATCH}, + ClauseTest{Clause::Op::kGreaterThan, 2, 1.99999, MATCH}, + ClauseTest{Clause::Op::kGreaterThan, 1.99999, 2, NO_MATCH}, + ClauseTest{Clause::Op::kGreaterThan, 2, 1, MATCH}, + ClauseTest{Clause::Op::kGreaterThanOrEqual, 1, 1.0, MATCH}, + + })); + +INSTANTIATE_TEST_SUITE_P( + StringClauses, + AllOperatorsTest, + ::testing::ValuesIn({ + ClauseTest{Clause::Op::kIn, "x", "x", MATCH}, + ClauseTest{Clause::Op::kIn, "x", + std::vector{"x", "a", "b", "c"}, MATCH}, + ClauseTest{Clause::Op::kIn, "x", "xyz", NO_MATCH}, + ClauseTest{Clause::Op::kStartsWith, "xyz", "x", MATCH}, + ClauseTest{Clause::Op::kStartsWith, "x", "xyz", NO_MATCH}, + ClauseTest{Clause::Op::kEndsWith, "xyz", "z", MATCH}, + ClauseTest{Clause::Op::kEndsWith, "z", "xyz", NO_MATCH}, + ClauseTest{Clause::Op::kContains, "xyz", "y", MATCH}, + ClauseTest{Clause::Op::kContains, "y", "xyz", NO_MATCH}, + })); + +INSTANTIATE_TEST_SUITE_P( + MixedStringAndNumbers, + AllOperatorsTest, + ::testing::ValuesIn({ + ClauseTest{Clause::Op::kIn, "99", 99, NO_MATCH}, + ClauseTest{Clause::Op::kIn, 99, "99", NO_MATCH}, + ClauseTest{Clause::Op::kContains, "99", 99, NO_MATCH}, + ClauseTest{Clause::Op::kStartsWith, "99", 99, NO_MATCH}, + ClauseTest{Clause::Op::kEndsWith, "99", 99, NO_MATCH}, + ClauseTest{Clause::Op::kLessThanOrEqual, "99", 99, NO_MATCH}, + ClauseTest{Clause::Op::kLessThanOrEqual, 99, "99", NO_MATCH}, + ClauseTest{Clause::Op::kGreaterThanOrEqual, "99", 99, NO_MATCH}, + ClauseTest{Clause::Op::kGreaterThanOrEqual, 99, "99", NO_MATCH}, + })); + +INSTANTIATE_TEST_SUITE_P( + BooleanEquality, + AllOperatorsTest, + ::testing::ValuesIn({ + ClauseTest{Clause::Op::kIn, true, true, MATCH}, + ClauseTest{Clause::Op::kIn, false, false, MATCH}, + ClauseTest{Clause::Op::kIn, true, false, NO_MATCH}, + ClauseTest{Clause::Op::kIn, false, true, NO_MATCH}, + ClauseTest{Clause::Op::kIn, true, + std::vector{false, true}, MATCH}, + })); + +INSTANTIATE_TEST_SUITE_P( + ArrayEquality, + AllOperatorsTest, + ::testing::ValuesIn({ + ClauseTest{Clause::Op::kIn, {{"x"}}, {{"x"}}, MATCH}, + ClauseTest{Clause::Op::kIn, {{"x"}}, {"x"}, NO_MATCH}, + ClauseTest{Clause::Op::kIn, {{"x"}}, {{"x"}, {"a"}, {"b"}}, MATCH}, + })); + +INSTANTIATE_TEST_SUITE_P( + ObjectEquality, + AllOperatorsTest, + ::testing::ValuesIn({ + ClauseTest{Clause::Op::kIn, Value::Object({{"x", "1"}}), + Value::Object({{"x", "1"}}), MATCH}, + ClauseTest{Clause::Op::kIn, Value::Object({{"x", "1"}}), + std::vector{ + Value::Object({{"x", "1"}}), + Value::Object({{"a", "2"}}), + Value::Object({{"b", "3"}}), + }, + MATCH}, + })); + +INSTANTIATE_TEST_SUITE_P( + RegexMatch, + AllOperatorsTest, + ::testing::ValuesIn({ + ClauseTest{Clause::Op::kMatches, "hello world", "hello.*rld", MATCH}, + ClauseTest{Clause::Op::kMatches, "hello world", "hello.*orl", MATCH}, + ClauseTest{Clause::Op::kMatches, "hello world", "l+", MATCH}, + ClauseTest{Clause::Op::kMatches, "hello world", "(world|planet)", + MATCH}, + ClauseTest{Clause::Op::kMatches, "hello world", "aloha", NO_MATCH}, + ClauseTest{Clause::Op::kMatches, "hello world", "***bad regex", + NO_MATCH}, + })); + +INSTANTIATE_TEST_SUITE_P( + DateClauses, + AllOperatorsTest, + ::testing::ValuesIn({ + ClauseTest{Clause::Op::kBefore, AllOperatorsTest::DATE_STR1, + AllOperatorsTest::DATE_STR2, MATCH}, + ClauseTest{Clause::Op::kBefore, AllOperatorsTest::DATE_MS1, + AllOperatorsTest::DATE_MS2, MATCH}, + ClauseTest{Clause::Op::kBefore, AllOperatorsTest::DATE_STR2, + AllOperatorsTest::DATE_STR1, NO_MATCH}, + ClauseTest{Clause::Op::kBefore, AllOperatorsTest::DATE_MS2, + AllOperatorsTest::DATE_MS1, NO_MATCH}, + ClauseTest{Clause::Op::kBefore, AllOperatorsTest::DATE_STR1, + AllOperatorsTest::DATE_STR1, NO_MATCH}, + ClauseTest{Clause::Op::kBefore, AllOperatorsTest::DATE_MS1, + AllOperatorsTest::DATE_MS1, NO_MATCH}, + ClauseTest{Clause::Op::kBefore, Value::Null(), + AllOperatorsTest::DATE_STR1, NO_MATCH}, + ClauseTest{Clause::Op::kBefore, AllOperatorsTest::DATE_STR1, + AllOperatorsTest::INVALID_DATE, NO_MATCH}, + ClauseTest{Clause::Op::kAfter, AllOperatorsTest::DATE_STR2, + AllOperatorsTest::DATE_STR1, MATCH}, + ClauseTest{Clause::Op::kAfter, AllOperatorsTest::DATE_MS2, + AllOperatorsTest::DATE_MS1, MATCH}, + ClauseTest{Clause::Op::kAfter, AllOperatorsTest::DATE_STR1, + AllOperatorsTest::DATE_STR2, NO_MATCH}, + ClauseTest{Clause::Op::kAfter, AllOperatorsTest::DATE_MS1, + AllOperatorsTest::DATE_MS2, NO_MATCH}, + ClauseTest{Clause::Op::kAfter, AllOperatorsTest::DATE_STR1, + AllOperatorsTest::DATE_STR1, NO_MATCH}, + ClauseTest{Clause::Op::kAfter, AllOperatorsTest::DATE_MS1, + AllOperatorsTest::DATE_MS1, NO_MATCH}, + ClauseTest{Clause::Op::kAfter, Value::Null(), + AllOperatorsTest::DATE_STR1, NO_MATCH}, + ClauseTest{Clause::Op::kAfter, AllOperatorsTest::DATE_STR1, + AllOperatorsTest::INVALID_DATE, NO_MATCH}, + ClauseTest{Clause::Op::kBefore, AllOperatorsTest::DATE_MS_NEGATIVE, + AllOperatorsTest::DATE_MS1, NO_MATCH}, + ClauseTest{Clause::Op::kAfter, AllOperatorsTest::DATE_MS1, + AllOperatorsTest::DATE_MS_NEGATIVE, NO_MATCH}, + + })); + +INSTANTIATE_TEST_SUITE_P( + SemVerTests, + AllOperatorsTest, + ::testing::ValuesIn( + {ClauseTest{Clause::Op::kSemVerEqual, "2.0.0", "2.0.0", MATCH}, + ClauseTest{Clause::Op::kSemVerEqual, "2.0", "2.0.0", MATCH}, + ClauseTest{Clause::Op::kSemVerEqual, "2-rc1", "2.0.0-rc1", MATCH}, + ClauseTest{Clause::Op::kSemVerEqual, "2+build2", "2.0.0+build2", + MATCH}, + ClauseTest{Clause::Op::kSemVerEqual, "2.0.0", "2.0.1", NO_MATCH}, + ClauseTest{Clause::Op::kSemVerLessThan, "2.0.0", "2.0.1", MATCH}, + ClauseTest{Clause::Op::kSemVerLessThan, "2.0", "2.0.1", MATCH}, + ClauseTest{Clause::Op::kSemVerLessThan, "2.0.1", "2.0.0", NO_MATCH}, + ClauseTest{Clause::Op::kSemVerLessThan, "2.0.1", "2.0", NO_MATCH}, + ClauseTest{Clause::Op::kSemVerLessThan, "2.0.1", "xbad%ver", NO_MATCH}, + ClauseTest{Clause::Op::kSemVerLessThan, "2.0.0-rc", "2.0.0-rc.beta", + MATCH}, + ClauseTest{Clause::Op::kSemVerGreaterThan, "2.0.1", "2.0", MATCH}, + ClauseTest{Clause::Op::kSemVerGreaterThan, "10.0.1", "2.0", MATCH}, + ClauseTest{Clause::Op::kSemVerGreaterThan, "2.0.0", "2.0.1", NO_MATCH}, + ClauseTest{Clause::Op::kSemVerGreaterThan, "2.0", "2.0.1", NO_MATCH}, + ClauseTest{Clause::Op::kSemVerGreaterThan, "2.0.1", "xbad%ver", + NO_MATCH}, + ClauseTest{Clause::Op::kSemVerGreaterThan, "2.0.0-rc.1", "2.0.0-rc.0", + MATCH}})); + +#undef MATCH +#undef NO_MATCH diff --git a/libs/server-sdk/tests/semver_tests.cpp b/libs/server-sdk/tests/semver_tests.cpp new file mode 100644 index 000000000..61cfe6121 --- /dev/null +++ b/libs/server-sdk/tests/semver_tests.cpp @@ -0,0 +1,66 @@ +#include +#include "evaluation/detail/semver_operations.hpp" + +using namespace launchdarkly::server_side::evaluation::detail; + +TEST(SemVer, DefaultConstruction) { + SemVer version; + EXPECT_EQ(version.Major(), 0); + EXPECT_EQ(version.Minor(), 0); + EXPECT_EQ(version.Patch(), 0); + EXPECT_FALSE(version.Prerelease()); +} + +TEST(SemVer, MinimalVersion) { + SemVer version{1, 2, 3}; + EXPECT_EQ(version.Major(), 1); + EXPECT_EQ(version.Minor(), 2); + EXPECT_EQ(version.Patch(), 3); + EXPECT_FALSE(version.Prerelease()); +} + +TEST(SemVer, ParseMinimalVersion) { + auto version = SemVer::Parse("1.2.3"); + ASSERT_TRUE(version); + EXPECT_EQ(version->Major(), 1); + EXPECT_EQ(version->Minor(), 2); + EXPECT_EQ(version->Patch(), 3); + EXPECT_FALSE(version->Prerelease()); +} + +TEST(SemVer, ParsePrereleaseVersion) { + auto version = SemVer::Parse("1.2.3-alpha.123.foo"); + ASSERT_TRUE(version); + ASSERT_TRUE(version->Prerelease()); + + auto const& pre = *version->Prerelease(); + ASSERT_EQ(pre.size(), 3); + EXPECT_EQ(pre[0], SemVer::Token("alpha")); + EXPECT_EQ(pre[1], SemVer::Token(123ull)); + EXPECT_EQ(pre[2], SemVer::Token("foo")); +} + +TEST(SemVer, ParseInvalid) { + ASSERT_FALSE(SemVer::Parse("")); + ASSERT_FALSE(SemVer::Parse("v1.2.3")); + ASSERT_FALSE(SemVer::Parse("foo")); + ASSERT_FALSE(SemVer::Parse("1.2.3 ")); + ASSERT_FALSE(SemVer::Parse("1.2.3.alpha.1")); + ASSERT_FALSE(SemVer::Parse("1.2.3.4")); + ASSERT_FALSE(SemVer::Parse("1.2.3-_")); +} + +TEST(SemVer, BasicComparison) { + EXPECT_LT(SemVer::Parse("1.0.0"), SemVer::Parse("2.0.0")); + ASSERT_LT(SemVer::Parse("1.0.0-alpha.1"), SemVer::Parse("1.0.0")); + + ASSERT_GT(SemVer::Parse("2.0.0"), SemVer::Parse("1.0.0")); + ASSERT_GT(SemVer::Parse("1.0.0"), SemVer::Parse("1.0.0-alpha.1")); + + ASSERT_EQ(SemVer::Parse("1.0.0"), SemVer::Parse("1.0.0")); + + // Build is irrelevant for comparisons. + ASSERT_EQ(SemVer::Parse("1.2.3+build12345"), SemVer::Parse("1.2.3")); + ASSERT_EQ(SemVer::Parse("1.2.3-alpha.1+1234"), + SemVer::Parse("1.2.3-alpha.1+4567")); +} diff --git a/libs/server-sdk/tests/spy_event_processor.hpp b/libs/server-sdk/tests/spy_event_processor.hpp new file mode 100644 index 000000000..2981f39d0 --- /dev/null +++ b/libs/server-sdk/tests/spy_event_processor.hpp @@ -0,0 +1,65 @@ +#pragma once + +#include + +#include + +namespace launchdarkly { +class SpyEventProcessor : public events::IEventProcessor { + public: + struct Flush {}; + struct Shutdown {}; + + using Record = events::InputEvent; + + SpyEventProcessor() : events_() {} + + void SendAsync(events::InputEvent event) override { + events_.push_back(std::move(event)); + } + + void FlushAsync() override {} + + void ShutdownAsync() override {} + + /** + * Asserts that 'count' events were recorded. + * @param count Number of expected events. + */ + [[nodiscard]] testing::AssertionResult Count(std::size_t count) const { + if (events_.size() == count) { + return testing::AssertionSuccess(); + } + return testing::AssertionFailure() + << "Expected " << count << " events, got " << events_.size(); + } + + template + [[nodiscard]] testing::AssertionResult Kind(std::size_t index) const { + return GetIndex(index, [&](auto const& actual) { + if (std::holds_alternative(actual)) { + return testing::AssertionSuccess(); + } else { + return testing::AssertionFailure() + << "Expected message " << index << " to be of kind " + << typeid(T).name() << ", got variant index " + << actual.index(); + } + }); + } + + private: + [[nodiscard]] testing::AssertionResult GetIndex( + std::size_t index, + std::function const& f) const { + if (index >= events_.size()) { + return testing::AssertionFailure() + << "Event index " << index << " out of range"; + } + auto const& record = events_[index]; + return f(record); + } + using Records = std::vector; + Records events_; +}; +} // namespace launchdarkly diff --git a/libs/server-sdk/tests/spy_logger.hpp b/libs/server-sdk/tests/spy_logger.hpp new file mode 100644 index 000000000..a04edb197 --- /dev/null +++ b/libs/server-sdk/tests/spy_logger.hpp @@ -0,0 +1,94 @@ +#pragma once + +#include +#include + +#include + +#include + +namespace launchdarkly::logging { + +class SpyLoggerBackend : public launchdarkly::ILogBackend { + public: + using Record = std::pair; + + SpyLoggerBackend() : messages_() {} + + /** + * Always returns true. + */ + [[nodiscard]] bool Enabled(LogLevel level) noexcept override { + return true; + } + + /** + * Records the message internally. + */ + void Write(LogLevel level, std::string message) noexcept override { + messages_.push_back({level, std::move(message)}); + } + + /** + * Asserts that 'count' messages were recorded. + * @param count Number of expected messages. + */ + [[nodiscard]] testing::AssertionResult Count(std::size_t count) const { + if (messages_.size() == count) { + return testing::AssertionSuccess(); + } + return testing::AssertionFailure() + << "Expected " << count << " messages, got " << messages_.size(); + } + + [[nodiscard]] testing::AssertionResult Equals( + std::size_t index, + LogLevel level, + std::string const& expected) const { + return GetIndex(index, level, [&](auto const& actual) { + if (actual.second != expected) { + return testing::AssertionFailure() + << "Expected message " << index << " to be " << expected + << ", got " << actual.second; + } + return testing::AssertionSuccess(); + }); + } + + [[nodiscard]] testing::AssertionResult Contains( + std::size_t index, + LogLevel level, + std::string const& expected) const { + return GetIndex( + index, level, [&](auto const& actual) -> testing::AssertionResult { + if (actual.second.find(expected) != std::string::npos) { + return testing::AssertionSuccess(); + } + return testing::AssertionFailure() + << "Expected message " << index << " to contain " + << expected << ", got " << actual.second; + }); + } + + private: + [[nodiscard]] testing::AssertionResult GetIndex( + std::size_t index, + LogLevel level, + std::function const& f) const { + if (index >= messages_.size()) { + return testing::AssertionFailure() + << "Message index " << index << " out of range"; + } + auto const& record = messages_[index]; + if (level != record.first) { + return testing::AssertionFailure() + << "Expected message " << index << " to be " << level + << ", got " << record.first; + } + return f(record); + } + using Records = std::vector; + Records messages_; +}; + +} // namespace launchdarkly::logging diff --git a/libs/server-sdk/tests/test_store.cpp b/libs/server-sdk/tests/test_store.cpp new file mode 100644 index 000000000..128eb2b8d --- /dev/null +++ b/libs/server-sdk/tests/test_store.cpp @@ -0,0 +1,307 @@ +#include "test_store.hpp" + +#include "data_store/memory_store.hpp" + +#include +#include + +namespace launchdarkly::server_side::test_store { + +std::unique_ptr Empty() { + auto store = std::make_unique(); + store->Init({}); + return store; +} + +data_store::FlagDescriptor Flag(char const* json) { + auto val = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(json)); + assert(val.has_value()); + assert(val.value().has_value()); + return data_store::FlagDescriptor{val.value().value()}; +} + +data_store::SegmentDescriptor Segment(char const* json) { + auto val = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(json)); + assert(val.has_value()); + assert(val.value().has_value()); + return data_store::SegmentDescriptor{val.value().value()}; +} + +std::unique_ptr TestData() { + auto store = std::make_unique(); + store->Init({}); + + store->Upsert("segmentWithNoRules", Segment(R"({ + "key": "segmentWithNoRules", + "included": ["alice"], + "excluded": [], + "rules": [], + "salt": "salty", + "version": 1 + })")); + store->Upsert("segmentWithRuleMatchesUserAlice", Segment(R"({ + "key": "segmentWithRuleMatchesUserAlice", + "included": [], + "excluded": [], + "rules": [{ + "id": "rule-1", + "clauses": [{ + "attribute": "key", + "negate": false, + "op": "in", + "values": ["alice"], + "contextKind": "user" + }] + }], + "salt": "salty", + "version": 1 + })")); + + store->Upsert("flagWithTarget", Flag(R"( + { + "key": "flagWithTarget", + "version": 42, + "on": false, + "targets": [{ + "values": ["bob"], + "variation": 0 + }], + "rules": [], + "prerequisites": [], + "fallthrough": {"variation": 1}, + "offVariation": 0, + "variations": [false, true], + "clientSide": true, + "clientSideAvailability": { + "usingEnvironmentId": true, + "usingMobileKey": true + }, + "salt": "salty" + })")); + + store->Upsert("flagWithMatchesOpOnGroups", Flag(R"({ + "key": "flagWithMatchesOpOnGroups", + "version": 42, + "on": true, + "targets": [], + "rules": [ + { + "variation": 0, + "id": "6a7755ac-e47a-40ea-9579-a09dd5f061bd", + "clauses": [ + { + "attribute": "groups", + "op": "matches", + "values": [ + "^\\w+" + ], + "negate": false + } + ], + "trackEvents": true + } + ], + "prerequisites": [], + "fallthrough": {"variation": 1}, + "offVariation": 0, + "variations": [false, true], + "clientSide": true, + "clientSideAvailability": { + "usingEnvironmentId": true, + "usingMobileKey": true + }, + "salt": "salty", + "trackEvents": false, + "trackEventsFallthrough": true, + "debugEventsUntilDate": 1500000000 + })")); + + store->Upsert("flagWithMatchesOpOnKinds", Flag(R"({ + "key": "flagWithMatchesOpOnKinds", + "version": 42, + "on": true, + "targets": [], + "rules": [ + { + "variation": 0, + "id": "6a7755ac-e47a-40ea-9579-a09dd5f061bd", + "clauses": [ + { + "attribute": "kind", + "op": "matches", + "values": [ + "^[ou]" + ], + "negate": false + } + ], + "trackEvents": true + } + ], + "prerequisites": [], + "fallthrough": {"variation": 1}, + "offVariation": 0, + "variations": [false, true], + "clientSide": true, + "clientSideAvailability": { + "usingEnvironmentId": true, + "usingMobileKey": true + }, + "salt": "salty", + "trackEvents": false, + "trackEventsFallthrough": true, + "debugEventsUntilDate": 1500000000 + })")); + + store->Upsert("cycleFlagA", Flag(R"({ + "key": "cycleFlagA", + "targets": [], + "rules": [], + "salt": "salty", + "prerequisites": [{ + "key": "cycleFlagB", + "variation": 0 + }], + "on": true, + "fallthrough": {"variation": 0}, + "offVariation": 1, + "variations": [true, false] + })")); + store->Upsert("cycleFlagB", Flag(R"({ + "key": "cycleFlagB", + "targets": [], + "rules": [], + "salt": "salty", + "prerequisites": [{ + "key": "cycleFlagA", + "variation": 0 + }], + "on": true, + "fallthrough": {"variation": 0}, + "offVariation": 1, + "variations": [true, false] + })")); + + store->Upsert("flagWithExperiment", Flag(R"({ + "key": "flagWithExperiment", + "version": 42, + "on": true, + "targets": [], + "rules": [], + "prerequisites": [], + "fallthrough": { + "rollout": { + "kind": "experiment", + "seed": 61, + "variations": [ + {"variation": 0, "weight": 10000, "untracked": false}, + {"variation": 1, "weight": 20000, "untracked": false}, + {"variation": 0, "weight": 70000, "untracked": true} + ] + } + }, + "offVariation": 0, + "variations": [false, true], + "clientSide": true, + "clientSideAvailability": { + "usingEnvironmentId": true, + "usingMobileKey": true + }, + "salt": "salty", + "trackEvents": false, + "trackEventsFallthrough": false, + "debugEventsUntilDate": 1500000000 + })")); + store->Upsert("flagWithExperimentTargetingContext", Flag(R"({ + "key": "flagWithExperimentTargetingContext", + "version": 42, + "on": true, + "targets": [], + "rules": [], + "prerequisites": [], + "fallthrough": { + "rollout": { + "kind": "experiment", + "contextKind": "org", + "seed": 61, + "variations": [ + {"variation": 0, "weight": 10000, "untracked": false}, + {"variation": 1, "weight": 20000, "untracked": false}, + {"variation": 0, "weight": 70000, "untracked": true} + ] + } + }, + "offVariation": 0, + "variations": [false, true], + "clientSide": true, + "clientSideAvailability": { + "usingEnvironmentId": true, + "usingMobileKey": true + }, + "salt": "salty", + "trackEvents": false, + "trackEventsFallthrough": false, + "debugEventsUntilDate": 1500000000 + })")); + store->Upsert("flagWithSegmentMatchRule", Flag(R"({ + "key": "flagWithSegmentMatchRule", + "version": 42, + "on": true, + "targets": [], + "rules": [{ + "id": "match-rule", + "clauses": [{ + "contextKind": "user", + "attribute": "key", + "negate": false, + "op": "segmentMatch", + "values": ["segmentWithNoRules"] + }], + "variation": 0, + "trackEvents": false + }], + "prerequisites": [], + "fallthrough": {"variation": 1}, + "offVariation": 0, + "variations": [false, true], + "clientSide": true, + "clientSideAvailability": { + "usingEnvironmentId": true, + "usingMobileKey": true + }, + "salt": "salty" + })")); + + store->Upsert("flagWithSegmentMatchesUserAlice", Flag(R"({ + "key": "flagWithSegmentMatchesUserAlice", + "version": 42, + "on": true, + "targets": [], + "rules": [{ + "id": "match-rule", + "clauses": [{ + "op": "segmentMatch", + "values": ["segmentWithRuleMatchesUserAlice"] + }], + "variation": 0, + "trackEvents": false + }], + "prerequisites": [], + "fallthrough": {"variation": 1}, + "offVariation": 0, + "variations": [false, true], + "clientSide": true, + "clientSideAvailability": { + "usingEnvironmentId": true, + "usingMobileKey": true + }, + "salt": "salty" + })")); + return store; +} + +} // namespace launchdarkly::server_side::test_store diff --git a/libs/server-sdk/tests/test_store.hpp b/libs/server-sdk/tests/test_store.hpp new file mode 100644 index 000000000..bfccc008e --- /dev/null +++ b/libs/server-sdk/tests/test_store.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include "data_store/data_store.hpp" + +#include + +namespace launchdarkly::server_side::test_store { + +/** + * @return A data store preloaded with flags/segments for unit tests. + */ +std::unique_ptr TestData(); + +/** + * @return An initialized, but empty, data store. + */ +std::unique_ptr Empty(); + +/** + * Returns a flag suitable for inserting into a memory store, parsed from the + * given JSON representation. + */ +data_store::FlagDescriptor Flag(char const* json); + +/** + * Returns a segment suitable for inserting into a memory store, parsed from the + * given JSON representation. + */ +data_store::SegmentDescriptor Segment(char const* json); + +} // namespace launchdarkly::server_side::test_store diff --git a/libs/server-sdk/tests/timestamp_tests.cpp b/libs/server-sdk/tests/timestamp_tests.cpp new file mode 100644 index 000000000..f448d1c94 --- /dev/null +++ b/libs/server-sdk/tests/timestamp_tests.cpp @@ -0,0 +1,88 @@ +#include "evaluation/detail/timestamp_operations.hpp" + +#include + +#include + +using namespace launchdarkly::server_side::evaluation::detail; +using namespace std::chrono_literals; + +static Timepoint BasicDate() { + return std::chrono::system_clock::from_time_t(1577836800); +} + +struct TimestampTest { + launchdarkly::Value input; + char const* explanation; + std::optional expected; +}; + +class TimestampTests : public ::testing::TestWithParam {}; +TEST_P(TimestampTests, ExpectedTimestampIsParsed) { + auto const& param = GetParam(); + + std::optional result = ToTimepoint(param.input); + + constexpr auto print_tp = + [](std::optional const& expected) -> std::string { + if (expected) { + return std::to_string(expected.value().time_since_epoch().count()); + } else { + return "(none)"; + } + }; + + ASSERT_EQ(result, param.expected) + << param.explanation << ": input was " << param.input << ", expected " + << print_tp(param.expected) << " but got " << print_tp(result); +} + +INSTANTIATE_TEST_SUITE_P( + ValidTimestamps, + TimestampTests, + ::testing::ValuesIn({ + TimestampTest{0.0, "default constructed", Timepoint{}}, + TimestampTest{1000.0, "1 second", Timepoint{1s}}, + TimestampTest{1000.0 * 60, "60 seconds", Timepoint{60s}}, + TimestampTest{1000.0 * 60 * 60, "1 hour", Timepoint{60min}}, + TimestampTest{"2020-01-01T00:00:00Z", "with Zulu offset", BasicDate()}, + TimestampTest{"2020-01-01T00:00:00+00:00", "with normal offset", + BasicDate()}, + TimestampTest{"2020-01-01T01:00:00+01:00", "with 1hr offset", + BasicDate()}, + TimestampTest{"2020-01-01T01:00:00+01:00", + "with colon-delimited offset", BasicDate()}, + + TimestampTest{"2020-01-01T00:00:00.123Z", "with milliseconds", + BasicDate() + 123ms}, + TimestampTest{"2020-01-01T00:00:00.123+00:00", + "with milliseconds and offset", BasicDate() + 123ms}, + TimestampTest{"2020-01-01T00:00:00.000123Z", "with microseconds ", + BasicDate() + 123us}, + TimestampTest{"2020-01-01T00:00:00.000123+00:00", + "with microseconds and offset", BasicDate() + 123us}, + + })); + +INSTANTIATE_TEST_SUITE_P( + InvalidTimestamps, + TimestampTests, + ::testing::ValuesIn({ + TimestampTest{0.1, "not an integer", std::nullopt}, + TimestampTest{1000.2, "not an integer", std::nullopt}, + TimestampTest{123456.789, "not an integer", std::nullopt}, + TimestampTest{-1000.5, "not an integer", std::nullopt}, + TimestampTest{-1000.0, "negative integer", std::nullopt}, + TimestampTest{"", "empty string", std::nullopt}, + TimestampTest{"2020-01-01T00:00:00/foo", "invalid offset", + std::nullopt}, + TimestampTest{"2020-01-01T00:00:00.0000000001Z", + "more than 9 digits of precision", std::nullopt}, + TimestampTest{launchdarkly::Value::Null(), "not a number or string", + std::nullopt}, + TimestampTest{launchdarkly::Value::Array(), "not a number or string", + std::nullopt}, + TimestampTest{launchdarkly::Value::Object(), "not a number or string", + std::nullopt}, + + }));