From d4251b7d6b160c06dcef38cd8bed0e7f9c08f750 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Wed, 1 Jul 2026 11:24:54 -0700 Subject: [PATCH] feat: Add C bindings for DynamoDB LazyLoad source --- .../dynamodb/dynamodb_client_options.h | 121 ++++++++++++ .../c/integrations/dynamodb/dynamodb_source.h | 134 +++++++++++++ .../src/CMakeLists.txt | 2 + .../src/bindings/dynamodb/client_options.cpp | 67 +++++++ .../src/bindings/dynamodb/dynamodb_source.cpp | 59 ++++++ .../tests/c_bindings_test.cpp | 176 ++++++++++++++++++ 6 files changed, 559 insertions(+) create mode 100644 libs/server-sdk-dynamodb-source/include/launchdarkly/server_side/bindings/c/integrations/dynamodb/dynamodb_client_options.h create mode 100644 libs/server-sdk-dynamodb-source/include/launchdarkly/server_side/bindings/c/integrations/dynamodb/dynamodb_source.h create mode 100644 libs/server-sdk-dynamodb-source/src/bindings/dynamodb/client_options.cpp create mode 100644 libs/server-sdk-dynamodb-source/src/bindings/dynamodb/dynamodb_source.cpp create mode 100644 libs/server-sdk-dynamodb-source/tests/c_bindings_test.cpp diff --git a/libs/server-sdk-dynamodb-source/include/launchdarkly/server_side/bindings/c/integrations/dynamodb/dynamodb_client_options.h b/libs/server-sdk-dynamodb-source/include/launchdarkly/server_side/bindings/c/integrations/dynamodb/dynamodb_client_options.h new file mode 100644 index 000000000..9f23f4174 --- /dev/null +++ b/libs/server-sdk-dynamodb-source/include/launchdarkly/server_side/bindings/c/integrations/dynamodb/dynamodb_client_options.h @@ -0,0 +1,121 @@ +/** @file dynamodb_client_options.h + * @brief LaunchDarkly Server-side DynamoDB Client Options C Binding. + */ +// NOLINTBEGIN modernize-use-using +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +// only need to export C interface if +// used by C++ source code +#endif + +/** + * @brief LDServerDynamoDBClientOptionsBuilder configures the AWS DynamoDB + * client that a LaunchDarkly DynamoDB integration will use. + * + * All fields are optional. When left unset, the AWS SDK's default provider + * chain (environment variables, shared config, EC2/ECS instance metadata) is + * used to resolve the corresponding field. + * + * The builder is passed by handle to a DynamoDB source or store factory, + * which takes ownership and frees it. Callers only need to call + * @ref LDServerDynamoDBClientOptionsBuilder_Free directly if the builder is + * not passed to a factory. + */ +typedef struct _LDServerDynamoDBClientOptionsBuilder* + LDServerDynamoDBClientOptionsBuilder; + +/** + * @brief Creates a new DynamoDB client options builder with all fields unset. + * + * @return A new builder handle. + */ +LD_EXPORT(LDServerDynamoDBClientOptionsBuilder) +LDServerDynamoDBClientOptionsBuilder_New(void); + +/** + * @brief Sets the AWS region for the DynamoDB client. + * + * When unset, the AWS SDK resolves the region via the standard region + * provider chain (environment variables, shared config file, instance + * metadata). + * + * @param b Builder. Must not be NULL. + * @param region Region string (e.g. "us-east-1"). Must not be NULL. + */ +LD_EXPORT(void) +LDServerDynamoDBClientOptionsBuilder_Region( + LDServerDynamoDBClientOptionsBuilder b, + char const* region); + +/** + * @brief Sets a custom endpoint for the DynamoDB client. + * + * Useful when pointing at DynamoDB Local or LocalStack for testing (e.g. + * "http://localhost:8000"). When unset, the AWS SDK uses the standard + * DynamoDB endpoint for the resolved region. + * + * @param b Builder. Must not be NULL. + * @param endpoint Endpoint URL. Must not be NULL. + */ +LD_EXPORT(void) +LDServerDynamoDBClientOptionsBuilder_Endpoint( + LDServerDynamoDBClientOptionsBuilder b, + char const* endpoint); + +/** + * @brief Sets the AWS access key ID for the DynamoDB client. + * + * When none of AccessKeyId, SecretAccessKey, or SessionToken are set, the + * AWS SDK's default credential provider chain is used (environment + * variables, shared credentials file, EC2/ECS roles). + * + * @param b Builder. Must not be NULL. + * @param access_key_id AWS access key ID. Must not be NULL. + */ +LD_EXPORT(void) +LDServerDynamoDBClientOptionsBuilder_AccessKeyId( + LDServerDynamoDBClientOptionsBuilder b, + char const* access_key_id); + +/** + * @brief Sets the AWS secret access key for the DynamoDB client. + * + * @param b Builder. Must not be NULL. + * @param secret_access_key AWS secret access key. Must not be NULL. + */ +LD_EXPORT(void) +LDServerDynamoDBClientOptionsBuilder_SecretAccessKey( + LDServerDynamoDBClientOptionsBuilder b, + char const* secret_access_key); + +/** + * @brief Sets the AWS session token for the DynamoDB client (for temporary + * credentials). + * + * @param b Builder. Must not be NULL. + * @param session_token AWS session token. Must not be NULL. + */ +LD_EXPORT(void) +LDServerDynamoDBClientOptionsBuilder_SessionToken( + LDServerDynamoDBClientOptionsBuilder b, + char const* session_token); + +/** + * @brief Frees a DynamoDB client options builder. Do not call if the builder + * was consumed by a DynamoDB source or store factory. + * + * @param b Builder to free. + */ +LD_EXPORT(void) +LDServerDynamoDBClientOptionsBuilder_Free( + LDServerDynamoDBClientOptionsBuilder b); + +#ifdef __cplusplus +} +#endif + +// NOLINTEND modernize-use-using diff --git a/libs/server-sdk-dynamodb-source/include/launchdarkly/server_side/bindings/c/integrations/dynamodb/dynamodb_source.h b/libs/server-sdk-dynamodb-source/include/launchdarkly/server_side/bindings/c/integrations/dynamodb/dynamodb_source.h new file mode 100644 index 000000000..bfc5a7557 --- /dev/null +++ b/libs/server-sdk-dynamodb-source/include/launchdarkly/server_side/bindings/c/integrations/dynamodb/dynamodb_source.h @@ -0,0 +1,134 @@ +/** @file dynamodb_source.h + * @brief LaunchDarkly Server-side DynamoDB Source C Binding. + */ +// NOLINTBEGIN modernize-use-using +#pragma once + +#include + +#include + +#ifdef __cplusplus +extern "C" { +// only need to export C interface if +// used by C++ source code +#endif + +/** + * @brief LDServerLazyLoadDynamoDBSource represents a data source for the + * Server-Side SDK backed by Amazon DynamoDB. It is meant to be used in place + * of the standard LaunchDarkly Streaming or Polling data sources. + * + * Call @ref LDServerLazyLoadDynamoDBSource_New to obtain a new instance. + * This instance can be passed into the SDK's DataSystem configuration via + * the LazyLoad builder. + * + * The DynamoDB table must already exist and follow the LaunchDarkly schema: + * a String partition key named `namespace` and a String sort key named + * `key`. The LaunchDarkly Relay Proxy populates the table with this schema; + * this source only reads from it. + * + * Example: + * @code + * // Optional: configure the AWS DynamoDB client. Pass NULL for defaults. + * LDServerDynamoDBClientOptionsBuilder options = + * LDServerDynamoDBClientOptionsBuilder_New(); + * LDServerDynamoDBClientOptionsBuilder_Region(options, "us-east-1"); + * + * // Stack allocate the result struct, which will hold the result pointer or + * // an error message. + * struct LDServerLazyLoadDynamoDBResult result; + * + * if (!LDServerLazyLoadDynamoDBSource_New("my-table", "testprefix", options, + * &result)) { + * // On failure, you may print the error message (result.error_message), + * // then exit or return. + * } + * + * LDServerLazyLoadBuilder lazy_builder = LDServerLazyLoadBuilder_New(); + * LDServerLazyLoadBuilder_SourcePtr( + * lazy_builder, (LDServerLazyLoadSourcePtr)result.source); + * + * LDServerConfigBuilder cfg_builder = LDServerConfigBuilder_New("sdk-123"); + * LDServerConfigBuilder_DataSystem_LazyLoad(cfg_builder, lazy_builder); + * @endcode + * + * This implementation is backed by the AWS SDK for C++. + */ +typedef struct _LDServerLazyLoadDynamoDBSource* LDServerLazyLoadDynamoDBSource; + +/* Defines the size of the error message buffer in + * LDServerLazyLoadDynamoDBResult. + */ +#ifndef LDSERVER_LAZYLOAD_DYNAMODBSOURCE_ERROR_MESSAGE_SIZE +#define LDSERVER_LAZYLOAD_DYNAMODBSOURCE_ERROR_MESSAGE_SIZE 256 +#endif + +/** + * @brief Stores the result of calling @ref LDServerLazyLoadDynamoDBSource_New. + * + * On successful creation, source will contain a pointer which may be passed + * into the LaunchDarkly SDK's configuration. + * + * On failure, error_message contains a NULL-terminated string describing the + * error. + * + * The message may be truncated if it was originally longer than + * error_message's buffer size. + */ +struct LDServerLazyLoadDynamoDBResult { + LDServerLazyLoadDynamoDBSource source; + char error_message[LDSERVER_LAZYLOAD_DYNAMODBSOURCE_ERROR_MESSAGE_SIZE]; +}; + +/** + * @brief Creates a new DynamoDB data source suitable for usage in the SDK's + * Lazy Load data system. + * + * In this system, the SDK will query DynamoDB for flag/segment data as + * required, with an in-memory cache to reduce the number of queries. + * + * Data is never written back to DynamoDB by the SDK; the LaunchDarkly Relay + * Proxy populates the table. + * + * @param table_name Name of the DynamoDB table to read from. The table must + * already exist; this function does not create it. Must not be NULL. + * + * @param prefix Prefix to use when reading SDK data from DynamoDB. This + * allows multiple SDK environments to coexist in the same table. Must not + * be NULL. + * + * @param options Optional AWS DynamoDB client configuration. When non-NULL, + * the builder is consumed and freed by this function; do not call + * @ref LDServerDynamoDBClientOptionsBuilder_Free on it afterward. When NULL, + * the AWS SDK's default provider chain is used for region, endpoint, and + * credentials. + * + * @param out_result Pointer to struct where the source pointer or an error + * message should be stored. Must not be NULL. + * + * @return True if the source was created successfully; out_result->source + * will contain the pointer. The caller must either free the pointer with + * @ref LDServerLazyLoadDynamoDBSource_Free, OR pass it into the SDK's + * configuration methods which will take ownership (in which case do not + * call @ref LDServerLazyLoadDynamoDBSource_Free.) + */ +LD_EXPORT(bool) +LDServerLazyLoadDynamoDBSource_New( + char const* table_name, + char const* prefix, + LDServerDynamoDBClientOptionsBuilder options, + struct LDServerLazyLoadDynamoDBResult* out_result); + +/** + * @brief Frees a DynamoDB data source pointer. Only necessary to call if not + * passing ownership to SDK configuration. + */ +LD_EXPORT(void) +LDServerLazyLoadDynamoDBSource_Free(LDServerLazyLoadDynamoDBSource source); + +#ifdef __cplusplus +} +#endif + +// NOLINTEND modernize-use-using diff --git a/libs/server-sdk-dynamodb-source/src/CMakeLists.txt b/libs/server-sdk-dynamodb-source/src/CMakeLists.txt index 02f954b07..983bf2ea6 100644 --- a/libs/server-sdk-dynamodb-source/src/CMakeLists.txt +++ b/libs/server-sdk-dynamodb-source/src/CMakeLists.txt @@ -17,6 +17,8 @@ target_sources(${LIBNAME} dynamodb_big_segment_store.cpp aws_sdk_guard.cpp client_factory.cpp + bindings/dynamodb/client_options.cpp + bindings/dynamodb/dynamodb_source.cpp ) diff --git a/libs/server-sdk-dynamodb-source/src/bindings/dynamodb/client_options.cpp b/libs/server-sdk-dynamodb-source/src/bindings/dynamodb/client_options.cpp new file mode 100644 index 000000000..a8f3cf9d1 --- /dev/null +++ b/libs/server-sdk-dynamodb-source/src/bindings/dynamodb/client_options.cpp @@ -0,0 +1,67 @@ +#include + +#include + +#include + +using namespace launchdarkly::server_side::integrations; + +#define TO_OPTIONS(ptr) (reinterpret_cast(ptr)) +#define FROM_OPTIONS(ptr) \ + (reinterpret_cast(ptr)) + +LD_EXPORT(LDServerDynamoDBClientOptionsBuilder) +LDServerDynamoDBClientOptionsBuilder_New(void) { + return FROM_OPTIONS(new DynamoDBClientOptions{}); +} + +LD_EXPORT(void) +LDServerDynamoDBClientOptionsBuilder_Region( + LDServerDynamoDBClientOptionsBuilder b, + char const* region) { + LD_ASSERT_NOT_NULL(b); + LD_ASSERT_NOT_NULL(region); + TO_OPTIONS(b)->region = region; +} + +LD_EXPORT(void) +LDServerDynamoDBClientOptionsBuilder_Endpoint( + LDServerDynamoDBClientOptionsBuilder b, + char const* endpoint) { + LD_ASSERT_NOT_NULL(b); + LD_ASSERT_NOT_NULL(endpoint); + TO_OPTIONS(b)->endpoint = endpoint; +} + +LD_EXPORT(void) +LDServerDynamoDBClientOptionsBuilder_AccessKeyId( + LDServerDynamoDBClientOptionsBuilder b, + char const* access_key_id) { + LD_ASSERT_NOT_NULL(b); + LD_ASSERT_NOT_NULL(access_key_id); + TO_OPTIONS(b)->aws_access_key_id = access_key_id; +} + +LD_EXPORT(void) +LDServerDynamoDBClientOptionsBuilder_SecretAccessKey( + LDServerDynamoDBClientOptionsBuilder b, + char const* secret_access_key) { + LD_ASSERT_NOT_NULL(b); + LD_ASSERT_NOT_NULL(secret_access_key); + TO_OPTIONS(b)->aws_secret_access_key = secret_access_key; +} + +LD_EXPORT(void) +LDServerDynamoDBClientOptionsBuilder_SessionToken( + LDServerDynamoDBClientOptionsBuilder b, + char const* session_token) { + LD_ASSERT_NOT_NULL(b); + LD_ASSERT_NOT_NULL(session_token); + TO_OPTIONS(b)->aws_session_token = session_token; +} + +LD_EXPORT(void) +LDServerDynamoDBClientOptionsBuilder_Free( + LDServerDynamoDBClientOptionsBuilder b) { + delete TO_OPTIONS(b); +} diff --git a/libs/server-sdk-dynamodb-source/src/bindings/dynamodb/dynamodb_source.cpp b/libs/server-sdk-dynamodb-source/src/bindings/dynamodb/dynamodb_source.cpp new file mode 100644 index 000000000..d0e752e0f --- /dev/null +++ b/libs/server-sdk-dynamodb-source/src/bindings/dynamodb/dynamodb_source.cpp @@ -0,0 +1,59 @@ +#include + +#include +#include + +#include + +#include +#include + +using namespace launchdarkly::server_side::integrations; + +LD_EXPORT(bool) +LDServerLazyLoadDynamoDBSource_New(char const* table_name, + char const* prefix, + LDServerDynamoDBClientOptionsBuilder options, + LDServerLazyLoadDynamoDBResult* out_result) { + LD_ASSERT_NOT_NULL(table_name); + LD_ASSERT_NOT_NULL(prefix); + LD_ASSERT_NOT_NULL(out_result); + + // Explicitely zero out the error_message buffer in case the error is + // shorter than the buffer. + memset(out_result->error_message, 0, + sizeof(LDServerLazyLoadDynamoDBResult::error_message)); + + // Ensure the source pointer isn't garbage. + out_result->source = nullptr; + + DynamoDBClientOptions opts{}; + if (options != nullptr) { + auto* opts_ptr = reinterpret_cast(options); + opts = *opts_ptr; + LDServerDynamoDBClientOptionsBuilder_Free(options); + } + + auto maybe_source = + DynamoDBDataSource::Create(table_name, prefix, std::move(opts)); + if (!maybe_source) { + // Avoid heap allocating another string to pass back to the caller; + // instead, we copy into the buffer and ensure a terminator is present. + // This does mean the message may be truncated. + std::size_t const len = maybe_source.error().copy( + out_result->error_message, sizeof(out_result->error_message) - 1); + out_result->error_message[len] = '\0'; + return false; + } + + // The pointer is no longer managed and must either be freed by the caller, + // or passed into the SDK which will take ownership. + out_result->source = reinterpret_cast( + maybe_source->release()); + return true; +} + +LD_EXPORT(void) +LDServerLazyLoadDynamoDBSource_Free(LDServerLazyLoadDynamoDBSource source) { + delete reinterpret_cast(source); +} diff --git a/libs/server-sdk-dynamodb-source/tests/c_bindings_test.cpp b/libs/server-sdk-dynamodb-source/tests/c_bindings_test.cpp new file mode 100644 index 000000000..315b1d166 --- /dev/null +++ b/libs/server-sdk-dynamodb-source/tests/c_bindings_test.cpp @@ -0,0 +1,176 @@ +#include + +#include +#include + +#include +#include +#include + +#include + +#include "aws_sdk_guard.hpp" +#include "prefixed_dynamodb_client.hpp" + +#include +#include +#include +#include + +#include +#include +#include + +using namespace launchdarkly::data_model; +using namespace launchdarkly::server_side::integrations; + +namespace { + +std::string EnvOr(char const* name, std::string const& fallback) { + char const* value = std::getenv(name); + if (value && *value) { + return value; + } + return fallback; +} + +std::string LocalEndpoint() { + return EnvOr("LD_DYNAMODB_TEST_ENDPOINT", "http://localhost:8000"); +} + +std::string LocalRegion() { + return EnvOr("LD_DYNAMODB_TEST_REGION", "us-east-1"); +} + +std::unique_ptr MakeRawClient() { + detail::AwsSdkGuard::Ensure(); + Aws::Client::ClientConfiguration config; + config.region = LocalRegion(); + config.endpointOverride = LocalEndpoint(); + if (config.endpointOverride.rfind("http://", 0) == 0) { + config.scheme = Aws::Http::Scheme::HTTP; + config.verifySSL = false; + } + Aws::Auth::AWSCredentials creds("dummy", "dummy"); + return std::make_unique(creds, config); +} + +LDServerDynamoDBClientOptionsBuilder LocalOptionsBuilder() { + LDServerDynamoDBClientOptionsBuilder opts = + LDServerDynamoDBClientOptionsBuilder_New(); + LDServerDynamoDBClientOptionsBuilder_Region(opts, LocalRegion().c_str()); + LDServerDynamoDBClientOptionsBuilder_Endpoint(opts, + LocalEndpoint().c_str()); + LDServerDynamoDBClientOptionsBuilder_AccessKeyId(opts, "dummy"); + LDServerDynamoDBClientOptionsBuilder_SecretAccessKey(opts, "dummy"); + return opts; +} + +} // namespace + +TEST(DynamoDBBindings, OptionsBuilderCanBeCreatedAndFreed) { + LDServerDynamoDBClientOptionsBuilder opts = + LDServerDynamoDBClientOptionsBuilder_New(); + ASSERT_NE(opts, nullptr); + LDServerDynamoDBClientOptionsBuilder_Region(opts, "us-east-1"); + LDServerDynamoDBClientOptionsBuilder_Endpoint(opts, + "http://localhost:8000"); + LDServerDynamoDBClientOptionsBuilder_AccessKeyId(opts, "id"); + LDServerDynamoDBClientOptionsBuilder_SecretAccessKey(opts, "secret"); + LDServerDynamoDBClientOptionsBuilder_SessionToken(opts, "token"); + LDServerDynamoDBClientOptionsBuilder_Free(opts); +} + +TEST(DynamoDBBindings, LazyLoadSourcePointerIsStoredOnSuccessfulCreation) { + LDServerLazyLoadDynamoDBResult result; + ASSERT_TRUE(LDServerLazyLoadDynamoDBSource_New( + "any-table", "foo", LocalOptionsBuilder(), &result)); + ASSERT_NE(result.source, nullptr); + LDServerLazyLoadDynamoDBSource_Free(result.source); +} + +TEST(DynamoDBBindings, LazyLoadSourceAcceptsNullOptions) { + LDServerLazyLoadDynamoDBResult result; + ASSERT_TRUE(LDServerLazyLoadDynamoDBSource_New("any-table", "foo", nullptr, + &result)); + ASSERT_NE(result.source, nullptr); + LDServerLazyLoadDynamoDBSource_Free(result.source); +} + +// End-to-end test that uses an actual DynamoDB (Local) instance with +// provisioned flag data. The source is passed into the SDK's LazyLoad data +// system, and AllFlags is used to verify that the data is read back from +// DynamoDB correctly through the C binding. +TEST(DynamoDBBindings, CanUseInSDKLazyLoadDataSource) { + std::string const table_name = "ld-dynamodb-c-bindings-test"; + std::string const prefix = "testprefix"; + + auto raw_client = MakeRawClient(); + PrefixedDynamoDBClient::DeleteTable(*raw_client, table_name); + PrefixedDynamoDBClient::CreateTable(*raw_client, table_name); + + PrefixedDynamoDBClient client(*raw_client, prefix, table_name); + Flag flag_a{"foo", 1, false, std::nullopt, {true, false}}; + flag_a.offVariation = 0; + Flag flag_b{"bar", 1, false, std::nullopt, {true, false}}; + flag_b.offVariation = 1; + + client.PutFlag(flag_a); + client.PutFlag(flag_b); + client.Init(); + + LDServerLazyLoadDynamoDBResult result; + ASSERT_TRUE(LDServerLazyLoadDynamoDBSource_New( + table_name.c_str(), prefix.c_str(), LocalOptionsBuilder(), &result)); + + LDServerConfigBuilder cfg_builder = LDServerConfigBuilder_New("sdk-123"); + + LDServerLazyLoadBuilder lazy_builder = LDServerLazyLoadBuilder_New(); + LDServerLazyLoadBuilder_SourcePtr( + lazy_builder, + reinterpret_cast(result.source)); + LDServerConfigBuilder_DataSystem_LazyLoad(cfg_builder, lazy_builder); + LDServerConfigBuilder_Events_Enabled(cfg_builder, false); + + LDServerConfig config; + LDStatus status = LDServerConfigBuilder_Build(cfg_builder, &config); + ASSERT_TRUE(LDStatus_Ok(status)); + + LDServerSDK sdk = LDServerSDK_New(config); + LDServerSDK_Start(sdk, LD_NONBLOCKING, nullptr); + + LDContextBuilder ctx_builder = LDContextBuilder_New(); + LDContextBuilder_AddKind(ctx_builder, "cat", "shadow"); + LDContext context = LDContextBuilder_Build(ctx_builder); + + LDAllFlagsState state = + LDServerSDK_AllFlagsState(sdk, context, LD_ALLFLAGSSTATE_DEFAULT); + + ASSERT_TRUE(LDAllFlagsState_Valid(state)); + LDValue all = LDAllFlagsState_Map(state); + ASSERT_EQ(LDValue_Type(all), LDValueType_Object); + + std::unordered_map values; + LDValue_ObjectIter iter; + for (iter = LDValue_ObjectIter_New(all); !LDValue_ObjectIter_End(iter); + LDValue_ObjectIter_Next(iter)) { + char const* key = LDValue_ObjectIter_Key(iter); + auto value_ref = reinterpret_cast( + LDValue_ObjectIter_Value(iter)); + values.emplace(key, *value_ref); + } + + LDValue_ObjectIter_Free(iter); + + std::unordered_map expected = { + {"foo", true}, {"bar", false}}; + ASSERT_EQ(values, expected); + + LDValue_Free(all); + LDAllFlagsState_Free(state); + + LDContext_Free(context); + LDServerSDK_Free(sdk); + + PrefixedDynamoDBClient::DeleteTable(*raw_client, table_name); +}