Post

Seamless JSON config files integration with EasyJsonCpp

As a Backend Engineer, ensuring confidentiality and security is paramount in my work, especially when handling sensitive data like API key and private configuration elements that require strict confidentiality.

While I strive to contribute to the open-source community, I am mindful of safeguarding my own privacy and security by avoiding any inadvertent disclosure of sensitive information. Consequently, I have adopted a simple technique which consists of loading configuration data from a JSON config file into a map or multiple maps (Or any data structure of choice) to enable seamless and efficient data retrieval only when and where needed.

This document presents an illustration of a C++ class, SomeClass, and utilizes its methods to demonstrate the core concepts of how to keep sensitive data like API keys secure and private.

1
2
3
4
5
6
7
8
9
10
11
12
EasyJsonCPP/
│
├── include/
│   ├── easyJson/
│   │   ├── easyJsonCPP.hpp
│   │   └── header.hpp
│   └── mathlib.hpp
│
├── src/
│   └── MathFunctions.cpp
│
└── CMakeLists.txt (or another build script)

Table of content

Configuration File Structure

I follow a naming convention for my config files, which involves naming them as PROJECT_config.json where “PROJECT” represents the project name. These config files follow a JSON array of objects structure where each element or section holds specific data for various components.

This format allows compact organization and easy data retrieval, with simple Json object iteration. Which simplify the process of extracting required information for each component.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
[
  {
    "info": "some string",
    "Item1": [
      {
        "api": "data",
        "port": "80"
      }
    ]
  },
  {
    "Item2": [
      {
        "endpoint": "to the moon",
        "token": "token"
      }
    ]
  },
  {
    "Item3": [
      {
        "client_secret": "ipsum lorem",
        "client_key": "sed do eiusmod tempor"
      }
    ]
  }
]

In particular, all of my project config files include an "info" section where I store essential project details. This section contains information such as:

  • run level mode (debug, off, warn, info, error, critical)
  • version
  • filename
  • author
  • description.
  • etc …
1
2
3
4
5
6
7
8
9
10
11
12
{
    "info": [
      {
        "mode": "debug",
        "project": "someProject",
        "version": "(v1.0.0)",
        "filename": "someProject_config.json",
        "author": "Wilfrantz Dede",
        "description": "Short project description."
      }
    ]
}

Additional sections can be added as needed, depending on the specific needs of the project. For instance, if the project involves retrieving data from the Twitter API, a dedicated "twitter" section can be created within the config file.

This section would contain all the necessary properties and configurations related to interacting with the Twitter API.

1
2
3
4
5
6
7
8
9
10
11
12
13
{
    "twitter": [
      {
        "api_key": "SECRET_API_KEY",
        "media_endpoint": "https://api.twitter.com/2/media/",
        "api_url": "https://api.twitter.com/1.1/statuses/show.json?id=",
        "api_secret": "API_SECRET",
        "access_token": "ACCESS_TOKEN",
        "access_token_secret": "ACCESS_TOKEN_SECRET",
        "bearer_token": "BEARER_TOKEN"
      }
    ]
}

By structuring the config file in this manner, it becomes flexible and adaptable to accommodate different project requirements and integrate with various external services or APIs.

Load Configuration Data Efficiently

The configuration data is structured as a JSON array of objects within the JSON file. This approach offers enhanced flexibility, as new components can be added without requiring any modifications to the program itself.

The config file becomes a scalable solution, enabling easy expansion and incorporation of additional components as needed. This design choice promotes agility and simplifies the process of adapting the program to evolving requirements without disrupting its core functionality.

The loadConfig() method serves the purpose of fetching the configuration data from the designated configuration file. It initiates by performing preliminary checks to determine whether the _configFile variable is empty. In which case, the program logs an error message and terminates promptly.

The method then parses the JSON content of the file, ensuring it is in the form of a JSON array of objects as emntioned in the configuration file structure section.

It iterates over each object within the array, validating their format and follow these important steps:

Iterates over the key-value pairs

For each object, loadConfig() further iterates over the key-value pairs within, processing them based on their value type.

1
2
3
4
5
6
7
8
9
10
11
12
for (const auto &object : root)
{
            if (!object.isObject())
            {
                throw std::runtime_error("Invalid format for object in configuration file.");
            }
            // iterates over the key-value pairs within
            for (auto const &key : object.getMemberNames())
            {
                // process key by value type.
            }
}

Process value by type

loadConfig() processes string and array values (specifically, an array of objects with string key-value pairs).

- If the value is an array

The code processes the data from the arrays and adds it to the corresponding config map. For example, in a Twitter class, a _configMap map stores Twitter API data, and in a TikTok class, another _configMap map stores TikTok API data. This improves efficiency and allows easy access to the necessary API information for each class.

Further, using a vector containing a list of target keys allows seamless iteration and efficient loading of the corresponding map.

1
const std::vector<std::string> _targetKeys = {"twitter", "tiktok", "instagram", "facebook"};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
for (const auto &key : object.getMemberNames())
{
    const auto &value = object[key];

    // Check if the key matches any target key in the vector
    if (std::find(targetKeys.begin(), targetKeys.end(), key) != targetKeys.end())
    {
        processTargetKeys(value, key);
    }
    else
    {
        // Process string and integer.
    }
}

The ProcessTargetKeys() method takes a configValue and a key as parameters. It processes the configuration values based on the specified target keys. It iterates through each element and further iterates through the key-value pairs.

The method handles specific target keys allowing efficient loading of data into the respective config map. If the value type is invalid, an exception is thrown. This method further enhances the flexibility and scalability of the code for handling different target keys and ensures proper configuration data management.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
void SomeClass::ProcessTargetKeys(const Json::Value &configValue, const std::string &key)
{
    if (configValue.isArray())
    {
        for (const auto &element : configValue)
        {
            for (const auto &subKey : element.getMemberNames())
            {
                const auto &subValue = element[subKey];

                if (subValue.isString())
                {
                    tiktok::TikTok tikTok;
                    twitter::Twitter twttr;
                    instagram::Instagram instgrm;

                    // Add data to the corresponding config map based on the key
                    switch (hashString(key.c_str()))
                    {
                    case hashString("Twitter"):
                    {
                        loadConfigMap(subKey, subValue.asString(), twttr.mapGetter());
                        break;
                    }
                    case hashString("TikTok"):
                    {
                        loadConfigMap(subKey, subValue.asString(), tikTok.mapGetter());
                        break;
                    }
                    case hashString("Instagram"):
                    {
                        loadConfigMap(subKey, subValue.asString(), instgrm.mapGetter());
                        break;
                    }
                    /// NOTE: Add more cases for other target keys as needed
                    default:
                    {
                        // Invalid key
                        throw std::runtime_error("Invalid key name in the configuration file.");
                    }
                    }
                }
                else
                {
                    // Invalid value type
                    spdlog::error("Invalid format for object: {} in the configuration file.", subValue.asString());
                    throw std::runtime_error("Invalid format for object in the configuration file.");
                }
            }
        }
    }
    // Add more conditions for other target keys as needed
    else
    {
        // Invalid value type
        throw std::runtime_error("Invalid format for object value in the configuration file.");
    }
}
If the value is a string

It converts numeric values to string and adds the key-value pair to a main _config map in a base class or main routine. Any invalid value types encountered result in appropriate error handling and exceptions being thrown.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if (value.isString())
{
    // Add data to main _config map.
    _config[key] = value.asString();
}
else if (value.isInt())
{
    // convert to string, add data to main _config map.
    _config[key] = std::to_string(value.asInt());
}
else
{
    // Invalid value type
    throw std::runtime_error("Invalid format for object value in configuration file.");
}

Here’s an example of a simple hashString function that utilizes std::hash from the <functional> header that can be used for string comparison:

1
2
3
4
constexpr std::size_t hashString(const char* str)
{
    return std::hash<const char*>{}(str);
}

remember to add #include <functional> to your header file.

Please note that this is a simple hash function for demonstration purposes. In a production environment, you may want to consider using a more robust hashing algorithm.

In case of any exceptions during the process, the method logs an error message indicating the issue encountered while reading the configuration file. At this point, the method can either return an error code or throw an exception, depending on the prefered error handling strategy.

Retrieve data from a map

The getFromConfigMap method provides a convenient way to read values from a map. It takes a key parameter indicating the desired configuration key to retrieve. The function returns the corresponding value from the config map or an error string if the key is not found.

Internally, the function attempts to retrieve the value using the std::unordered_map::at function, which throws a std::out_of_range exception if the key is not present in the map. In the catch block, an error message is logged, and an error string is constructed using the provided key. This error string is then returned.

The function utilizes a static error string to ensure the error message remains accessible even after the function’s scope ends. This prevents the caller from dealing with dangling references.

It enhances code readability and maintainability by encapsulating the logic for accessing a config map and handling missing key scenarios in a single function.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
    /// @brief Read from the config file
    /// @param key[in] The key to read from the config file.
    /// @return The value of the key or an error string.
    const std::string &SomeCLass::getFromConfigMap(const std::string &key)
    {
        static std::string errorString;

        try
        {
            return _config.at(key);
        }
        catch (const std::out_of_range &)
        {
            _logger->error("Error retrieving key: {} from config file.", key);
            static const std::string errorString = "Error retrieving " + key + " from config file";
            return errorString;
            exit(1);
        }
        return errorString;
    }

Conclusion

These practices leverage object-oriented programming (OOP) features like encapsulation, confidentiality and security. The encapsulation principle ensures that sensitive information, such as API keys and private configuration elements, is encapsulated within the appropriate classes, preventing direct access and inadvertent exposure.

Additionally, these techniques enhance the overall robustness and maintainability of the codebase. By encapsulating the configuration loading and retrieval logic within dedicated functions and classes, the codebase becomes more modular and organized. This improves code readability and makes it easier to maintain and update in the future.

Furthermore, the use of classes, along with proper data encapsulation and abstraction, allows for a clearer separation of concerns and promotes code reuse. This enhances the overall efficiency of configuration data loading and retrieval, enabling seamless integration with external services and APIs.

Overall, by leveraging OOP principles like encapsulation, the codebase becomes more secure, robust, and maintainable, providing a solid foundation for handling sensitive information and ensuring the smooth functioning of the system.

This post is licensed under CC BY 4.0 by the author.