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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

**Note**: Semantic Versioning applies to source-level compatibility only. No guarantees are made regarding Application Binary Interface (ABI) stability. When upgrading versions, always recompile your application against the new SDK version. Avoid mixing object files or libraries compiled against different SDK versions.

## [2.0.3] - 2026-01-27

- fix crash in `parseConfigResponse` when receiving empty, null, or non-object JSON input (#51)

## [2.0.2] - 2026-01-14

- fix time_utils fmodules build
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ help:
@echo " test-eval-performance - Run flag evaluation performance tests (min/max/avg μs)"
@echo " examples - Build all examples"
@echo " run-bandits - Build and run the bandits example"
@echo " run-flag-assignments - Build and run the flag_assignments example"
@echo " run-flags - Build and run the flag_assignments example"
@echo " run-assignment-details - Build and run the assignment_details example"
@echo " run-manual-sync - Build and run the manual_sync example"
@echo " format - Format all C++ source files with clang-format"
Expand Down
7 changes: 7 additions & 0 deletions src/config_response.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,13 @@ ParseResult<ConfigResponse> parseConfigResponse(const nlohmann::json& j) {
ParseResult<ConfigResponse> result;
ConfigResponse cr;

// Validate that input is a JSON object
if (!j.is_object()) {
result.errors.push_back("ConfigResponse: Expected a JSON object, got " +
std::string(j.type_name()));
Comment on lines +632 to +633
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consider another log line that prints out the received configuration to give them a totally self service way to iterate; alternatively does this error give them a strong enough signal that they could catch and print it themselves?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current error message includes the JSON type name (e.g., "Expected a JSON object, got null" or "got array"), which should give a good signal about what happened. Since parseConfigResponse returns a ParseResult with the error in result.errors, consuming apps can catch and log the full input themselves. So we should be good 👍

return result;
}

// Parse flags - required field
if (!j.contains("flags")) {
result.errors.push_back("ConfigResponse: Missing required field: flags");
Expand Down
2 changes: 1 addition & 1 deletion src/version.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

#define EPPOCLIENT_VERSION_MAJOR 2
#define EPPOCLIENT_VERSION_MINOR 0
#define EPPOCLIENT_VERSION_PATCH 2
#define EPPOCLIENT_VERSION_PATCH 3
#define EPPOCLIENT_VERSION \
(EPPO_STRINGIFY(EPPOCLIENT_VERSION_MAJOR) "." EPPO_STRINGIFY( \
EPPOCLIENT_VERSION_MINOR) "." EPPO_STRINGIFY(EPPOCLIENT_VERSION_PATCH))
Expand Down
90 changes: 90 additions & 0 deletions test/test_config_response_json.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,96 @@ TEST_CASE("ConfigResponse deserialization - empty config", "[config_response][js
REQUIRE(result->flags.empty());
}

TEST_CASE("ConfigResponse deserialization - empty string input", "[config_response][json]") {
std::string emptyString = "";

auto result = parseConfigResponse(emptyString);
REQUIRE_FALSE(result.hasValue());
REQUIRE(result.errors.size() == 1);
REQUIRE(result.errors[0].find("JSON parse error") != std::string::npos);
}

TEST_CASE("ConfigResponse deserialization - null JSON input", "[config_response][json]") {
json j = nullptr;

auto result = parseConfigResponse(j);
REQUIRE_FALSE(result.hasValue());
REQUIRE(result.errors.size() == 1);
REQUIRE(result.errors[0].find("Expected a JSON object") != std::string::npos);
}

TEST_CASE("ConfigResponse deserialization - null JSON string input", "[config_response][json]") {
std::string nullString = "null";

auto result = parseConfigResponse(nullString);
REQUIRE_FALSE(result.hasValue());
REQUIRE(result.errors.size() == 1);
REQUIRE(result.errors[0].find("Expected a JSON object") != std::string::npos);
}

TEST_CASE("ConfigResponse deserialization - array JSON input", "[config_response][json]") {
json j = json::array();

auto result = parseConfigResponse(j);
REQUIRE_FALSE(result.hasValue());
REQUIRE(result.errors.size() == 1);
REQUIRE(result.errors[0].find("Expected a JSON object") != std::string::npos);
}

TEST_CASE("ConfigResponse deserialization - array JSON string input", "[config_response][json]") {
std::string arrayString = "[]";

auto result = parseConfigResponse(arrayString);
REQUIRE_FALSE(result.hasValue());
REQUIRE(result.errors.size() == 1);
REQUIRE(result.errors[0].find("Expected a JSON object") != std::string::npos);
}

TEST_CASE("ConfigResponse deserialization - number JSON input", "[config_response][json]") {
json j = 123;

auto result = parseConfigResponse(j);
REQUIRE_FALSE(result.hasValue());
REQUIRE(result.errors.size() == 1);
REQUIRE(result.errors[0].find("Expected a JSON object") != std::string::npos);
}

TEST_CASE("ConfigResponse deserialization - string JSON input", "[config_response][json]") {
json j = "not an object";

auto result = parseConfigResponse(j);
REQUIRE_FALSE(result.hasValue());
REQUIRE(result.errors.size() == 1);
REQUIRE(result.errors[0].find("Expected a JSON object") != std::string::npos);
}

TEST_CASE("ConfigResponse deserialization - boolean JSON input", "[config_response][json]") {
json j = true;

auto result = parseConfigResponse(j);
REQUIRE_FALSE(result.hasValue());
REQUIRE(result.errors.size() == 1);
REQUIRE(result.errors[0].find("Expected a JSON object") != std::string::npos);
}

TEST_CASE("ConfigResponse deserialization - malformed JSON string", "[config_response][json]") {
std::string malformed = "{invalid json";

auto result = parseConfigResponse(malformed);
REQUIRE_FALSE(result.hasValue());
REQUIRE(result.errors.size() == 1);
REQUIRE(result.errors[0].find("JSON parse error") != std::string::npos);
}

TEST_CASE("ConfigResponse deserialization - truncated JSON string", "[config_response][json]") {
std::string truncated = R"({"flags": {"test": {"key": "t)";

auto result = parseConfigResponse(truncated);
REQUIRE_FALSE(result.hasValue());
REQUIRE(result.errors.size() == 1);
REQUIRE(result.errors[0].find("JSON parse error") != std::string::npos);
}

TEST_CASE("ConfigResponse deserialization - single simple flag", "[config_response][json]") {
json j = R"({
"flags": {
Expand Down
Loading