diff --git a/CHANGELOG.md b/CHANGELOG.md index ea7cdd8..5da9a80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ConfigurationStore(Configuration config)` - constructor accepting Configuration by value - `setConfiguration(Configuration config)` - setter accepting Configuration by value - Move constructor and move assignment operator for `Configuration` class +- `parseConfiguration()` convenience function for parsing both flag configuration and bandit models in a single call + - Takes flag config JSON, bandit models JSON, and error reference parameter + - Returns a fully constructed `Configuration` object with both parsed configurations + - Simplifies setup for applications using contextual bandits ### Changed diff --git a/Makefile b/Makefile index 7e7c621..76867ec 100644 --- a/Makefile +++ b/Makefile @@ -91,17 +91,17 @@ examples: .PHONY: run-bandits run-bandits: examples @echo "Running bandits example..." - @$(BUILD_DIR)/bandits + @cd examples && ../$(BUILD_DIR)/bandits .PHONY: run-flags run-flags: examples @echo "Running flag_assignments example..." - @$(BUILD_DIR)/flag_assignments + @cd examples && ../$(BUILD_DIR)/flag_assignments .PHONY: run-assignment-details run-assignment-details: examples @echo "Running assignment_details example..." - @$(BUILD_DIR)/assignment_details + @cd examples && ../$(BUILD_DIR)/assignment_details # Clean build artifacts .PHONY: clean diff --git a/README.md b/README.md index 8ab5c95..5d9fa3f 100644 --- a/README.md +++ b/README.md @@ -124,15 +124,8 @@ Other dependencies (nlohmann/json, semver, etc.) are vendored and require no ins The Eppo SDK requires configuration data containing your feature flags. This SDK is designed for offline use, so you'll load configuration directly rather than using SDK keys or polling. ```cpp -#include #include "client.hpp" -// Parse configuration from a JSON string -eppoclient::ConfigResponse parseConfiguration(const std::string& configJson) { - nlohmann::json j = nlohmann::json::parse(configJson); - return j; -} - // Your configuration as a JSON string std::string configJson = R"({ "flags": { @@ -143,10 +136,17 @@ std::string configJson = R"({ } })"; +// Parse configuration from JSON string +std::string error; +eppoclient::Configuration config = eppoclient::parseConfiguration(configJson, error); +if (!error.empty()) { + std::cerr << "Configuration parsing error: " << error << std::endl; + return 1; +} + // Create and initialize the configuration store eppoclient::ConfigurationStore configStore; -eppoclient::ConfigResponse config = parseConfiguration(configJson); -configStore.setConfiguration(eppoclient::Configuration(config)); +configStore.setConfiguration(config); // Create the client (configStore must outlive client) eppoclient::EppoClient client(configStore); @@ -267,8 +267,13 @@ int main() { // Initialize configuration eppoclient::ConfigurationStore configStore; std::string configJson = "..."; // Your JSON config string - eppoclient::ConfigResponse config = parseConfiguration(configJson); - configStore.setConfiguration(eppoclient::Configuration(config)); + std::string error; + eppoclient::Configuration config = eppoclient::parseConfiguration(configJson, error); + if (!error.empty()) { + std::cerr << "Configuration parsing error: " << error << std::endl; + return 1; + } + configStore.setConfiguration(config); // Create loggers auto assignmentLogger = std::make_shared(); @@ -317,25 +322,25 @@ Eppo's contextual bandits allow you to dynamically select the best variant based To use bandits, you need to load both flag configuration and bandit models: ```cpp -#include #include "client.hpp" -// Parse bandit models from a JSON string -eppoclient::BanditResponse parseBanditModels(const std::string& modelsJson) { - nlohmann::json j = nlohmann::json::parse(modelsJson); - return j; -} - // Your configuration and bandit models as JSON strings std::string flagConfigJson = "..."; // Your flag config JSON std::string banditModelsJson = "..."; // Your bandit models JSON -// Initialize with both flags and bandit models -eppoclient::ConfigResponse flagConfig = parseConfiguration(flagConfigJson); -eppoclient::BanditResponse banditModels = parseBanditModels(banditModelsJson); +std::string error; +eppoclient::Configuration config = eppoclient::parseConfiguration( + flagConfigJson, + banditModelsJson, + error +); +if (!error.empty()) { + std::cerr << "Configuration parsing error: " << error << std::endl; + return 1; +} eppoclient::ConfigurationStore configStore; -configStore.setConfiguration(eppoclient::Configuration(flagConfig, banditModels)); +configStore.setConfiguration(config); // Create bandit logger to track bandit actions class MyBanditLogger : public eppoclient::BanditLogger { @@ -421,69 +426,6 @@ if (result.action.has_value()) { } ``` -### Complete Bandit Example - -Here's a complete example from `examples/bandits.cpp` showing bandit-powered car recommendations: - -```cpp -#include -#include -#include "client.hpp" - -int main() { - // Load configuration - eppoclient::ConfigurationStore configStore; - std::string flagConfigJson = "..."; // Your flag config JSON - std::string banditModelsJson = "..."; // Your bandit models JSON - eppoclient::ConfigResponse flagConfig = parseConfiguration(flagConfigJson); - eppoclient::BanditResponse banditModels = parseBanditModels(banditModelsJson); - configStore.setConfiguration(eppoclient::Configuration(flagConfig, banditModels)); - - // Create loggers - auto assignmentLogger = std::make_shared(); - auto banditLogger = std::make_shared(); - auto applicationLogger = std::make_shared(); - - // Create client - eppoclient::EppoClient client( - configStore, - assignmentLogger, - banditLogger, - applicationLogger - ); - - // Define subject attributes (user context) - eppoclient::ContextAttributes subjectAttributes; - // Add any relevant user attributes here - - // Define available car actions with their attributes - std::map actions; - - eppoclient::ContextAttributes toyota; - toyota.numericAttributes["speed"] = 120.0; - actions["toyota"] = toyota; - - eppoclient::ContextAttributes honda; - honda.numericAttributes["speed"] = 115.0; - actions["honda"] = honda; - - // Get bandit recommendation - eppoclient::BanditResult result = client.getBanditAction( - "car_bandit_flag", - "user-abc123", - subjectAttributes, - actions, - "car_bandit" - ); - - if (result.action.has_value()) { - std::cout << "Recommended car: " << result.action.value() << std::endl; - } - - return 0; -} -``` - ## Error Handling The Eppo SDK is built with **`-fno-exceptions`** and does not use exceptions internally. When errors occur during flag evaluation (such as missing flags, invalid parameters, or type mismatches), the SDK: @@ -637,8 +579,14 @@ Always ensure these preconditions are met to avoid assertion failures. int main() { // Initialize client with application logger eppoclient::ConfigurationStore configStore; - eppoclient::ConfigResponse config = parseConfiguration(configJson); - configStore.setConfiguration(eppoclient::Configuration(config)); + std::string configJson = "..."; // Your JSON config string + std::string error; + eppoclient::Configuration config = eppoclient::parseConfiguration(configJson, error); + if (!error.empty()) { + std::cerr << "Configuration parsing error: " << error << std::endl; + return 1; + } + configStore.setConfiguration(config); auto applicationLogger = std::make_shared(); eppoclient::EppoClient client( diff --git a/examples/assignment_details.cpp b/examples/assignment_details.cpp index 23d354b..3c1212e 100644 --- a/examples/assignment_details.cpp +++ b/examples/assignment_details.cpp @@ -69,31 +69,40 @@ class ConsoleApplicationLogger : public eppoclient::ApplicationLogger { }; // Helper function to load flags configuration from JSON file -bool loadFlagsConfiguration(const std::string& filepath, eppoclient::ConfigResponse& response) { +bool loadFlagsConfiguration(const std::string& filepath, eppoclient::Configuration& config) { std::ifstream file(filepath); if (!file.is_open()) { std::cerr << "Failed to open flags configuration file: " << filepath << std::endl; return false; } - nlohmann::json j; - file >> j; + // Read entire file content into a string + std::string configJson((std::istreambuf_iterator(file)), + std::istreambuf_iterator()); + + // Parse configuration using parseConfiguration + std::string error; + config = eppoclient::parseConfiguration(configJson, error); + + if (!error.empty()) { + std::cerr << "Failed to parse configuration: " << error << std::endl; + return false; + } - response = j; return true; } int main() { // Load the flags configuration std::cout << "Loading flags configuration..." << std::endl; - eppoclient::ConfigResponse ufc; - if (!loadFlagsConfiguration("config/flags-v1.json", ufc)) { + eppoclient::Configuration config; + if (!loadFlagsConfiguration("config/flags-v1.json", config)) { return 1; } // Create configuration store and set the configuration auto configStore = std::make_shared(); - configStore->setConfiguration(eppoclient::Configuration(ufc)); + configStore->setConfiguration(config); // Create assignment logger and application logger auto assignmentLogger = std::make_shared(); diff --git a/examples/bandits.cpp b/examples/bandits.cpp index 5b649f6..ebd4c2e 100644 --- a/examples/bandits.cpp +++ b/examples/bandits.cpp @@ -118,51 +118,50 @@ class ConsoleApplicationLogger : public eppoclient::ApplicationLogger { } }; -// Helper function to load flags configuration from JSON file -bool loadFlagsConfiguration(const std::string& filepath, eppoclient::ConfigResponse& response) { - std::ifstream file(filepath); - if (!file.is_open()) { - std::cerr << "Failed to open flags configuration file: " << filepath << std::endl; +// Helper function to load complete configuration (flags + bandits) from JSON files +bool loadConfiguration(const std::string& flagsFilepath, const std::string& banditsFilepath, + eppoclient::Configuration& config) { + // Read flags configuration file + std::ifstream flagsFile(flagsFilepath); + if (!flagsFile.is_open()) { + std::cerr << "Failed to open flags configuration file: " << flagsFilepath << std::endl; return false; } + std::string flagsJson((std::istreambuf_iterator(flagsFile)), + std::istreambuf_iterator()); - nlohmann::json j; - file >> j; + // Read bandit models file + std::ifstream banditsFile(banditsFilepath); + if (!banditsFile.is_open()) { + std::cerr << "Failed to open bandit models file: " << banditsFilepath << std::endl; + return false; + } + std::string banditsJson((std::istreambuf_iterator(banditsFile)), + std::istreambuf_iterator()); - response = j; - return true; -} + // Parse both configurations at once + std::string error; + config = eppoclient::parseConfiguration(flagsJson, banditsJson, error); -// Helper function to load bandit models from JSON file -bool loadBanditModels(const std::string& filepath, eppoclient::BanditResponse& response) { - std::ifstream file(filepath); - if (!file.is_open()) { - std::cerr << "Failed to open bandit models file: " << filepath << std::endl; + if (!error.empty()) { + std::cerr << "Failed to parse configuration: " << error << std::endl; return false; } - nlohmann::json j; - file >> j; - - response = j; return true; } int main() { // Load the bandit flags and models configuration std::cout << "Loading bandit flags and models configuration..." << std::endl; - eppoclient::ConfigResponse banditFlags; - eppoclient::BanditResponse banditModels; - if (!loadFlagsConfiguration("config/bandit-flags-v1.json", banditFlags)) { - return 1; - } - if (!loadBanditModels("config/bandit-models-v1.json", banditModels)) { + eppoclient::Configuration config; + if (!loadConfiguration("config/bandit-flags-v1.json", "config/bandit-models-v1.json", config)) { return 1; } // Create configuration store and set the configuration with both flags and bandits auto configStore = std::make_shared(); - configStore->setConfiguration(eppoclient::Configuration(banditFlags, banditModels)); + configStore->setConfiguration(config); // Create assignment logger, bandit logger, and application logger auto assignmentLogger = std::make_shared(); diff --git a/examples/flag_assignments.cpp b/examples/flag_assignments.cpp index 59b424b..be3150a 100644 --- a/examples/flag_assignments.cpp +++ b/examples/flag_assignments.cpp @@ -67,31 +67,40 @@ class ConsoleApplicationLogger : public eppoclient::ApplicationLogger { }; // Helper function to load flags configuration from JSON file -bool loadFlagsConfiguration(const std::string& filepath, eppoclient::ConfigResponse& response) { +bool loadFlagsConfiguration(const std::string& filepath, eppoclient::Configuration& config) { std::ifstream file(filepath); if (!file.is_open()) { std::cerr << "Failed to open flags configuration file: " << filepath << std::endl; return false; } - nlohmann::json j; - file >> j; + // Read entire file content into a string + std::string configJson((std::istreambuf_iterator(file)), + std::istreambuf_iterator()); + + // Parse configuration using parseConfiguration + std::string error; + config = eppoclient::parseConfiguration(configJson, error); + + if (!error.empty()) { + std::cerr << "Failed to parse configuration: " << error << std::endl; + return false; + } - response = j; return true; } int main() { // Load the flags configuration std::cout << "Loading flags configuration..." << std::endl; - eppoclient::ConfigResponse ufc; - if (!loadFlagsConfiguration("config/flags-v1.json", ufc)) { + eppoclient::Configuration config; + if (!loadFlagsConfiguration("config/flags-v1.json", config)) { return 1; } // Create configuration store and set the configuration auto configStore = std::make_shared(); - configStore->setConfiguration(eppoclient::Configuration(ufc)); + configStore->setConfiguration(config); // Create assignment logger and application logger auto assignmentLogger = std::make_shared(); diff --git a/src/bandit_model.cpp b/src/bandit_model.cpp index e0eca63..9dc55fb 100644 --- a/src/bandit_model.cpp +++ b/src/bandit_model.cpp @@ -249,32 +249,59 @@ void to_json(nlohmann::json& j, const BanditResponse& br) { j = nlohmann::json{{"bandits", br.bandits}, {"updatedAt", formatISOTimestamp(br.updatedAt)}}; } -void from_json(const nlohmann::json& j, BanditResponse& br) { - br.bandits.clear(); +namespace internal { + +BanditResponse parseBanditResponse(const std::string& banditJson, std::string& error) { + error.clear(); + BanditResponse response; - // Parse bandits - each bandit independently, skip invalid ones + // Use the non-throwing version of parse (returns discarded value on error) + nlohmann::json j = nlohmann::json::parse(banditJson, nullptr, false); + + if (j.is_discarded()) { + error = "Failed to parse JSON bandit response string"; + return BanditResponse(); // Return empty BanditResponse on error + } + + std::vector allErrors; + + // Parse bandits - each bandit independently, collect errors if (j.contains("bandits") && j["bandits"].is_object()) { - for (auto& [banditKey, banditJson] : j["bandits"].items()) { + for (auto& [banditKey, banditJsonObj] : j["bandits"].items()) { std::string parseError; - auto bandit = internal::parseBanditConfiguration(banditJson, parseError); + auto bandit = internal::parseBanditConfiguration(banditJsonObj, parseError); if (bandit) { - br.bandits[banditKey] = *bandit; + response.bandits[banditKey] = *bandit; + } else if (!parseError.empty()) { + allErrors.push_back("Bandit '" + banditKey + "': " + parseError); } - // If parsing failed, bandit is simply not included in the map } } + // Parse updatedAt field if (j.contains("updatedAt") && j["updatedAt"].is_string()) { - std::string error; - br.updatedAt = parseISOTimestamp(j["updatedAt"].get_ref(), error); - // TODO: log error - // if (!error.empty()) { - // logger.error("BanditResponse: Invalid updatedAt: " + error); - // } + std::string parseError; + response.updatedAt = + parseISOTimestamp(j["updatedAt"].get_ref(), parseError); + if (!parseError.empty()) { + allErrors.push_back("Invalid updatedAt: " + parseError); + } } + + // Consolidate all errors into a single error message + if (!allErrors.empty()) { + error = "Bandit response parsing encountered errors:\n"; + for (const auto& err : allErrors) { + error += " - " + err + "\n"; + } + } + + return response; } +} // namespace internal + // BanditVariation serialization void to_json(nlohmann::json& j, const BanditVariation& bv) { j = nlohmann::json{{"key", bv.key}, diff --git a/src/bandit_model.hpp b/src/bandit_model.hpp index f1bf630..1ffe2ee 100644 --- a/src/bandit_model.hpp +++ b/src/bandit_model.hpp @@ -99,7 +99,6 @@ struct BanditResponse { }; void to_json(nlohmann::json& j, const BanditResponse& br); -void from_json(const nlohmann::json& j, BanditResponse& br); /** * Associates a bandit with a specific flag variation. @@ -119,6 +118,11 @@ void to_json(nlohmann::json& j, const BanditVariation& bv); // Internal namespace for implementation details not covered by semver namespace internal { +// Parse bandit response from a JSON string +// Returns a BanditResponse. If parsing fails, error will contain the error message. +// INTERNAL API: Use parseConfiguration() in the parent namespace instead. +BanditResponse parseBanditResponse(const std::string& banditJson, std::string& error); + // Custom parsing functions that handle errors gracefully. // These are INTERNAL APIs and may change without notice. std::optional parseBanditNumericAttributeCoefficient( diff --git a/src/config_response.cpp b/src/config_response.cpp index 08efc03..1b7b7b5 100644 --- a/src/config_response.cpp +++ b/src/config_response.cpp @@ -1,8 +1,5 @@ #include "config_response.hpp" -#include #include -#include -#include #include "json_utils.hpp" #include "rules.hpp" #include "time_utils.hpp" @@ -620,20 +617,33 @@ void to_json(nlohmann::json& j, const ConfigResponse& cr) { j = nlohmann::json{{"flags", cr.flags}, {"bandits", cr.bandits}}; } -void from_json(const nlohmann::json& j, ConfigResponse& cr) { - cr.flags.clear(); - cr.bandits.clear(); +namespace internal { + +ConfigResponse parseConfigResponse(const std::string& configJson, std::string& error) { + error.clear(); + ConfigResponse response; + + // Use the non-throwing version of parse (returns discarded value on error) + nlohmann::json j = nlohmann::json::parse(configJson, nullptr, false); + + if (j.is_discarded()) { + error = "Failed to parse JSON configuration string"; + return ConfigResponse(); // Return empty ConfigResponse on error + } + + std::vector allErrors; - // Parse flags - each flag independently, skip invalid ones + // Parse flags - each flag independently, collect errors if (j.contains("flags") && j["flags"].is_object()) { for (auto& [flagKey, flagJson] : j["flags"].items()) { std::string parseError; auto flag = internal::parseFlagConfiguration(flagJson, parseError); if (flag) { - cr.flags[flagKey] = *flag; + response.flags[flagKey] = *flag; + } else if (!parseError.empty()) { + allErrors.push_back("Flag '" + flagKey + "': " + parseError); } - // If parsing failed, flag is simply not included in the map } } @@ -643,20 +653,38 @@ void from_json(const nlohmann::json& j, ConfigResponse& cr) { // Each bandit entry is an array of BanditVariation if (banditJsonArray.is_array()) { std::vector variations; + int varIndex = 0; for (const auto& varJson : banditJsonArray) { std::string parseError; auto banditVar = internal::parseBanditVariation(varJson, parseError); if (banditVar) { variations.push_back(*banditVar); + } else if (!parseError.empty()) { + allErrors.push_back("Bandit '" + banditKey + "' variation[" + + std::to_string(varIndex) + "]: " + parseError); } - // If parsing failed, variation is simply not included + varIndex++; } if (!variations.empty()) { - cr.bandits[banditKey] = variations; + response.bandits[banditKey] = variations; } + } else { + allErrors.push_back("Bandit '" + banditKey + "': Expected array of variations"); } } } + + // Consolidate all errors into a single error message + if (!allErrors.empty()) { + error = "Configuration parsing encountered errors:\n"; + for (const auto& err : allErrors) { + error += " - " + err + "\n"; + } + } + + return response; } +} // namespace internal + } // namespace eppoclient diff --git a/src/config_response.hpp b/src/config_response.hpp index 341a0a2..556dbf9 100644 --- a/src/config_response.hpp +++ b/src/config_response.hpp @@ -155,11 +155,15 @@ struct ConfigResponse { // serialization/deserialization for the nlohmann::json library void to_json(nlohmann::json& j, const ConfigResponse& cr); -void from_json(const nlohmann::json& j, ConfigResponse& cr); // Internal namespace for implementation details not covered by semver namespace internal { +// Parse configuration from a JSON string +// Returns a ConfigResponse. If parsing fails, error will contain the error message. +// INTERNAL API: Use parseConfiguration() in the parent namespace instead. +ConfigResponse parseConfigResponse(const std::string& configJson, std::string& error); + // Custom parsing functions that handle errors gracefully. // These are INTERNAL APIs and may change without notice. std::optional parseShardRange(const nlohmann::json& j, std::string& error); diff --git a/src/configuration.cpp b/src/configuration.cpp index 967142f..e68faad 100644 --- a/src/configuration.cpp +++ b/src/configuration.cpp @@ -60,4 +60,34 @@ const BanditConfiguration* Configuration::getBanditConfiguration(const std::stri return &(it->second); } +Configuration parseConfiguration(const std::string& flagConfigJson, + const std::string& banditModelsJson, std::string& error) { + error.clear(); + + // Parse flag configuration + std::string flagError; + ConfigResponse flagConfig = internal::parseConfigResponse(flagConfigJson, flagError); + if (!flagError.empty()) { + error = "Configuration parsing error: " + flagError; + return Configuration(); + } + + // Parse bandit models if provided + BanditResponse banditModels; + if (!banditModelsJson.empty()) { + std::string banditError; + banditModels = internal::parseBanditResponse(banditModelsJson, banditError); + if (!banditError.empty()) { + error = "Bandit models parsing error: " + banditError; + return Configuration(); + } + } + + return Configuration(std::move(flagConfig), std::move(banditModels)); +} + +Configuration parseConfiguration(const std::string& flagConfigJson, std::string& error) { + return parseConfiguration(flagConfigJson, "", error); +} + } // namespace eppoclient diff --git a/src/configuration.hpp b/src/configuration.hpp index bbcf4b5..83d8239 100644 --- a/src/configuration.hpp +++ b/src/configuration.hpp @@ -54,6 +54,16 @@ class Configuration { std::map> banditFlagAssociations_; }; +// Parse complete configuration from JSON strings +// Returns a Configuration object. If parsing fails, error will contain the error message. +// banditModelsJson is optional - pass an empty string to parse only flags. +Configuration parseConfiguration(const std::string& flagConfigJson, + const std::string& banditModelsJson, std::string& error); + +// Parse flag configuration only (without bandit models) +// Returns a Configuration object. If parsing fails, error will contain the error message. +Configuration parseConfiguration(const std::string& flagConfigJson, std::string& error); + } // namespace eppoclient #endif // CONFIGURATION_H diff --git a/test/shared_test_cases/test_bandit_evaluation.cpp b/test/shared_test_cases/test_bandit_evaluation.cpp index 66c9775..c838bbf 100644 --- a/test/shared_test_cases/test_bandit_evaluation.cpp +++ b/test/shared_test_cases/test_bandit_evaluation.cpp @@ -79,32 +79,36 @@ ContextAttributes parseContextAttributes(const json& attrJson) { return attributes; } -// Helper function to load bandit flags configuration from JSON file -ConfigResponse loadBanditFlagsConfiguration(const std::string& filepath) { - std::ifstream file(filepath); - if (!file.is_open()) { - throw std::runtime_error("Failed to open bandit flags configuration file: " + filepath); +// Helper function to load complete configuration (flags + bandits) from JSON files +Configuration loadBanditConfiguration(const std::string& flagsFilepath, + const std::string& banditsFilepath) { + // Read flags configuration file + std::ifstream flagsFile(flagsFilepath); + if (!flagsFile.is_open()) { + throw std::runtime_error("Failed to open bandit flags configuration file: " + + flagsFilepath); } + std::string flagsJson((std::istreambuf_iterator(flagsFile)), + std::istreambuf_iterator()); + + // Read bandit models file + std::ifstream banditsFile(banditsFilepath); + if (!banditsFile.is_open()) { + throw std::runtime_error("Failed to open bandit models configuration file: " + + banditsFilepath); + } + std::string banditsJson((std::istreambuf_iterator(banditsFile)), + std::istreambuf_iterator()); - json j; - file >> j; - - ConfigResponse response = j; - return response; -} + // Parse both configurations at once + std::string error; + Configuration config = parseConfiguration(flagsJson, banditsJson, error); -// Helper function to load bandit models configuration from JSON file -BanditResponse loadBanditModelsConfiguration(const std::string& filepath) { - std::ifstream file(filepath); - if (!file.is_open()) { - throw std::runtime_error("Failed to open bandit models configuration file: " + filepath); + if (!error.empty()) { + throw std::runtime_error("Failed to parse configuration: " + error); } - json j; - file >> j; - - BanditResponse response = j; - return response; + return config; } // Helper function to load a single bandit test case from JSON file @@ -197,38 +201,8 @@ TEST_CASE("UFC Bandit Test Cases - Bandit Action Selection", "[ufc][bandits]") { std::string flagsPath = "test/data/ufc/bandit-flags-v1.json"; std::string modelsPath = "test/data/ufc/bandit-models-v1.json"; - // Read flags JSON - std::ifstream flagsFile(flagsPath); - REQUIRE(flagsFile.is_open()); - json flagsJson; - flagsFile >> flagsJson; - flagsFile.close(); - - // Read models JSON - std::ifstream modelsFile(modelsPath); - REQUIRE(modelsFile.is_open()); - json modelsJson; - modelsFile >> modelsJson; - modelsFile.close(); - - // Create a JSON for ConfigResponse with flags and flattened bandits - json configJson; - configJson["flags"] = flagsJson["flags"]; - - // Parse the bandits array structure - ConfigResponse expects arrays - // The bandit-flags-v1.json has bandits as arrays which matches our structure - if (flagsJson.contains("bandits")) { - configJson["bandits"] = flagsJson["bandits"]; - } - - // Create ConfigResponse from the flags JSON - ConfigResponse configResponse = configJson; - - // Create BanditResponse from the models JSON - BanditResponse banditResponse = modelsJson; - - // Create configuration with both flags and bandit models - Configuration combinedConfig(configResponse, banditResponse); + // Load configuration using the combined helper function + Configuration combinedConfig = loadBanditConfiguration(flagsPath, modelsPath); // Create client with configuration auto configStore = std::make_shared(); @@ -340,8 +314,16 @@ TEST_CASE("Load bandit models configuration", "[ufc][bandit-config]") { std::string modelsPath = "test/data/ufc/bandit-models-v1.json"; SECTION("Bandit models file exists and can be parsed") { - BanditResponse response; - REQUIRE_NOTHROW(response = loadBanditModelsConfiguration(modelsPath)); + // Read the file + std::ifstream file(modelsPath); + REQUIRE(file.is_open()); + std::string jsonStr((std::istreambuf_iterator(file)), + std::istreambuf_iterator()); + + // Parse using parseBanditResponse + std::string error; + BanditResponse response = internal::parseBanditResponse(jsonStr, error); + REQUIRE(error.empty()); // Verify we have some bandits REQUIRE(response.bandits.size() > 0); diff --git a/test/shared_test_cases/test_flag_evaluation.cpp b/test/shared_test_cases/test_flag_evaluation.cpp index 454fcc5..622dd2c 100644 --- a/test/shared_test_cases/test_flag_evaluation.cpp +++ b/test/shared_test_cases/test_flag_evaluation.cpp @@ -29,17 +29,25 @@ struct TestCase { }; // Helper function to load flags configuration from JSON file -ConfigResponse loadFlagsConfiguration(const std::string& filepath) { +Configuration loadFlagsConfiguration(const std::string& filepath) { std::ifstream file(filepath); if (!file.is_open()) { throw std::runtime_error("Failed to open flags configuration file: " + filepath); } - json j; - file >> j; + // Read entire file content into a string + std::string configJson((std::istreambuf_iterator(file)), + std::istreambuf_iterator()); + + // Parse configuration using parseConfiguration + std::string error; + Configuration config = parseConfiguration(configJson, error); - ConfigResponse response = j; - return response; + if (!error.empty()) { + throw std::runtime_error("Failed to parse configuration: " + error); + } + + return config; } // Helper function to parse attributes from JSON @@ -177,12 +185,9 @@ bool compareValues( TEST_CASE("UFC Test Cases - Flag Assignments", "[ufc][flags]") { // Load flags configuration std::string flagsPath = "test/data/ufc/flags-v1.json"; - ConfigResponse configResponse; - - REQUIRE_NOTHROW(configResponse = loadFlagsConfiguration(flagsPath)); + Configuration config; - // Create configuration - Configuration config(configResponse); + REQUIRE_NOTHROW(config = loadFlagsConfiguration(flagsPath)); // Create client with configuration auto configStore = std::make_shared(); @@ -300,16 +305,17 @@ TEST_CASE("Load flags configuration", "[ufc][config]") { std::string flagsPath = "test/data/ufc/flags-v1.json"; SECTION("Flags file exists and can be parsed") { - ConfigResponse response; - REQUIRE_NOTHROW(response = loadFlagsConfiguration(flagsPath)); + Configuration config; + REQUIRE_NOTHROW(config = loadFlagsConfiguration(flagsPath)); - // Verify we have some flags - REQUIRE(response.flags.size() > 0); + // Verify we have some flags (check via getFlagConfiguration) + // We know from the test data that "kill-switch" flag exists + REQUIRE(config.getFlagConfiguration("kill-switch") != nullptr); // Check for specific known flags - CHECK(response.flags.count("kill-switch") > 0); - CHECK(response.flags.count("numeric_flag") > 0); - CHECK(response.flags.count("boolean-false-assignment") > 0); + CHECK(config.getFlagConfiguration("kill-switch") != nullptr); + CHECK(config.getFlagConfiguration("numeric_flag") != nullptr); + CHECK(config.getFlagConfiguration("boolean-false-assignment") != nullptr); } } diff --git a/test/shared_test_cases/test_flag_evaluation_details.cpp b/test/shared_test_cases/test_flag_evaluation_details.cpp index befae82..9f0bae2 100644 --- a/test/shared_test_cases/test_flag_evaluation_details.cpp +++ b/test/shared_test_cases/test_flag_evaluation_details.cpp @@ -40,17 +40,25 @@ struct EvaluationDetailsTestCase { }; // Helper function to load flags configuration from JSON file -static ConfigResponse loadFlagsConfiguration(const std::string& filepath) { +static Configuration loadFlagsConfiguration(const std::string& filepath) { std::ifstream file(filepath); if (!file.is_open()) { throw std::runtime_error("Failed to open flags configuration file: " + filepath); } - json j; - file >> j; + // Read entire file content into a string + std::string configJson((std::istreambuf_iterator(file)), + std::istreambuf_iterator()); + + // Parse configuration using parseConfiguration + std::string error; + Configuration config = parseConfiguration(configJson, error); - ConfigResponse response = j; - return response; + if (!error.empty()) { + throw std::runtime_error("Failed to parse configuration: " + error); + } + + return config; } // Helper function to parse attributes from JSON @@ -285,12 +293,9 @@ static void validateAllocationEvaluationCodes(const EvaluationDetails& evaluatio TEST_CASE("UFC Test Cases - Flag Evaluation Details", "[ufc][evaluation-details]") { // Load flags configuration std::string flagsPath = "test/data/ufc/flags-v1.json"; - ConfigResponse configResponse; - - REQUIRE_NOTHROW(configResponse = loadFlagsConfiguration(flagsPath)); + Configuration config; - // Create configuration - Configuration config(configResponse); + REQUIRE_NOTHROW(config = loadFlagsConfiguration(flagsPath)); // Create client with configuration auto configStore = std::make_shared(); diff --git a/test/shared_test_cases/test_flag_performance.cpp b/test/shared_test_cases/test_flag_performance.cpp index 18ccfa7..61033c5 100644 --- a/test/shared_test_cases/test_flag_performance.cpp +++ b/test/shared_test_cases/test_flag_performance.cpp @@ -42,17 +42,25 @@ struct TimingResult { }; // Helper function to load flags configuration from JSON file -static ConfigResponse loadFlagsConfiguration(const std::string& filepath) { +static Configuration loadFlagsConfiguration(const std::string& filepath) { std::ifstream file(filepath); if (!file.is_open()) { throw std::runtime_error("Failed to open flags configuration file: " + filepath); } - json j; - file >> j; + // Read entire file content into a string + std::string configJson((std::istreambuf_iterator(file)), + std::istreambuf_iterator()); + + // Parse configuration using parseConfiguration + std::string error; + Configuration config = parseConfiguration(configJson, error); - ConfigResponse response = j; - return response; + if (!error.empty()) { + throw std::runtime_error("Failed to parse configuration: " + error); + } + + return config; } // Helper function to parse attributes from JSON @@ -183,19 +191,15 @@ TEST_CASE("Performance - Flag Evaluation Timing", "[performance][flags]") { // Load flags configuration std::string flagsPath = "test/data/ufc/flags-v1.json"; std::cout << "Loading flags configuration...\n"; - ConfigResponse configResponse; + Configuration config; try { - configResponse = loadFlagsConfiguration(flagsPath); + config = loadFlagsConfiguration(flagsPath); std::cout << "Configuration loaded successfully\n"; } catch (const std::exception& e) { std::cerr << "Failed to load configuration: " << e.what() << std::endl; throw; } - // Create configuration - std::cout << "Creating configuration...\n"; - Configuration config(configResponse); - // Create client with configuration std::cout << "Creating client...\n"; diff --git a/test/test_allocation_evaluation_details.cpp b/test/test_allocation_evaluation_details.cpp index d1f8ab2..dc68ed0 100644 --- a/test/test_allocation_evaluation_details.cpp +++ b/test/test_allocation_evaluation_details.cpp @@ -16,9 +16,19 @@ ConfigResponse loadFlagsConfiguration(const std::string& filepath) { throw std::runtime_error("Failed to open flags configuration file: " + filepath); } - json j; - file >> j; - return j.get(); + // Read entire file content into a string + std::string configJson((std::istreambuf_iterator(file)), + std::istreambuf_iterator()); + + // Parse configuration using parseConfigResponse + std::string error; + ConfigResponse response = internal::parseConfigResponse(configJson, error); + + if (!error.empty()) { + throw std::runtime_error("Failed to parse configuration: " + error); + } + + return response; } } // namespace diff --git a/test/test_assignment_details.cpp b/test/test_assignment_details.cpp index c606d92..11f50f4 100644 --- a/test/test_assignment_details.cpp +++ b/test/test_assignment_details.cpp @@ -50,9 +50,19 @@ ConfigResponse loadFlagsConfiguration(const std::string& filepath) { throw std::runtime_error("Failed to open flags configuration file: " + filepath); } - json j; - file >> j; - return j.get(); + // Read entire file content into a string + std::string configJson((std::istreambuf_iterator(file)), + std::istreambuf_iterator()); + + // Parse configuration using parseConfigResponse + std::string error; + ConfigResponse response = internal::parseConfigResponse(configJson, error); + + if (!error.empty()) { + throw std::runtime_error("Failed to parse configuration: " + error); + } + + return response; } } // namespace diff --git a/test/test_bandit_action_details.cpp b/test/test_bandit_action_details.cpp index bf8266d..3cfd6e1 100644 --- a/test/test_bandit_action_details.cpp +++ b/test/test_bandit_action_details.cpp @@ -50,9 +50,19 @@ ConfigResponse loadFlagsConfiguration(const std::string& filepath) { throw std::runtime_error("Failed to open flags configuration file: " + filepath); } - json j; - file >> j; - return j.get(); + // Read entire file content into a string + std::string configJson((std::istreambuf_iterator(file)), + std::istreambuf_iterator()); + + // Parse configuration using parseConfigResponse + std::string error; + ConfigResponse response = internal::parseConfigResponse(configJson, error); + + if (!error.empty()) { + throw std::runtime_error("Failed to parse configuration: " + error); + } + + return response; } } // namespace diff --git a/test/test_bandit_model.cpp b/test/test_bandit_model.cpp index 21d332c..3775e27 100644 --- a/test/test_bandit_model.cpp +++ b/test/test_bandit_model.cpp @@ -206,7 +206,9 @@ TEST_CASE("BanditResponse serialization", "[bandit_model]") { CHECK(j["bandits"]["bandit1"]["banditKey"] == "bandit1"); // Deserialize from JSON - BanditResponse response2 = j; + std::string error2; + BanditResponse response2 = internal::parseBanditResponse(j.dump(), error2); + CHECK(error2.empty()); CHECK(response2.bandits.size() == 1); CHECK(response2.bandits["bandit1"].banditKey == "bandit1"); CHECK(response2.bandits["bandit1"].modelData.gamma == 0.85); @@ -256,11 +258,10 @@ TEST_CASE("Complete JSON round-trip", "[bandit_model]") { "updatedAt": "2024-01-15T10:30:00Z" })"; - // Parse JSON - json j = json::parse(jsonStr); - // Deserialize to BanditResponse - BanditResponse response = j; + std::string error; + BanditResponse response = internal::parseBanditResponse(jsonStr, error); + CHECK(error.empty()); // Verify structure CHECK(response.bandits.size() == 1); diff --git a/test/test_config_response_json.cpp b/test/test_config_response_json.cpp index 265b7a0..313a9bd 100644 --- a/test/test_config_response_json.cpp +++ b/test/test_config_response_json.cpp @@ -165,7 +165,9 @@ TEST_CASE("ConfigResponse deserialization - empty config", "[config_response][js json j = json::object(); j["flags"] = json::object(); - ConfigResponse response = j; + std::string error; + ConfigResponse response = internal::parseConfigResponse(j.dump(), error); + REQUIRE(error.empty()); REQUIRE(response.flags.empty()); } @@ -189,7 +191,9 @@ TEST_CASE("ConfigResponse deserialization - single simple flag", "[config_respon } })"_json; - ConfigResponse response = j; + std::string error; + ConfigResponse response = internal::parseConfigResponse(j.dump(), error); + REQUIRE(error.empty()); REQUIRE(response.flags.size() == 1); REQUIRE(response.flags.count("test-flag") == 1); @@ -247,7 +251,9 @@ TEST_CASE("ConfigResponse deserialization - all variation types", "[config_respo } })"_json; - ConfigResponse response = j; + std::string error; + ConfigResponse response = internal::parseConfigResponse(j.dump(), error); + REQUIRE(error.empty()); REQUIRE(response.flags.size() == 5); REQUIRE(response.flags["string-flag"].variationType == VariationType::STRING); @@ -303,7 +309,9 @@ TEST_CASE("ConfigResponse deserialization - flag with allocations", "[config_res } })"_json; - ConfigResponse response = j; + std::string error; + ConfigResponse response = internal::parseConfigResponse(j.dump(), error); + REQUIRE(error.empty()); REQUIRE(response.flags.size() == 1); REQUIRE(response.flags["allocated-flag"].allocations.size() == 1); @@ -325,7 +333,9 @@ TEST_CASE("ConfigResponse deserialization - from real UFC file", "[config_respon json j; file >> j; - ConfigResponse response = j; + std::string error; + ConfigResponse response = internal::parseConfigResponse(j.dump(), error); + REQUIRE(error.empty()); // Verify some expected flags from the test file REQUIRE(response.flags.size() > 0); @@ -363,7 +373,9 @@ TEST_CASE("ConfigResponse round-trip - simple flag", "[config_response][json]") json j = original; // Deserialize - ConfigResponse deserialized = j; + std::string error; + ConfigResponse deserialized = internal::parseConfigResponse(j.dump(), error); + REQUIRE(error.empty()); // Verify REQUIRE(deserialized.flags.size() == 1); @@ -446,7 +458,9 @@ TEST_CASE("ConfigResponse round-trip - complex flag with allocations", "[config_ json j = original; // Deserialize - ConfigResponse deserialized = j; + std::string error; + ConfigResponse deserialized = internal::parseConfigResponse(j.dump(), error); + REQUIRE(error.empty()); // Verify structure is preserved REQUIRE(deserialized.flags.size() == 1); @@ -488,7 +502,9 @@ TEST_CASE("ConfigResponse precompute after deserialization", "[config_response][ } })"_json; - ConfigResponse response = j; + std::string error; + ConfigResponse response = internal::parseConfigResponse(j.dump(), error); + REQUIRE(error.empty()); // Precompute should not crash REQUIRE_NOTHROW(response.precompute()); @@ -543,7 +559,9 @@ TEST_CASE("ConfigResponse usage example - deserialize from file", "[config_respo nlohmann::json j; file >> j; - ConfigResponse response = j; + std::string error; + ConfigResponse response = internal::parseConfigResponse(j.dump(), error); + REQUIRE(error.empty()); // Verify the response is valid REQUIRE(response.flags.size() > 0); diff --git a/test/test_configuration.cpp b/test/test_configuration.cpp index 28e391c..1b90005 100644 --- a/test/test_configuration.cpp +++ b/test/test_configuration.cpp @@ -66,11 +66,10 @@ TEST_CASE("ConfigResponse with bandits", "[configuration]") { } })"; - // Parse the JSON - json j = json::parse(jsonStr); - - // Deserialize to ConfigResponse - ConfigResponse response = j; + // Parse configuration using parseConfigResponse + std::string error; + ConfigResponse response = internal::parseConfigResponse(jsonStr, error); + REQUIRE(error.empty()); // Verify structure CHECK(response.flags.size() == 1); @@ -141,11 +140,10 @@ TEST_CASE("BanditResponse with multiple bandits", "[configuration]") { "updatedAt": "2024-01-15T11:00:00Z" })"; - // Parse JSON - json j = json::parse(jsonStr); - // Deserialize to BanditResponse - BanditResponse response = j; + std::string error; + BanditResponse response = internal::parseBanditResponse(jsonStr, error); + CHECK(error.empty()); // Verify structure CHECK(response.bandits.size() == 2); @@ -175,3 +173,160 @@ TEST_CASE("BanditResponse with multiple bandits", "[configuration]") { CHECK(j2["bandits"]["bandit-1"]["modelName"] == "falcon"); CHECK(j2["bandits"]["bandit-2"]["modelName"] == "contextual"); } + +TEST_CASE("parseConfiguration convenience function", "[configuration]") { + std::string flagConfigJson = R"({ + "flags": { + "test-flag": { + "key": "test-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "control": { + "key": "control", + "value": "default" + } + }, + "allocations": [], + "totalShards": 10000 + } + } + })"; + + std::string banditModelsJson = R"({ + "bandits": { + "test-bandit": { + "banditKey": "test-bandit", + "modelName": "falcon", + "modelVersion": "v1", + "updatedAt": "2024-01-15T10:30:00Z", + "modelData": { + "gamma": 1.0, + "defaultActionScore": 0.0, + "actionProbabilityFloor": 0.0, + "coefficients": {} + } + } + } + })"; + + // Parse both configurations + std::string error; + Configuration config = parseConfiguration(flagConfigJson, banditModelsJson, error); + CHECK(error.empty()); + + // Verify flag was parsed + const FlagConfiguration* flag = config.getFlagConfiguration("test-flag"); + REQUIRE(flag != nullptr); + CHECK(flag->key == "test-flag"); + CHECK(flag->enabled == true); + + // Verify bandit was parsed + const BanditConfiguration* bandit = config.getBanditConfiguration("test-bandit"); + REQUIRE(bandit != nullptr); + CHECK(bandit->banditKey == "test-bandit"); + CHECK(bandit->modelName == "falcon"); +} + +TEST_CASE("parseConfiguration with invalid flag config", "[configuration]") { + std::string flagConfigJson = "invalid json"; + std::string banditModelsJson = R"({"bandits": {}})"; + + std::string error; + Configuration config = parseConfiguration(flagConfigJson, banditModelsJson, error); + + // Should have an error + CHECK(!error.empty()); + CHECK(error.find("Configuration parsing error") != std::string::npos); +} + +TEST_CASE("parseConfiguration with invalid bandit config", "[configuration]") { + std::string flagConfigJson = R"({"flags": {}})"; + std::string banditModelsJson = "invalid json"; + + std::string error; + Configuration config = parseConfiguration(flagConfigJson, banditModelsJson, error); + + // Should have an error + CHECK(!error.empty()); + CHECK(error.find("Bandit models parsing error") != std::string::npos); +} + +TEST_CASE("parseConfiguration without bandit models", "[configuration]") { + std::string flagConfigJson = R"({ + "flags": { + "test-flag": { + "key": "test-flag", + "enabled": true, + "variationType": "BOOLEAN", + "variations": { + "on": { + "key": "on", + "value": true + }, + "off": { + "key": "off", + "value": false + } + }, + "allocations": [], + "totalShards": 10000 + } + } + })"; + + // Parse with empty bandit models JSON + std::string error; + Configuration config = parseConfiguration(flagConfigJson, "", error); + CHECK(error.empty()); + + // Verify flag was parsed + const FlagConfiguration* flag = config.getFlagConfiguration("test-flag"); + REQUIRE(flag != nullptr); + CHECK(flag->key == "test-flag"); + CHECK(flag->enabled == true); + + // Verify no bandit configuration + const BanditConfiguration* bandit = config.getBanditConfiguration("test-bandit"); + CHECK(bandit == nullptr); +} + +TEST_CASE("parseConfiguration flags-only overload", "[configuration]") { + std::string flagConfigJson = R"({ + "flags": { + "simple-flag": { + "key": "simple-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "a": { + "key": "a", + "value": "variant-a" + }, + "b": { + "key": "b", + "value": "variant-b" + } + }, + "allocations": [], + "totalShards": 10000 + } + } + })"; + + // Parse with two-parameter overload (no bandits) + std::string error; + Configuration config = parseConfiguration(flagConfigJson, error); + CHECK(error.empty()); + + // Verify flag was parsed + const FlagConfiguration* flag = config.getFlagConfiguration("simple-flag"); + REQUIRE(flag != nullptr); + CHECK(flag->key == "simple-flag"); + CHECK(flag->enabled == true); + CHECK(flag->variations.size() == 2); + + // Verify no bandit configuration + const BanditConfiguration* bandit = config.getBanditConfiguration("any-bandit"); + CHECK(bandit == nullptr); +} diff --git a/test/test_serialized_json_assignment.cpp b/test/test_serialized_json_assignment.cpp index eb3d65c..faa8626 100644 --- a/test/test_serialized_json_assignment.cpp +++ b/test/test_serialized_json_assignment.cpp @@ -39,10 +39,18 @@ ConfigResponse loadFlagsConfiguration(const std::string& filepath) { throw std::runtime_error("Failed to open flags configuration file: " + filepath); } - json j; - file >> j; + // Read entire file content into a string + std::string configJson((std::istreambuf_iterator(file)), + std::istreambuf_iterator()); + + // Parse configuration using parseConfigResponse + std::string error; + ConfigResponse response = internal::parseConfigResponse(configJson, error); + + if (!error.empty()) { + throw std::runtime_error("Failed to parse configuration: " + error); + } - ConfigResponse response = j; return response; }