diff --git a/CMakeLists.txt b/CMakeLists.txt index 88b515b..2530319 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,6 +12,7 @@ endif() find_package(ament_cmake REQUIRED) find_package(rosidl_default_generators REQUIRED) find_package(nlohmann_json REQUIRED) +find_package(nlohmann_json_schema_validator REQUIRED) # Generate VDA5050 ROS 2 messages set(msg_files @@ -31,6 +32,7 @@ install( ament_export_dependencies( rosidl_default_runtime nlohmann_json + nlohmann_json_schema_validator ) if(BUILD_TESTING) @@ -74,6 +76,22 @@ if(BUILD_TESTING) nlohmann_json::nlohmann_json ${cpp_typesupport_target} ) + + # TODO: (@shawnkchan) Remove this once we move validators to another repo + ament_add_gtest(${PROJECT_NAME}_validator_test + test/test_json_validators.cpp + + ) + target_include_directories(${PROJECT_NAME}_validator_test + PUBLIC + $ + $ + ) + target_link_libraries(${PROJECT_NAME}_validator_test + nlohmann_json::nlohmann_json + nlohmann_json_schema_validator + ${cpp_typesupport_target} + ) endif() ament_package() diff --git a/include/vda5050_msgs/json_utils/schemas.hpp b/include/vda5050_msgs/json_utils/schemas.hpp new file mode 100644 index 0000000..493c7ed --- /dev/null +++ b/include/vda5050_msgs/json_utils/schemas.hpp @@ -0,0 +1,63 @@ +#ifndef VDA5050_MSGS__JSON_UTILS__SCHEMAS_HPP_ +#define VDA5050_MSGS__JSON_UTILS__SCHEMAS_HPP_ + +#include + +/// \brief Schema of the VDA5050 Connection Object +inline constexpr auto connection_schema = R"( + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "connection", + "description": "The last will message of the AGV. Has to be sent with retain flag.\nOnce the AGV comes online, it has to send this message on its connect topic, with the connectionState enum set to \"ONLINE\".\n The last will message is to be configured with the connection state set to \"CONNECTIONBROKEN\".\nThus, if the AGV disconnects from the broker, master control gets notified via the topic \"connection\".\nIf the AGV is disconnecting in an orderly fashion (e.g. shutting down, sleeping), the AGV is to publish a message on this topic with the connectionState set to \"DISCONNECTED\".", + "subtopic": "/connection", + "type": "object", + "required": [ + "headerId", + "timestamp", + "version", + "manufacturer", + "serialNumber", + "connectionState" + ], + "properties": { + "headerId": { + "type": "integer", + "description": "Header ID of the message. The headerId is defined per topic and incremented by 1 with each sent (but not necessarily received) message." + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "Timestamp in ISO8601 format (YYYY-MM-DDTHH:mm:ss.ssZ).", + "examples": [ + "1991-03-11T11:40:03.12Z" + ] + }, + "version": { + "type": "string", + "description": "Version of the protocol [Major].[Minor].[Patch]", + "examples": [ + "1.3.2" + ] + }, + "manufacturer": { + "type": "string", + "description": "Manufacturer of the AGV." + }, + "serialNumber": { + "type": "string", + "description": "Serial number of the AGV." + }, + "connectionState": { + "type": "string", + "enum": [ + "ONLINE", + "OFFLINE", + "CONNECTIONBROKEN" + ], + "description": "ONLINE: connection between AGV and broker is active. OFFLINE: connection between AGV and broker has gone offline in a coordinated way. CONNECTIONBROKEN: The connection between AGV and broker has unexpectedly ended." + } + } + } +)"; + +#endif \ No newline at end of file diff --git a/include/vda5050_msgs/json_utils/validators.hpp b/include/vda5050_msgs/json_utils/validators.hpp new file mode 100644 index 0000000..4acbf97 --- /dev/null +++ b/include/vda5050_msgs/json_utils/validators.hpp @@ -0,0 +1,111 @@ +#ifndef VDA5050_MSGS__JSON_UTILS__VALIDATORS_HPP_ +#define VDA5050_MSGS__JSON_UTILS__VALIDATORS_HPP_ /// TODO: change header guard name when we separate this from the VDA5050 Messages package + +#include +#include +#include +#include + +#include "vda5050_msgs/json_utils/schemas.hpp" + +constexpr const char* ISO8601_FORMAT = "%Y-%m-%dT%H:%M:%S"; + +/// \brief Utility function to check that a given string is in ISO8601 format +/// +/// \param value The string to be checked +/// +/// \return True if the given string follows the format +bool is_in_ISO8601_format(const std::string& value) +{ + std::tm t = {}; + char sep; + int millisec = 0; + + std::istringstream ss(value); + + ss >> std::get_time(&t, ISO8601_FORMAT); + if (ss.fail()) + { + return false; + } + + ss >> sep; + if (ss.fail() || sep != '.') + { + return false; + } + + ss >> millisec; + if (ss.fail()) + { + return false; + } + if (!ss.eof()) + { + ss.ignore(std::numeric_limits::max(), 'Z'); + } + else + { + return false; + } + return true; +} + +/// TODO (@shawnkchan) This can probably be generalised for any other custom formats that we may need. Keeping it specific for now. +/// \brief Format checker for a date-time field +/// +/// \param format Name of the field whose format is to be checked +/// \param value Value associated with the given field +/// +/// \throw std::invalid_argument if the value in the date-time field does not follow ISO8601 format. +/// \throw std::logic_error if the format field is not "date-time". +static void date_time_format_checker( + const std::string& format, const std::string& value) +{ + if (format == "date-time") + { + if (!is_in_ISO8601_format(value)) + { + throw std::invalid_argument("Value is not in valid ISO8601 format"); + } + } + else + { + throw std::logic_error("Don't know how to validate " + format); + } +} + +/// \brief Checks that a JSON object is following the a given schema +/// +/// \param schema The schema to validate against, as an nlohmann::json object +/// \param j Reference to the nlohmann::json object to be validated +/// +/// \return true if schema is valid, false otherwise +bool is_valid_schema(nlohmann::json schema, nlohmann::json& j) +{ + nlohmann::json_schema::json_validator validator( + nullptr, date_time_format_checker); + + try + { + validator.set_root_schema(schema); + } + catch (const std::exception& e) + { + std::cerr << "Validation of schema failed: " << e.what() << "\n"; + return false; + } + + try + { + validator.validate(j); + } + catch (const std::exception& e) + { + std::cerr << e.what() << '\n'; + return false; + } + return true; +} + +#endif diff --git a/package.xml b/package.xml index 3bf6e8c..7aaf06d 100644 --- a/package.xml +++ b/package.xml @@ -11,6 +11,7 @@ rosidl_default_generators nlohmann-json-dev + nlohmann-json-schema-validator-dev rosidl_default_runtime diff --git a/test/generator/generator.hpp b/test/generator/generator.hpp index 4ec7b06..2499d55 100644 --- a/test/generator/generator.hpp +++ b/test/generator/generator.hpp @@ -62,6 +62,41 @@ class RandomDataGenerator return uint_dist_(rng_); } + /// \brief Generate a random 64-bit floating-point number + double generate_random_float() + { + return float_dist_(rng_); + } + + /// \brief Generate a random boolean value + bool generate_random_bool() + { + return bool_dist_(rng_); + } + + /// \brief Generate a random ISO8601 formatted timestamp + std::string generate_random_ISO8601_timestamp() + { + constexpr const char* ISO8601_FORMAT = "%Y-%m-%dT%H:%M:%S"; + + int64_t timestamp = generate_milliseconds(); + std::chrono::system_clock::time_point tp{std::chrono::milliseconds(timestamp)}; + std::time_t time_sec = std::chrono::system_clock::to_time_t(tp); + auto duration = tp.time_since_epoch(); + auto millisec = std::chrono::duration_cast(duration).count() % 1000; + + std::ostringstream oss; + oss << std::put_time(std::gmtime(&time_sec), ISO8601_FORMAT); + oss << "." << std::setw(3) << std::setfill('0') << millisec << "Z"; + + if (oss.fail()) + { + throw std::runtime_error("Failed to generate a random ISO8601 timestamp"); + } + + return oss.str(); + } + /// \brief Generate a random alphanumerical string with length upto 50 std::string generate_random_string() { @@ -97,6 +132,42 @@ class RandomDataGenerator return states[state_idx]; } + /// \brief Generate a random index for enum selection + uint8_t generate_random_index(size_t size) + { + std::uniform_int_distribution index_dist(0, size - 1); + return index_dist(rng_); + } + + /// \brief Generate a random vector of type float64 + std::vector generate_random_float_vector(const uint8_t size) + { + std::vector vec(size); + for (auto it = vec.begin(); it != vec.end(); ++it) + { + *it = generate_random_float(); + } + return vec; + } + + /// \brief Generate a random vector of type T + template + std::vector generate_random_vector(const uint8_t size) + { + std::vector vec(size); + for (auto it = vec.begin(); it != vec.end(); ++it) + { + *it = generate(); + } + return vec; + } + + /// \brief + uint8_t generate_random_size() + { + return size_dist_(rng_); + } + /// \brief Generate a fully populated message of a supported type template T generate() @@ -133,6 +204,13 @@ class RandomDataGenerator /// \brief Distribution for unsigned 32-bit integers std::uniform_int_distribution uint_dist_; + /// \brief Distribution for 64-bit floating-point numbers + std::uniform_real_distribution float_dist_; + + /// \brief Distribution for a boolean value + /// TODO (@shawnkchan): KIV should we be bounding this between 0 and 1? + std::uniform_int_distribution bool_dist_{0, 1}; + /// \brief Distribution for random string lengths std::uniform_int_distribution string_length_dist_; @@ -141,6 +219,12 @@ class RandomDataGenerator /// \brief Distribution for VDA 5050 connectionState std::uniform_int_distribution connection_state_dist_; + + /// \brief Distribution for random vector size + std::uniform_int_distribution size_dist_; + + /// \brief Upper bound for order.nodes and order.edges random vector; + uint8_t ORDER_VECTOR_SIZE_UPPER_BOUND = 10; }; #endif // TEST__GENERATOR__GENERATOR_HPP_ diff --git a/test/generator/jsonGenerator.hpp b/test/generator/jsonGenerator.hpp new file mode 100644 index 0000000..25f6d58 --- /dev/null +++ b/test/generator/jsonGenerator.hpp @@ -0,0 +1,71 @@ +#ifndef TEST__GENERATOR__JSON_GENERATOR_HPP_ +#define TEST__GENERATOR__JSON_GENERATOR_HPP_ + +#include + +#include "generator.hpp" + +/// \brief Utility class to generate random VDA5050 JSON objects +class RandomJSONgenerator +{ +public: + /// \brief Enum values for each VDA5050 JSON object + enum class JsonTypes + { + Connection, + Order, + InstantActions, + State, + Visualization, + Factsheet + }; + + /// \brief Generate a fully populated JSON object of a supported type + nlohmann::json generate(const JsonTypes type) + { + nlohmann::json j; + RandomDataGenerator generator; + + j["headerId"] = generator.generate_uint(); + j["timestamp"] = generator.generate_random_ISO8601_timestamp(); + j["version"] = generator.generate_random_string(); + j["manufacturer"] = generator.generate_random_string(); + j["serialNumber"] = generator.generate_random_string(); + + switch (type) + { + case JsonTypes::Connection: + /// create Connection JSON object + j["connectionState"] = generator.generate_connection_state(); + break; + + case JsonTypes::Order: + /// TODO: (@shawnkchan) complete this once random generator for Order message is completed + /// create Order JSON Object + break; + + case JsonTypes::InstantActions: + /// TODO: (@shawnkchan) complete this once random generator for InstantActions message is completed + /// create InstantActions JSON Object + break; + + case JsonTypes::State: + /// TODO: (@shawnkchan) complete this once random generator for State message is completed + /// create State object + break; + + case JsonTypes::Visualization: + /// TODO: (@shawnkchan) complete this once random generator for Visualization message is completed + /// create Visualization object + break; + + case JsonTypes::Factsheet: + /// TODO: (@shawnkchan) complete this once random generator for Factsheet message is completed + /// Factsheet + break; + } + return j; + } +}; + +#endif \ No newline at end of file diff --git a/test/test_json_validators.cpp b/test/test_json_validators.cpp new file mode 100644 index 0000000..17eb014 --- /dev/null +++ b/test/test_json_validators.cpp @@ -0,0 +1,35 @@ +#include +#include +#include + +#include "generator/jsonGenerator.hpp" +#include "vda5050_msgs/json_utils/schemas.hpp" +#include "vda5050_msgs/json_utils/validators.hpp" + +/// \brief Fixture class to create VDA5050 JSON objects for tests +class JsonValidatorTest : public ::testing::Test +{ +protected: + JsonValidatorTest() + { + connection_object = + json_generator.generate(RandomJSONgenerator::JsonTypes::Connection); + connection_schema = nlohmann::json::parse(connection_schema); + /// TODO: Instantiate other VDA5050 JSON objects + } + + RandomJSONgenerator json_generator; + nlohmann::json connection_object; + nlohmann::json connection_schema; + /// TODO: Declare other VDA5050 JSON objects +}; + +/// \brief Tests the is_valid_schema function to check that it passes when validating a correctly formatted JSON object +TEST_F(JsonValidatorTest, BasicValidationTest) +{ + /// TODO (@shawnkchan) Change this to a typed test so that we can iterate over the different VDA5050 object types + std::cout << "running test" << "\n"; + int connection_result = is_valid_schema(connection_schema, connection_object); + EXPECT_EQ(connection_result, true); + std::cout << "test ended" << "\n"; +}