fix: Array parsing in new config (#1884)

Fixes #1870.
This commit is contained in:
Sergey Kuznetsov
2025-02-12 13:28:06 +00:00
committed by GitHub
parent cd1aa8fb70
commit e503dffc9a
12 changed files with 818 additions and 191 deletions

View File

@@ -36,14 +36,22 @@ Array::Array(ConfigValue arg) : itemPattern_{std::move(arg)}
{
}
std::string_view
Array::prefix(std::string_view key)
{
static constexpr std::string_view kARRAY_SUFFIX = ".[]";
ASSERT(key.contains(kARRAY_SUFFIX), "Provided key is not an array key: {}", key);
return key.substr(0, key.rfind(kARRAY_SUFFIX) + kARRAY_SUFFIX.size());
}
std::optional<Error>
Array::addValue(Value value, std::optional<std::string_view> key)
{
auto const& configValPattern = itemPattern_;
auto const constraint = configValPattern.getConstraint();
auto const constraint = itemPattern_.getConstraint();
auto newElem = constraint.has_value() ? ConfigValue{configValPattern.type()}.withConstraint(constraint->get())
: ConfigValue{configValPattern.type()};
auto newElem = constraint.has_value() ? ConfigValue{itemPattern_.type()}.withConstraint(constraint->get())
: ConfigValue{itemPattern_.type()};
if (auto const maybeError = newElem.setValue(value, key); maybeError.has_value())
return maybeError;
elements_.emplace_back(std::move(newElem));

View File

@@ -47,6 +47,17 @@ public:
*/
Array(ConfigValue arg);
/**
* @brief Extract array prefix from a key, For example for a key foo.[].bar the method will return foo.[]
* @note Provided key must contain '.[]'
* @warning Be careful with string_view! Returned value is valid only while the key is valid
*
* @param key The key to extract the array prefix from
* @return Prefix of array extracted from the key
*/
static std::string_view
prefix(std::string_view key);
/**
* @brief Add ConfigValues to Array class
*

View File

@@ -20,6 +20,7 @@
#include "util/newconfig/ConfigFileJson.hpp"
#include "util/Assert.hpp"
#include "util/newconfig/Array.hpp"
#include "util/newconfig/Error.hpp"
#include "util/newconfig/Types.hpp"
@@ -30,15 +31,19 @@
#include <boost/json/value.hpp>
#include <fmt/core.h>
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <exception>
#include <filesystem>
#include <fstream>
#include <ios>
#include <optional>
#include <queue>
#include <sstream>
#include <string>
#include <string_view>
#include <unordered_map>
#include <utility>
#include <vector>
@@ -69,14 +74,17 @@ extractJsonValue(boost::json::value const& jsonValue)
if (jsonValue.is_double()) {
return jsonValue.as_double();
}
ASSERT(false, "Json is not of type int, uint, string, bool or double");
if (jsonValue.is_null()) {
return NullType{};
}
ASSERT(false, "Json is not of type null, int, uint, string, bool or double");
std::unreachable();
}
} // namespace
ConfigFileJson::ConfigFileJson(boost::json::object jsonObj)
{
flattenJson(jsonObj, "");
flattenJson(jsonObj);
}
std::expected<ConfigFileJson, Error>
@@ -86,8 +94,7 @@ ConfigFileJson::makeConfigFileJson(std::filesystem::path const& configFilePath)
if (auto const in = std::ifstream(configFilePath.string(), std::ios::in | std::ios::binary); in) {
std::stringstream contents;
contents << in.rdbuf();
auto opts = boost::json::parse_options{};
opts.allow_comments = true;
auto const opts = boost::json::parse_options{.allow_comments = true};
auto const tempObj = boost::json::parse(contents.str(), {}, opts).as_object();
return ConfigFileJson{tempObj};
}
@@ -105,7 +112,9 @@ ConfigFileJson::makeConfigFileJson(std::filesystem::path const& configFilePath)
Value
ConfigFileJson::getValue(std::string_view key) const
{
ASSERT(containsKey(key), "Key {} not found in ConfigFileJson", key);
auto const jsonValue = jsonObject_.at(key);
ASSERT(jsonValue.is_primitive(), "Key {} has value that is not a primitive", key);
auto const value = extractJsonValue(jsonValue);
return value;
}
@@ -113,14 +122,15 @@ ConfigFileJson::getValue(std::string_view key) const
std::vector<Value>
ConfigFileJson::getArray(std::string_view key) const
{
ASSERT(containsKey(key), "Key {} not found in ConfigFileJson", key);
ASSERT(jsonObject_.at(key).is_array(), "Key {} has value that is not an array", key);
std::vector<Value> configValues;
auto const arr = jsonObject_.at(key).as_array();
for (auto const& item : arr) {
auto const value = extractJsonValue(item);
configValues.emplace_back(value);
auto value = extractJsonValue(item);
configValues.emplace_back(std::move(value));
}
return configValues;
}
@@ -131,38 +141,90 @@ ConfigFileJson::containsKey(std::string_view key) const
return jsonObject_.contains(key);
}
void
ConfigFileJson::flattenJson(boost::json::object const& obj, std::string const& prefix)
boost::json::object const&
ConfigFileJson::inner() const
{
for (auto const& [key, value] : obj) {
std::string const fullKey = prefix.empty() ? std::string(key) : fmt::format("{}.{}", prefix, std::string(key));
return jsonObject_;
}
// In ClioConfigDefinition, value must be a primitive or array
if (value.is_object()) {
flattenJson(value.as_object(), fullKey);
} else if (value.is_array()) {
auto const& arr = value.as_array();
for (std::size_t i = 0; i < arr.size(); ++i) {
std::string const arrayPrefix = fullKey + ".[]";
if (arr[i].is_object()) {
flattenJson(arr[i].as_object(), arrayPrefix);
void
ConfigFileJson::flattenJson(boost::json::object const& jsonRootObject)
{
struct Task {
boost::json::object const& object;
std::string prefix;
std::optional<size_t> arrayIndex = std::nullopt;
};
std::queue<Task> tasks;
tasks.push(Task{.object = jsonRootObject, .prefix = ""});
std::unordered_map<std::string, size_t> arraysSizes;
while (not tasks.empty()) {
auto const task = std::move(tasks.front());
tasks.pop();
for (auto const& [key, value] : task.object) {
auto fullKey =
task.prefix.empty() ? std::string(key) : fmt::format("{}.{}", task.prefix, std::string_view{key});
if (value.is_object()) {
tasks.push(
Task{.object = value.as_object(), .prefix = std::move(fullKey), .arrayIndex = task.arrayIndex}
);
} else if (value.is_array()) {
fullKey += ".[]";
auto const& array = value.as_array();
if (std::ranges::all_of(array, [](auto const& v) { return v.is_primitive(); })) {
jsonObject_[fullKey] = array;
} else if (std::ranges::all_of(array, [](auto const& v) { return v.is_object(); })) {
for (size_t i = 0; i < array.size(); ++i) {
tasks.push(Task{.object = array.at(i).as_object(), .prefix = fullKey, .arrayIndex = i});
}
} else {
jsonObject_[arrayPrefix] = arr;
ASSERT(
false,
"Arrays containing both values and objects are not supported. Please check the array {}",
fullKey
);
}
}
} else {
// if "[]" is present in key, then value must be an array instead of primitive
if (fullKey.contains(".[]") && !jsonObject_.contains(fullKey)) {
boost::json::array newArray;
newArray.emplace_back(value);
jsonObject_[fullKey] = newArray;
} else if (fullKey.contains(".[]") && jsonObject_.contains(fullKey)) {
jsonObject_[fullKey].as_array().emplace_back(value);
} else {
jsonObject_[fullKey] = value;
if (task.arrayIndex.has_value()) {
if (not jsonObject_.contains(fullKey)) {
jsonObject_[fullKey] = boost::json::array{};
}
auto& targetArray = jsonObject_.at(fullKey).as_array();
while (targetArray.size() < (*task.arrayIndex + 1)) {
targetArray.push_back(boost::json::value());
}
targetArray.at(*task.arrayIndex) = value;
auto const prefix = std::string{Array::prefix(fullKey)};
arraysSizes[prefix] = std::max(arraysSizes[prefix], targetArray.size());
} else {
jsonObject_[fullKey] = value;
}
}
}
}
// adjust length of each array containing objects
std::ranges::for_each(jsonObject_, [&arraysSizes](auto& item) {
auto const key = item.key();
if (not key.contains("[]"))
return;
auto& value = item.value();
auto const prefix = std::string{Array::prefix(key)};
if (auto const it = arraysSizes.find(prefix); it != arraysSizes.end()) {
auto const size = it->second;
while (value.as_array().size() < size) {
value.as_array().push_back(boost::json::value{});
}
}
});
}
} // namespace util::config

View File

@@ -35,6 +35,8 @@ namespace util::config {
/** @brief Json representation of config */
class ConfigFileJson final : public ConfigFileInterface {
boost::json::object jsonObject_;
public:
/**
* @brief Construct a new ConfigJson object and stores the values from
@@ -81,20 +83,26 @@ public:
[[nodiscard]] static std::expected<ConfigFileJson, Error>
makeConfigFileJson(std::filesystem::path const& configFilePath);
/**
* @brief Get the inner representation of json file.
* @note This method is mostly used for testing purposes.
*
* @return The inner representation of json file.
*/
[[nodiscard]] boost::json::object const&
inner() const;
private:
/**
* @brief Recursive function to flatten a JSON object into the same structure as the Clio Config.
* @brief Method to flatten a JSON object into the same structure as the Clio Config.
*
* The keys will end up having the same naming convensions in Clio Config.
* The keys will end up having the same naming conventions in Clio Config.
* Other than the keys specified in user Config file, no new keys are created.
*
* @param obj The JSON object to flatten.
* @param prefix The prefix to use for the keys in the flattened object.
*/
void
flattenJson(boost::json::object const& obj, std::string const& prefix);
boost::json::object jsonObject_;
flattenJson(boost::json::object const& jsonRootObject);
};
} // namespace util::config

View File

@@ -34,6 +34,7 @@
#include <ostream>
#include <string>
#include <string_view>
#include <utility>
#include <variant>
namespace util::config {
@@ -80,18 +81,31 @@ public:
[[nodiscard]] std::optional<Error>
setValue(Value value, std::optional<std::string_view> key = std::nullopt)
{
if (std::holds_alternative<NullType>(value)) {
if (hasValue()) {
// Using default value
return std::nullopt;
}
if (not isOptional()) {
return Error{
key.value_or("Unknown_key"),
"Provided value is null but ConfigValue is not optional and doesn't have a default value."
};
}
value_ = std::move(value);
return std::nullopt;
}
auto err = checkTypeConsistency(type_, value);
if (err.has_value()) {
if (key.has_value())
err->error = fmt::format("{} {}", key.value(), err->error);
err->error = fmt::format("{} {}", key.value_or("Unknown_key"), err->error);
return err;
}
if (cons_.has_value()) {
auto constraintCheck = cons_->get().checkConstraint(value);
if (constraintCheck.has_value()) {
if (key.has_value())
constraintCheck->error = fmt::format("{} {}", key.value(), constraintCheck->error);
constraintCheck->error = fmt::format("{} {}", key.value_or("Unknown_key"), constraintCheck->error);
return constraintCheck;
}
}
@@ -127,7 +141,8 @@ public:
[&type](bool tmp) { type = fmt::format("bool {}", tmp); },
[&type](std::string const& tmp) { type = fmt::format("string {}", tmp); },
[&type](double tmp) { type = fmt::format("double {}", tmp); },
[&type](int64_t tmp) { type = fmt::format("int {}", tmp); }
[&type](int64_t tmp) { type = fmt::format("int {}", tmp); },
[&type](NullType) { type = "null"; },
},
value_.value()
);
@@ -199,6 +214,7 @@ public:
[[nodiscard]] Value const&
getValue() const
{
ASSERT(value_.has_value(), "getValue() is called when there is no value set");
return value_.value();
}

View File

@@ -21,6 +21,8 @@
#include "util/UnsupportedType.hpp"
#include <fmt/core.h>
#include <cstdint>
#include <expected>
#include <ostream>
@@ -30,7 +32,23 @@
namespace util::config {
/** @brief Custom clio config types */
enum class ConfigType { Integer, String, Double, Boolean };
enum class ConfigType { Integer, String, Double, Boolean, Null };
/**
* @brief A type that represents a null value
*/
struct NullType {
/**
* @brief Compare two NullType objects
*
* @return true always. Any two NullType objects are equal
*/
[[nodiscard]] bool
operator==(NullType const&) const
{
return true;
}
};
/**
* @brief Prints the specified config type to output stream
@@ -43,7 +61,7 @@ std::ostream&
operator<<(std::ostream& stream, ConfigType type);
/** @brief Represents the supported Config Values */
using Value = std::variant<int64_t, std::string, bool, double>;
using Value = std::variant<int64_t, std::string, bool, double, NullType>;
/**
* @brief Prints the specified value to output stream
@@ -73,9 +91,24 @@ getType()
return ConfigType::Double;
} else if constexpr (std::is_same_v<Type, bool>) {
return ConfigType::Boolean;
} else if constexpr (std::is_same_v<Type, NullType>) {
return ConfigType::Null;
} else {
static_assert(util::Unsupported<Type>, "Wrong config type");
}
}
} // namespace util::config
/** @cond */
// Doxygen could not parse this
template <>
struct fmt::formatter<util::config::NullType> : fmt::formatter<char const*> {
[[nodiscard]]
auto
format(util::config::NullType const&, fmt::format_context& ctx)
{
return fmt::formatter<char const*>::format("null", ctx);
}
};
/** @endcond */

View File

@@ -183,8 +183,7 @@ target_sources(
util/newconfig/ClioConfigDefinitionTests.cpp
util/newconfig/ConfigValueTests.cpp
util/newconfig/ObjectViewTests.cpp
util/newconfig/JsonConfigFileTests.cpp
util/newconfig/JsonFileTests.cpp
util/newconfig/ConfigFileJsonTests.cpp
util/newconfig/ValueViewTests.cpp
)

View File

@@ -31,6 +31,18 @@
using namespace util::config;
TEST(ArrayTest, prefix)
{
EXPECT_EQ(Array::prefix("foo.[]"), "foo.[]");
EXPECT_EQ(Array::prefix("foo.[].bar"), "foo.[]");
EXPECT_EQ(Array::prefix("foo.bar.[].baz"), "foo.bar.[]");
}
TEST(ArrayDeathTest, prefix)
{
EXPECT_DEATH(Array::prefix("foo.bar"), ".*");
}
TEST(ArrayTest, addSingleValue)
{
auto arr = Array{ConfigValue{ConfigType::Double}};

View File

@@ -0,0 +1,480 @@
//------------------------------------------------------------------------------
/*
This file is part of clio: https://github.com/XRPLF/clio
Copyright (c) 2024, the clio developers.
Permission to use, copy, modify, and distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#include "util/LoggerFixtures.hpp"
#include "util/NameGenerator.hpp"
#include "util/OverloadSet.hpp"
#include "util/TmpFile.hpp"
#include "util/newconfig/ConfigFileJson.hpp"
#include "util/newconfig/Types.hpp"
#include <boost/json/array.hpp>
#include <boost/json/object.hpp>
#include <boost/json/parse.hpp>
#include <gtest/gtest.h>
#include <algorithm>
#include <cstdint>
#include <string>
#include <unordered_map>
#include <variant>
using namespace util::config;
namespace {
constexpr auto kEPS = 1e-9;
} // namespace
struct ConfigFileJsonParseTestBundle {
using ValidationMap = std::unordered_map<
std::string,
std::variant<int64_t, double, bool, std::string, boost::json::object, boost::json::array>>;
std::string testName;
std::string configStr;
ValidationMap validationMap;
};
struct ConfigFileJsonParseTest : NoLoggerFixture, testing::WithParamInterface<ConfigFileJsonParseTestBundle> {};
TEST_P(ConfigFileJsonParseTest, parseValues)
{
ConfigFileJson const configFile{boost::json::parse(GetParam().configStr).as_object()};
auto const& flatJson = configFile.inner();
ASSERT_EQ(GetParam().validationMap.size(), flatJson.size());
std::ranges::for_each(GetParam().validationMap, [&flatJson](auto const& kvPair) {
auto const& key = kvPair.first;
auto const& value = kvPair.second;
EXPECT_TRUE(flatJson.contains(key));
std::visit(
util::OverloadSet{
[&flatJson, &key](int64_t const v) {
EXPECT_TRUE(flatJson.at(key).is_number()) << key << ": " << v;
EXPECT_EQ(flatJson.at(key).as_int64(), v) << key << ": " << v;
},
[&flatJson, &key](double const v) {
EXPECT_TRUE(flatJson.at(key).is_double()) << key << ": " << v;
EXPECT_NEAR(flatJson.at(key).as_double(), v, kEPS) << key << ": " << v;
},
[&flatJson, &key](bool const v) {
EXPECT_TRUE(flatJson.at(key).is_bool()) << key << ": " << v;
EXPECT_EQ(flatJson.at(key).as_bool(), v) << key << ": " << v;
},
[&flatJson, &key](std::string const& v) {
EXPECT_TRUE(flatJson.at(key).is_string()) << key << ": " << v;
EXPECT_EQ(flatJson.at(key).as_string(), v) << key << ": " << v;
},
[&flatJson, &key](boost::json::object const& v) {
EXPECT_TRUE(flatJson.at(key).is_object()) << key << ": " << v;
EXPECT_EQ(flatJson.at(key).as_object(), v) << key << ": " << v;
},
[&flatJson, &key](boost::json::array const& v) {
EXPECT_TRUE(flatJson.at(key).is_array()) << key << ": " << v;
EXPECT_EQ(flatJson.at(key).as_array(), v) << key << ": " << v;
},
},
value
);
});
}
INSTANTIATE_TEST_CASE_P(
ConfigFileJsonParseTestGroup,
ConfigFileJsonParseTest,
testing::Values(
ConfigFileJsonParseTestBundle{
.testName = "values",
.configStr = R"json({
"int": 42,
"double": 123.456,
"bool": true,
"string": "some string"
})json",
.validationMap = {{"int", 42}, {"double", 123.456}, {"bool", true}, {"string", "some string"}}
},
ConfigFileJsonParseTestBundle{
.testName = "nested",
.configStr = R"json({
"level_0": {
"int": 42,
"level_1":{
"double": 123.456,
"level_2": {
"bool": true,
"level_3": {
"string": "some string"
}
}
}
}
})json",
.validationMap =
{{"level_0.int", 42},
{"level_0.level_1.double", 123.456},
{"level_0.level_1.level_2.bool", true},
{"level_0.level_1.level_2.level_3.string", "some string"}}
},
ConfigFileJsonParseTestBundle{
.testName = "array",
.configStr = R"json({
"array": [1, 2, 3]
})json",
.validationMap = {{"array.[]", boost::json::array{1, 2, 3}}}
},
ConfigFileJsonParseTestBundle{
.testName = "nested_array",
.configStr = R"json({
"level_0": {
"array": [1, 2, 3],
"level_1": {
"array": [4, 5, 6],
"level_2": {
"array": [7, 8, 9]
}
}
}
})json",
.validationMap =
{
{"level_0.array.[]", boost::json::array{1, 2, 3}},
{"level_0.level_1.array.[]", boost::json::array{4, 5, 6}},
{"level_0.level_1.level_2.array.[]", boost::json::array{7, 8, 9}},
}
},
ConfigFileJsonParseTestBundle{
.testName = "mixed",
.configStr = R"json({
"int": 42,
"double": 123.456,
"bool": true,
"string": "some string",
"array": [1, 2, 3],
"nested": {
"int": 42,
"double": 123.456,
"bool": true,
"string": "some string",
"array": [1, 2, 3]
}
})json",
.validationMap =
{
{"int", 42},
{"double", 123.456},
{"bool", true},
{"string", "some string"},
{"array.[]", boost::json::array{1, 2, 3}},
{"nested.int", 42},
{"nested.double", 123.456},
{"nested.bool", true},
{"nested.string", "some string"},
{"nested.array.[]", boost::json::array{1, 2, 3}},
}
},
ConfigFileJsonParseTestBundle{.testName = "empty", .configStr = R"json({})json", .validationMap = {}},
ConfigFileJsonParseTestBundle{
.testName = "empty_nested",
.configStr = R"json({
"level_0": {
"level_1": {
"level_2": {
"level_3": {}
}
}
}
})json",
.validationMap = {}
},
ConfigFileJsonParseTestBundle{
.testName = "empty_array",
.configStr = R"json({
"array": []
})json",
.validationMap = {{"array.[]", boost::json::array{}}}
},
ConfigFileJsonParseTestBundle{
.testName = "empty_nested_array",
.configStr = R"json({
"level_0": {
"array": [],
"level_1": {
"array": [],
"level_2": {
"array": []
}
}
}
})json",
.validationMap =
{
{"level_0.array.[]", boost::json::array{}},
{"level_0.level_1.array.[]", boost::json::array{}},
{"level_0.level_1.level_2.array.[]", boost::json::array{}},
}
},
ConfigFileJsonParseTestBundle{
.testName = "object_inside_array",
.configStr = R"json({
"array": [
{ "int": 42 }
]
})json",
.validationMap = {{"array.[].int", boost::json::array{42}}}
},
ConfigFileJsonParseTestBundle{
.testName = "object_with_optional_fields_inside_array",
.configStr = R"json({
"array": [
{"int": 42},
{"int": 24, "bool": true}
]
})json",
.validationMap =
{{"array.[].int", boost::json::array{42, 24}},
{"array.[].bool", boost::json::array{boost::json::value{}, true}}}
},
ConfigFileJsonParseTestBundle{
.testName = "full_object_is_at_the_front_of_array",
.configStr = R"json({
"array": [
{"int": 42, "bool": true},
{"int": 2},
{"int": 4}
]
})json",
.validationMap =
{{"array.[].int", boost::json::array{42, 2, 4}},
{"array.[].bool", boost::json::array{true, boost::json::value{}, boost::json::value{}}}}
},
ConfigFileJsonParseTestBundle{
.testName = "full_object_is_in_the_middle_of_array",
.configStr = R"json({
"array": [
{"int": 42},
{"int": 2, "bool": true},
{"int": 4}
]
})json",
.validationMap =
{{"array.[].int", boost::json::array{42, 2, 4}},
{"array.[].bool", boost::json::array{boost::json::value{}, true, boost::json::value{}}}}
},
ConfigFileJsonParseTestBundle{
.testName = "no_full_object",
.configStr = R"json({
"array": [
{"int": 42},
{"int": 2},
{"bool": true}
]
})json",
.validationMap =
{{"array.[].int", boost::json::array{42, 2, boost::json::value{}}},
{"array.[].bool", boost::json::array{boost::json::value{}, boost::json::value{}, true}}}
},
ConfigFileJsonParseTestBundle{
.testName = "array_with_nexted_objects",
.configStr = R"json({
"array": [
{ "object": { "int": 42 } },
{ "object": { "string": "some string" } }
]
})json",
.validationMap =
{{"array.[].object.int", boost::json::array{42, boost::json::value{}}},
{"array.[].object.string", boost::json::array{boost::json::value{}, "some string"}}}
}
),
tests::util::kNAME_GENERATOR
);
struct ConfigFileJsonTest : NoLoggerFixture {};
TEST_F(ConfigFileJsonTest, getValue)
{
auto const jsonStr = R"json({
"int": 42,
"object": { "string": "some string" },
"bool": true,
"double": 123.456,
"null": null
})json";
auto const jsonFileObj = ConfigFileJson{boost::json::parse(jsonStr).as_object()};
auto const intValue = jsonFileObj.getValue("int");
ASSERT_TRUE(std::holds_alternative<int64_t>(intValue));
EXPECT_EQ(std::get<int64_t>(intValue), 42);
auto const stringValue = jsonFileObj.getValue("object.string");
ASSERT_TRUE(std::holds_alternative<std::string>(stringValue));
EXPECT_EQ(std::get<std::string>(stringValue), "some string");
auto const boolValue = jsonFileObj.getValue("bool");
ASSERT_TRUE(std::holds_alternative<bool>(boolValue));
EXPECT_EQ(std::get<bool>(boolValue), true);
auto const doubleValue = jsonFileObj.getValue("double");
ASSERT_TRUE(std::holds_alternative<double>(doubleValue));
EXPECT_NEAR(std::get<double>(doubleValue), 123.456, kEPS);
auto const nullValue = jsonFileObj.getValue("null");
EXPECT_TRUE(std::holds_alternative<NullType>(nullValue));
EXPECT_FALSE(jsonFileObj.containsKey("object.int"));
}
struct ConfigFileJsonDeathTest : ConfigFileJsonTest {};
TEST_F(ConfigFileJsonDeathTest, getValueInvalidKey)
{
auto const jsonFileObj = ConfigFileJson{boost::json::parse("{}").as_object()};
EXPECT_DEATH([[maybe_unused]] auto a = jsonFileObj.getValue("some_key"), ".*");
}
TEST_F(ConfigFileJsonDeathTest, getValueOfArray)
{
auto const jsonStr = R"json({
"array": [1, 2, 3]
})json";
auto const jsonFileObj = ConfigFileJson{boost::json::parse(jsonStr).as_object()};
EXPECT_DEATH([[maybe_unused]] auto a = jsonFileObj.getValue("array"), ".*");
}
TEST_F(ConfigFileJsonTest, getArray)
{
auto const jsonStr = R"json({
"array": [1, "2", 3.14, true],
"object": { "array": [3, 4] }
})json";
auto const jsonFileObj = ConfigFileJson{boost::json::parse(jsonStr).as_object()};
auto const array = jsonFileObj.getArray("array.[]");
ASSERT_EQ(array.size(), 4);
ASSERT_TRUE(std::holds_alternative<int64_t>(array.at(0)));
EXPECT_EQ(std::get<int64_t>(array.at(0)), 1);
ASSERT_TRUE(std::holds_alternative<std::string>(array.at(1)));
EXPECT_EQ(std::get<std::string>(array.at(1)), "2");
ASSERT_TRUE(std::holds_alternative<double>(array.at(2)));
EXPECT_NEAR(std::get<double>(array.at(2)), 3.14, kEPS);
ASSERT_TRUE(std::holds_alternative<bool>(array.at(3)));
EXPECT_EQ(std::get<bool>(array.at(3)), true);
auto const arrayFromObject = jsonFileObj.getArray("object.array.[]");
ASSERT_EQ(arrayFromObject.size(), 2);
EXPECT_EQ(std::get<int64_t>(arrayFromObject.at(0)), 3);
EXPECT_EQ(std::get<int64_t>(arrayFromObject.at(1)), 4);
}
TEST_F(ConfigFileJsonTest, getArrayObjectInArray)
{
auto const jsonStr = R"json({
"array": [
{ "int": 42 },
{ "string": "some string" }
]
})json";
auto const jsonFileObj = ConfigFileJson{boost::json::parse(jsonStr).as_object()};
auto const ints = jsonFileObj.getArray("array.[].int");
ASSERT_EQ(ints.size(), 2);
ASSERT_TRUE(std::holds_alternative<int64_t>(ints.at(0)));
EXPECT_EQ(std::get<int64_t>(ints.at(0)), 42);
EXPECT_TRUE(std::holds_alternative<NullType>(ints.at(1)));
auto const strings = jsonFileObj.getArray("array.[].string");
ASSERT_EQ(strings.size(), 2);
EXPECT_TRUE(std::holds_alternative<NullType>(strings.at(0)));
ASSERT_TRUE(std::holds_alternative<std::string>(strings.at(1)));
EXPECT_EQ(std::get<std::string>(strings.at(1)), "some string");
}
TEST_F(ConfigFileJsonDeathTest, getArrayInvalidKey)
{
auto const jsonFileObj = ConfigFileJson{boost::json::parse("{}").as_object()};
EXPECT_DEATH([[maybe_unused]] auto a = jsonFileObj.getArray("some_key"), ".*");
}
TEST_F(ConfigFileJsonDeathTest, getArrayNotArray)
{
auto const jsonStr = R"json({
"int": 42
})json";
auto const jsonFileObj = ConfigFileJson{boost::json::parse(jsonStr).as_object()};
EXPECT_DEATH([[maybe_unused]] auto a = jsonFileObj.getArray("int"), ".*");
}
TEST_F(ConfigFileJsonTest, containsKey)
{
auto const jsonStr = R"json({
"int": 42,
"object": { "string": "some string", "array": [1, 2, 3] },
"array2": [1, 2, 3],
"array_of_objects": [ {"int": 42}, {"string": "some string"} ]
})json";
auto const jsonFileObj = ConfigFileJson{boost::json::parse(jsonStr).as_object()};
EXPECT_TRUE(jsonFileObj.containsKey("int"));
EXPECT_FALSE(jsonFileObj.containsKey("other_key"));
EXPECT_TRUE(jsonFileObj.containsKey("object.string"));
EXPECT_FALSE(jsonFileObj.containsKey("object.int"));
EXPECT_TRUE(jsonFileObj.containsKey("object.array.[]"));
EXPECT_FALSE(jsonFileObj.containsKey("object.array"));
EXPECT_TRUE(jsonFileObj.containsKey("array2.[]"));
EXPECT_FALSE(jsonFileObj.containsKey("array2"));
EXPECT_FALSE(jsonFileObj.containsKey("array2.[].int"));
EXPECT_TRUE(jsonFileObj.containsKey("array_of_objects.[].int"));
EXPECT_TRUE(jsonFileObj.containsKey("array_of_objects.[].string"));
EXPECT_FALSE(jsonFileObj.containsKey("array_of_objects.[]"));
EXPECT_FALSE(jsonFileObj.containsKey("array_of_objects.[].object"));
}
struct ConfigFileJsonMakeTest : ConfigFileJsonTest {};
TEST_F(ConfigFileJsonMakeTest, invalidFile)
{
auto const jsonFileObj = ConfigFileJson::makeConfigFileJson("does_not_exist");
EXPECT_FALSE(jsonFileObj.has_value());
}
TEST_F(ConfigFileJsonMakeTest, invalidJson)
{
auto const file = TmpFile("invalid json");
auto const jsonFileObj = ConfigFileJson::makeConfigFileJson(file.path);
EXPECT_FALSE(jsonFileObj.has_value());
}
TEST_F(ConfigFileJsonMakeTest, validFile)
{
auto const file = TmpFile(R"json({ "int": 42 })json");
auto const jsonFileObj = ConfigFileJson::makeConfigFileJson(file.path);
ASSERT_TRUE(jsonFileObj.has_value());
auto const& flatJson = jsonFileObj->inner();
ASSERT_EQ(flatJson.size(), 1);
ASSERT_TRUE(flatJson.contains("int"));
ASSERT_TRUE(flatJson.at("int").is_number());
EXPECT_EQ(flatJson.at("int").as_int64(), 42);
}

View File

@@ -17,62 +17,173 @@
*/
//==============================================================================
#include "util/LoggerFixtures.hpp"
#include "util/newconfig/ConfigConstraints.hpp"
#include "util/newconfig/ConfigValue.hpp"
#include "util/newconfig/Error.hpp"
#include "util/newconfig/Types.hpp"
#include <fmt/core.h>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <array>
#include <optional>
#include <ostream>
#include <string>
using namespace util::config;
TEST(ConfigValue, GetSetString)
struct ConfigValueTest : NoLoggerFixture {};
struct ConfigValueDeathTest : ConfigValueTest {};
TEST_F(ConfigValueTest, construct)
{
auto const cvStr = ConfigValue{ConfigType::String}.defaultValue("12345");
EXPECT_EQ(cvStr.type(), ConfigType::String);
EXPECT_TRUE(cvStr.hasValue());
EXPECT_FALSE(cvStr.isOptional());
ConfigValue const cv{ConfigType::Integer};
EXPECT_FALSE(cv.hasValue());
EXPECT_FALSE(cv.isOptional());
EXPECT_EQ(cv.type(), ConfigType::Integer);
}
TEST(ConfigValue, GetSetInteger)
TEST_F(ConfigValueTest, optional)
{
auto const cvInt = ConfigValue{ConfigType::Integer}.defaultValue(543);
EXPECT_EQ(cvInt.type(), ConfigType::Integer);
EXPECT_TRUE(cvInt.hasValue());
EXPECT_FALSE(cvInt.isOptional());
auto const cv = ConfigValue{ConfigType::Integer}.optional();
EXPECT_FALSE(cv.hasValue());
EXPECT_TRUE(cv.isOptional());
EXPECT_EQ(cv.type(), ConfigType::Integer);
}
auto const cvOpt = ConfigValue{ConfigType::Integer}.optional();
EXPECT_TRUE(cvOpt.isOptional());
TEST_F(ConfigValueTest, defaultValue)
{
auto const cv = ConfigValue{ConfigType::Integer}.defaultValue(123);
EXPECT_TRUE(cv.hasValue());
EXPECT_FALSE(cv.isOptional());
EXPECT_EQ(cv.type(), ConfigType::Integer);
}
TEST_F(ConfigValueDeathTest, invalidDefaultValue)
{
EXPECT_DEATH({ [[maybe_unused]] auto const a = ConfigValue{ConfigType::String}.defaultValue(33); }, ".*");
}
TEST_F(ConfigValueTest, setValueNull)
{
auto cv = ConfigValue{ConfigType::Integer};
auto const err = cv.setValue(NullType{});
EXPECT_TRUE(err.has_value());
}
TEST_F(ConfigValueTest, setValueNullOptional)
{
auto cv = ConfigValue{ConfigType::Integer}.optional();
auto const err = cv.setValue(NullType{});
EXPECT_FALSE(err.has_value());
}
TEST_F(ConfigValueTest, setValueNullDefault)
{
auto cv = ConfigValue{ConfigType::Integer}.defaultValue(123);
auto const err = cv.setValue(NullType{});
EXPECT_FALSE(err.has_value());
EXPECT_EQ(cv.getValue(), Value{123});
}
TEST_F(ConfigValueTest, setValueWrongType)
{
auto cv = ConfigValue{ConfigType::Integer};
auto const err = cv.setValue("123");
EXPECT_TRUE(err.has_value());
}
TEST_F(ConfigValueTest, setValueNormalPath)
{
auto cv = ConfigValue{ConfigType::Integer};
auto const err = cv.setValue(123);
EXPECT_FALSE(err.has_value());
EXPECT_EQ(cv.getValue(), Value{123});
}
struct ConfigValueConstraintTest : ConfigValueTest {
struct MockConstraint : Constraint {
MOCK_METHOD(std::optional<Error>, checkTypeImpl, (Value const&), (const, override));
MOCK_METHOD(std::optional<Error>, checkValueImpl, (Value const&), (const, override));
MOCK_METHOD(void, print, (std::ostream&), (const, override));
};
testing::StrictMock<MockConstraint> constraint;
};
TEST_F(ConfigValueConstraintTest, setValueWithConstraint)
{
auto cv = ConfigValue{ConfigType::Integer}.withConstraint(constraint);
auto const value = Value{123};
EXPECT_CALL(constraint, checkTypeImpl).WillOnce(testing::Return(std::nullopt));
EXPECT_CALL(constraint, checkValueImpl).WillOnce(testing::Return(std::nullopt));
auto const err = cv.setValue(value);
EXPECT_FALSE(err.has_value());
EXPECT_EQ(cv.getValue(), value);
}
TEST_F(ConfigValueConstraintTest, setValueWithConstraintTypeCheckError)
{
auto cv = ConfigValue{ConfigType::Integer}.withConstraint(constraint);
auto const value = 123;
EXPECT_CALL(constraint, checkTypeImpl).WillOnce(testing::Return(Error{"type error"}));
auto const err = cv.setValue(value);
EXPECT_TRUE(err.has_value());
EXPECT_EQ(err->error, "Unknown_key type error");
}
TEST_F(ConfigValueConstraintTest, defaultValueWithConstraint)
{
EXPECT_CALL(constraint, checkTypeImpl).WillOnce(testing::Return(std::nullopt));
EXPECT_CALL(constraint, checkValueImpl).WillOnce(testing::Return(std::nullopt));
auto const cv = ConfigValue{ConfigType::Integer}.defaultValue(123).withConstraint(constraint);
EXPECT_EQ(cv.getValue(), Value{123});
}
struct ConfigValueConstraintDeathTest : ConfigValueConstraintTest {};
TEST_F(ConfigValueConstraintDeathTest, defaultValueWithConstraintCheckError)
{
EXPECT_DEATH(
{
EXPECT_CALL(constraint, checkTypeImpl).WillOnce(testing::Return(std::nullopt));
EXPECT_CALL(constraint, checkValueImpl).WillOnce(testing::Return(Error{"value error"}));
[[maybe_unused]] auto const cv =
ConfigValue{ConfigType::Integer}.defaultValue(123).withConstraint(constraint);
},
".*"
);
}
// A test for each constraint so it's easy to change in the future
TEST(ConfigValue, PortConstraint)
struct ConstraintTest : NoLoggerFixture {};
TEST_F(ConstraintTest, PortConstraint)
{
auto const portConstraint{PortConstraint{}};
EXPECT_FALSE(portConstraint.checkConstraint(4444).has_value());
EXPECT_TRUE(portConstraint.checkConstraint(99999).has_value());
}
TEST(ConfigValue, SetValuesOnPortConstraint)
TEST_F(ConstraintTest, SetValuesOnPortConstraint)
{
auto cvPort = ConfigValue{ConfigType::Integer}.defaultValue(4444).withConstraint(gValidatePort);
auto const err = cvPort.setValue(99999);
EXPECT_TRUE(err.has_value());
EXPECT_EQ(err->error, "Port does not satisfy the constraint bounds");
EXPECT_EQ(err->error, "Unknown_key Port does not satisfy the constraint bounds");
EXPECT_TRUE(cvPort.setValue(33.33).has_value());
EXPECT_TRUE(cvPort.setValue(33.33).value().error == "value does not match type integer");
EXPECT_EQ(cvPort.setValue(33.33).value().error, "Unknown_key value does not match type integer");
EXPECT_FALSE(cvPort.setValue(1).has_value());
auto cvPort2 = ConfigValue{ConfigType::String}.defaultValue("4444").withConstraint(gValidatePort);
auto const strPortError = cvPort2.setValue("100000");
EXPECT_TRUE(strPortError.has_value());
EXPECT_EQ(strPortError->error, "Port does not satisfy the constraint bounds");
EXPECT_EQ(strPortError->error, "Unknown_key Port does not satisfy the constraint bounds");
}
TEST(ConfigValue, OneOfConstraintOneValue)
TEST_F(ConstraintTest, OneOfConstraintOneValue)
{
std::array<char const*, 1> const arr = {"tracer"};
auto const databaseConstraint{OneOf{"database.type", arr}};
@@ -88,7 +199,7 @@ TEST(ConfigValue, OneOfConstraintOneValue)
);
}
TEST(ConfigValue, OneOfConstraint)
TEST_F(ConstraintTest, OneOfConstraint)
{
std::array<char const*, 3> const arr = {"123", "trace", "haha"};
auto const oneOfCons{OneOf{"log_level", arr}};
@@ -105,14 +216,14 @@ TEST(ConfigValue, OneOfConstraint)
);
}
TEST(ConfigValue, IpConstraint)
TEST_F(ConstraintTest, IpConstraint)
{
auto ip = ConfigValue{ConfigType::String}.defaultValue("127.0.0.1").withConstraint(gValidateIp);
EXPECT_FALSE(ip.setValue("http://127.0.0.1").has_value());
EXPECT_FALSE(ip.setValue("http://127.0.0.1.com").has_value());
auto const err = ip.setValue("123.44");
EXPECT_TRUE(err.has_value());
EXPECT_EQ(err->error, "Ip is not a valid ip address");
EXPECT_EQ(err->error, "Unknown_key Ip is not a valid ip address");
EXPECT_FALSE(ip.setValue("126.0.0.2"));
EXPECT_TRUE(ip.setValue("644.3.3.0"));
@@ -123,7 +234,7 @@ TEST(ConfigValue, IpConstraint)
EXPECT_FALSE(ip.setValue("http://example.com:8080/path"));
}
TEST(ConfigValue, positiveNumConstraint)
TEST_F(ConstraintTest, positiveNumConstraint)
{
auto const numCons{NumberValueConstraint{0, 5}};
EXPECT_FALSE(numCons.checkConstraint(0));
@@ -136,7 +247,7 @@ TEST(ConfigValue, positiveNumConstraint)
EXPECT_EQ(numCons.checkConstraint(8)->error, fmt::format("Number must be between {} and {}", 0, 5));
}
TEST(ConfigValue, SetValuesOnNumberConstraint)
TEST_F(ConstraintTest, SetValuesOnNumberConstraint)
{
auto positiveNum = ConfigValue{ConfigType::Integer}.defaultValue(20u).withConstraint(gValidateUint16);
auto const err = positiveNum.setValue(-22, "key");
@@ -145,7 +256,7 @@ TEST(ConfigValue, SetValuesOnNumberConstraint)
EXPECT_FALSE(positiveNum.setValue(99, "key"));
}
TEST(ConfigValue, PositiveDoubleConstraint)
TEST_F(ConstraintTest, PositiveDoubleConstraint)
{
auto const doubleCons{PositiveDouble{}};
EXPECT_FALSE(doubleCons.checkConstraint(0.2));
@@ -162,7 +273,7 @@ struct ConstraintTestBundle {
Constraint const& constraint;
};
struct ConstraintDeathTest : public testing::Test, public testing::WithParamInterface<ConstraintTestBundle> {};
struct ConstraintDeathTest : testing::TestWithParam<ConstraintTestBundle> {};
INSTANTIATE_TEST_SUITE_P(
EachConstraints,
@@ -195,7 +306,7 @@ TEST_P(ConstraintDeathTest, TestEachConstraint)
);
}
TEST(ConfigValueDeathTest, SetInvalidValueTypeStringAndBool)
TEST(ConstraintDeathTest, SetInvalidValueTypeStringAndBool)
{
EXPECT_DEATH(
{
@@ -207,7 +318,7 @@ TEST(ConfigValueDeathTest, SetInvalidValueTypeStringAndBool)
EXPECT_DEATH({ [[maybe_unused]] auto a = ConfigValue{ConfigType::Boolean}.defaultValue(-66); }, ".*");
}
TEST(ConfigValueDeathTest, OutOfBounceIntegerConstraint)
TEST(ConstraintDeathTest, OutOfBounceIntegerConstraint)
{
EXPECT_DEATH(
{

View File

@@ -1,113 +0,0 @@
//------------------------------------------------------------------------------
/*
This file is part of clio: https://github.com/XRPLF/clio
Copyright (c) 2024, the clio developers.
Permission to use, copy, modify, and distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#include "util/TmpFile.hpp"
#include "util/newconfig/ConfigFileJson.hpp"
#include "util/newconfig/FakeConfigData.hpp"
#include <boost/json/parse.hpp>
#include <gtest/gtest.h>
#include <algorithm>
#include <cstdint>
#include <string>
#include <variant>
#include <vector>
TEST(CreateConfigFile, filePath)
{
auto const jsonFileObj = ConfigFileJson::makeConfigFileJson(TmpFile(kJSON_DATA).path);
EXPECT_TRUE(jsonFileObj.has_value());
EXPECT_TRUE(jsonFileObj->containsKey("array.[].sub"));
auto const arrSub = jsonFileObj->getArray("array.[].sub");
EXPECT_EQ(arrSub.size(), 3);
}
TEST(CreateConfigFile, incorrectFilePath)
{
auto const jsonFileObj = util::config::ConfigFileJson::makeConfigFileJson("123/clio");
EXPECT_FALSE(jsonFileObj.has_value());
}
struct ParseJson : testing::Test {
ParseJson() : jsonFileObj{boost::json::parse(kJSON_DATA).as_object()}
{
}
ConfigFileJson const jsonFileObj;
};
TEST_F(ParseJson, validateValues)
{
EXPECT_TRUE(jsonFileObj.containsKey("header.text1"));
EXPECT_EQ(std::get<std::string>(jsonFileObj.getValue("header.text1")), "value");
EXPECT_TRUE(jsonFileObj.containsKey("header.sub.sub2Value"));
EXPECT_EQ(std::get<std::string>(jsonFileObj.getValue("header.sub.sub2Value")), "TSM");
EXPECT_TRUE(jsonFileObj.containsKey("dosguard.port"));
EXPECT_EQ(std::get<int64_t>(jsonFileObj.getValue("dosguard.port")), 44444);
EXPECT_FALSE(jsonFileObj.containsKey("idk"));
EXPECT_FALSE(jsonFileObj.containsKey("optional.withNoDefault"));
}
TEST_F(ParseJson, validateArrayValue)
{
// validate array.[].sub matches expected values
EXPECT_TRUE(jsonFileObj.containsKey("array.[].sub"));
auto const arrSub = jsonFileObj.getArray("array.[].sub");
EXPECT_EQ(arrSub.size(), 3);
std::vector<double> expectedArrSubVal{111.11, 4321.55, 5555.44};
std::vector<double> actualArrSubVal{};
for (auto it = arrSub.begin(); it != arrSub.end(); ++it) {
ASSERT_TRUE(std::holds_alternative<double>(*it));
actualArrSubVal.emplace_back(std::get<double>(*it));
}
EXPECT_TRUE(std::ranges::equal(expectedArrSubVal, actualArrSubVal));
// validate array.[].sub2 matches expected values
EXPECT_TRUE(jsonFileObj.containsKey("array.[].sub2"));
auto const arrSub2 = jsonFileObj.getArray("array.[].sub2");
EXPECT_EQ(arrSub2.size(), 3);
std::vector<std::string> expectedArrSub2Val{"subCategory", "temporary", "london"};
std::vector<std::string> actualArrSub2Val{};
for (auto it = arrSub2.begin(); it != arrSub2.end(); ++it) {
ASSERT_TRUE(std::holds_alternative<std::string>(*it));
actualArrSub2Val.emplace_back(std::get<std::string>(*it));
}
EXPECT_TRUE(std::ranges::equal(expectedArrSub2Val, actualArrSub2Val));
EXPECT_TRUE(jsonFileObj.containsKey("dosguard.whitelist.[]"));
auto const whitelistArr = jsonFileObj.getArray("dosguard.whitelist.[]");
EXPECT_EQ(whitelistArr.size(), 2);
EXPECT_EQ("125.5.5.1", std::get<std::string>(whitelistArr.at(0)));
EXPECT_EQ("204.2.2.1", std::get<std::string>(whitelistArr.at(1)));
}
struct JsonValueDeathTest : ParseJson {};
TEST_F(JsonValueDeathTest, invalidGetArray)
{
EXPECT_DEATH([[maybe_unused]] auto a = jsonFileObj.getArray("header.text1"), ".*");
}

View File

@@ -89,9 +89,9 @@ TEST_F(JsonFromTempFile, validateArrayValue)
EXPECT_EQ("204.2.2.1", std::get<std::string>(whitelistArr.at(1)));
}
struct JsonValueDeathTest : JsonFromTempFile {};
struct ConfigValueJsonGetArrayDeathTest : JsonFromTempFile {};
TEST_F(JsonValueDeathTest, invalidGetValues)
TEST_F(ConfigValueJsonGetArrayDeathTest, invalidGetValues)
{
// not possible for json value to call a value that doesn't exist
EXPECT_DEATH([[maybe_unused]] auto a = jsonFileObj.getArray("header.text1"), ".*");