diff --git a/docs/design/index.rst b/docs/design/index.rst index df2bd45eb..915d17f99 100644 --- a/docs/design/index.rst +++ b/docs/design/index.rst @@ -18,4 +18,5 @@ This section contains design documentation for the ros2_medkit project packages. ros2_medkit_msgs/index ros2_medkit_param_beacon/index ros2_medkit_serialization/index + ros2_medkit_sovd_service_interface/index ros2_medkit_topic_beacon/index diff --git a/docs/design/ros2_medkit_sovd_service_interface b/docs/design/ros2_medkit_sovd_service_interface new file mode 120000 index 000000000..f130faad0 --- /dev/null +++ b/docs/design/ros2_medkit_sovd_service_interface @@ -0,0 +1 @@ +../../src/ros2_medkit_plugins/ros2_medkit_sovd_service_interface/design \ No newline at end of file diff --git a/docs/tutorials/plugin-system.rst b/docs/tutorials/plugin-system.rst index 7d56440ef..fb262c891 100644 --- a/docs/tutorials/plugin-system.rst +++ b/docs/tutorials/plugin-system.rst @@ -20,6 +20,13 @@ Plugins implement the ``GatewayPlugin`` C++ base class plus one or more typed pr - **ScriptProvider** - replaces or augments the default filesystem-based script backend. Plugins can provide script listings, create custom scripts, and execute them using alternative runtimes. See the ``/scripts`` endpoints in :doc:`/api/rest`. +- **DataProvider** - per-entity data resource backend (list, read, write data). Plugins + that create entities via IntrospectionProvider can serve data for those entities. + Entity requests are routed to the owning plugin automatically. +- **OperationProvider** - per-entity operation backend (list operations, execute). Uses + the same per-entity routing model as DataProvider. +- **FaultProvider** - per-entity fault backend (list faults, get fault details, clear + faults). Uses the same per-entity routing model as DataProvider. A single plugin can implement multiple provider interfaces. For example, a "systemd" plugin could provide both introspection (discover systemd units) and updates (manage service restarts). @@ -224,7 +231,7 @@ Plugin Lifecycle 1. ``dlopen`` loads the ``.so`` with ``RTLD_NOW | RTLD_LOCAL`` 2. ``plugin_api_version()`` is checked against the gateway's ``PLUGIN_API_VERSION`` 3. ``create_plugin()`` factory function creates the plugin instance -4. Provider interfaces are queried via ``get_update_provider()`` / ``get_introspection_provider()`` / ``get_log_provider()`` / ``get_script_provider()`` +4. Provider interfaces are queried via ``get_update_provider()`` / ``get_introspection_provider()`` / ``get_log_provider()`` / ``get_script_provider()`` / ``get_data_provider()`` / ``get_operation_provider()`` / ``get_fault_provider()`` 5. ``configure()`` is called with per-plugin JSON config 6. ``set_context()`` provides ``PluginContext`` with ROS 2 node, entity cache, faults, and HTTP utilities 7. ``get_routes()`` returns custom REST endpoint definitions as ``vector`` @@ -665,8 +672,31 @@ Multiple plugins can be loaded simultaneously: - **LogProvider**: Only the first plugin's LogProvider is used for queries (same as UpdateProvider). All LogProvider plugins receive ``on_log_entry()`` calls as observers. - **ScriptProvider**: Only the first plugin's ScriptProvider is used (same as UpdateProvider). +- **DataProvider / OperationProvider / FaultProvider**: These use per-entity routing based + on entity ownership. Entities created by a plugin's IntrospectionProvider are automatically + routed to that same plugin's DataProvider, OperationProvider, and FaultProvider. Multiple + plugins can each serve different entities concurrently - there is no "first wins" conflict + because each plugin only handles requests for its own entities. - **Custom routes**: All plugins can register endpoints (use unique path prefixes) +Entity Ownership +~~~~~~~~~~~~~~~~ + +DataProvider, OperationProvider, and FaultProvider use an entity ownership model to +route requests to the correct plugin. + +- ``IntrospectionProvider::introspect()`` determines ownership: entities returned in a + plugin's ``IntrospectionResult::new_entities`` are owned by that plugin. +- Entity ownership is refreshed periodically during cache updates (each discovery cycle + re-evaluates ``introspect()`` results from all plugins). +- The gateway maintains an internal map from entity ID to the plugin that created it. +- When a data, operation, or fault request arrives for an entity, the handler looks up + the owning plugin and delegates to its corresponding provider. Entities not owned by + any plugin fall through to the default gateway behavior. + +This model allows multiple plugins to coexist without conflict - each plugin manages +its own entities independently. + Graph Provider Plugin (ros2_medkit_graph_provider) --------------------------------------------------- diff --git a/src/ros2_medkit_gateway/CMakeLists.txt b/src/ros2_medkit_gateway/CMakeLists.txt index 5a84a8ac0..036988aac 100644 --- a/src/ros2_medkit_gateway/CMakeLists.txt +++ b/src/ros2_medkit_gateway/CMakeLists.txt @@ -88,6 +88,7 @@ include_directories(include) # Gateway library (shared between executable and tests for coverage) add_library(gateway_lib STATIC src/config.cpp + src/entity_validation.cpp src/gateway_node.cpp src/data_access_manager.cpp src/type_introspection.cpp @@ -598,6 +599,10 @@ if(BUILD_TESTING) ament_add_gtest(test_plugin_manager test/test_plugin_manager.cpp) target_link_libraries(test_plugin_manager gateway_lib) + # Plugin entity routing tests + ament_add_gtest(test_plugin_entity_routing test/test_plugin_entity_routing.cpp) + target_link_libraries(test_plugin_entity_routing gateway_lib) + # Plugin HTTP types tests ament_add_gtest(test_plugin_http_types test/test_plugin_http_types.cpp) target_link_libraries(test_plugin_http_types gateway_lib) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/entity_validation.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/entity_validation.hpp new file mode 100644 index 000000000..73cca8b74 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/entity_validation.hpp @@ -0,0 +1,29 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include + +#include + +namespace ros2_medkit_gateway { + +/// Validate an entity ID against naming conventions. +/// Allow: alphanumeric (a-z, A-Z, 0-9), underscore (_), hyphen (-). +/// Max 256 characters. +/// @return void on success, error message string on failure. +tl::expected validate_entity_id(const std::string & entity_id); + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/error_codes.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/error_codes.hpp index 2e4322029..05d88144a 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/error_codes.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/error_codes.hpp @@ -140,6 +140,9 @@ constexpr const char * ERR_SCRIPT_NOT_RUNNING = "x-medkit-script-not-running"; constexpr const char * ERR_SCRIPT_CONCURRENCY_LIMIT = "x-medkit-concurrency-limit"; constexpr const char * ERR_SCRIPT_FILE_TOO_LARGE = "x-medkit-script-too-large"; +/// Plugin provider returned an error (used for DataProvider/OperationProvider/FaultProvider errors) +constexpr const char * ERR_PLUGIN_ERROR = "x-medkit-plugin-error"; + /** * @brief Check if an error code is a vendor-specific code * @param error_code Error code to check diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handler_context.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handler_context.hpp index afd3473e6..e0688c045 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handler_context.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handler_context.hpp @@ -76,6 +76,10 @@ struct EntityInfo { std::string peer_url; ///< Base URL of the peer (e.g., "http://localhost:8081") std::string peer_name; ///< Peer name for metadata (e.g., "subsystem_b") + // Plugin routing fields + bool is_plugin{false}; ///< True if entity is owned by a plugin + std::string plugin_name; ///< Plugin name (empty if not plugin-owned) + /** * @brief Get SovdEntityType equivalent */ diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/plugins/plugin_http_types.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/plugins/plugin_http_types.hpp index a6392ae67..643e43d42 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/plugins/plugin_http_types.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/plugins/plugin_http_types.hpp @@ -40,6 +40,9 @@ class PluginRequest { /// Request body (by reference - avoids copying large payloads). const std::string & body() const; + /// Get a query parameter value by name. Returns empty string if not present. + std::string query_param(const std::string & name) const; + private: const void * impl_; }; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/plugins/plugin_loader.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/plugins/plugin_loader.hpp index 059232c1a..88cd04bba 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/plugins/plugin_loader.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/plugins/plugin_loader.hpp @@ -27,6 +27,9 @@ class UpdateProvider; class IntrospectionProvider; class LogProvider; class ScriptProvider; +class DataProvider; +class FaultProvider; +class OperationProvider; /** * @brief Result of loading a gateway plugin. @@ -63,6 +66,16 @@ struct GatewayPluginLoadResult { /// Lifetime tied to plugin - do not use after plugin is destroyed. ScriptProvider * script_provider = nullptr; + /// Non-owning pointer to DataProvider interface (null if not provided). + /// Unlike LogProvider/ScriptProvider, multiple plugins can each provide data for different entities. + DataProvider * data_provider = nullptr; + + /// Non-owning pointer to OperationProvider interface (null if not provided). + OperationProvider * operation_provider = nullptr; + + /// Non-owning pointer to FaultProvider interface (null if not provided). + FaultProvider * fault_provider = nullptr; + /// Get the dlopen handle (for dlsym queries by PluginManager) void * dl_handle() const { return handle_; @@ -85,6 +98,9 @@ struct GatewayPluginLoadResult { * extern "C" IntrospectionProvider* get_introspection_provider(GatewayPlugin* plugin); * extern "C" LogProvider* get_log_provider(GatewayPlugin* plugin); * extern "C" ScriptProvider* get_script_provider(GatewayPlugin* plugin); + * extern "C" DataProvider* get_data_provider(GatewayPlugin* plugin); + * extern "C" OperationProvider* get_operation_provider(GatewayPlugin* plugin); + * extern "C" FaultProvider* get_fault_provider(GatewayPlugin* plugin); * * Path requirements: must be absolute, have .so extension, and resolve to a real file. */ diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/plugins/plugin_manager.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/plugins/plugin_manager.hpp index d1649e699..45c7bf221 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/plugins/plugin_manager.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/plugins/plugin_manager.hpp @@ -19,8 +19,11 @@ #include "ros2_medkit_gateway/plugins/plugin_context.hpp" #include "ros2_medkit_gateway/plugins/plugin_loader.hpp" #include "ros2_medkit_gateway/plugins/plugin_types.hpp" +#include "ros2_medkit_gateway/providers/data_provider.hpp" +#include "ros2_medkit_gateway/providers/fault_provider.hpp" #include "ros2_medkit_gateway/providers/introspection_provider.hpp" #include "ros2_medkit_gateway/providers/log_provider.hpp" +#include "ros2_medkit_gateway/providers/operation_provider.hpp" #include "ros2_medkit_gateway/providers/script_provider.hpp" #include "ros2_medkit_gateway/providers/update_provider.hpp" #include "ros2_medkit_gateway/resource_sampler.hpp" @@ -28,8 +31,10 @@ #include #include +#include #include #include +#include #include namespace httplib { @@ -165,6 +170,35 @@ class PluginManager { return context_; } + // ---- Entity ownership (per-entity provider routing) ---- + + /// Register entity ownership for a plugin. + /// Called after IntrospectionProvider::introspect() returns new entities. + /// Maps entity IDs to the plugin that created them, enabling per-entity + /// provider routing in handlers. + void register_entity_ownership(const std::string & plugin_name, const std::vector & entity_ids); + + /// Clear all entity ownership entries for a given plugin. + /// Called before re-registering during refresh to remove stale entries. + void clear_entity_ownership(const std::string & plugin_name); + + /// Get DataProvider for a specific entity (if plugin-owned) + /// @return Non-owning pointer, or nullptr if entity is not plugin-owned + /// or owning plugin doesn't implement DataProvider + DataProvider * get_data_provider_for_entity(const std::string & entity_id) const; + + /// Get OperationProvider for a specific entity (if plugin-owned) + /// @return Non-owning pointer, or nullptr if entity is not plugin-owned + /// or owning plugin doesn't implement OperationProvider + OperationProvider * get_operation_provider_for_entity(const std::string & entity_id) const; + + /// Get FaultProvider for a specific entity (if plugin-owned) + FaultProvider * get_fault_provider_for_entity(const std::string & entity_id) const; + + /// Check if an entity is owned by a plugin + /// @return Plugin name if owned, nullopt otherwise + std::optional get_entity_owner(const std::string & entity_id) const; + // ---- Info ---- bool has_plugins() const; std::vector plugin_names() const; @@ -179,6 +213,9 @@ class PluginManager { IntrospectionProvider * introspection_provider = nullptr; LogProvider * log_provider = nullptr; ScriptProvider * script_provider = nullptr; + DataProvider * data_provider = nullptr; + OperationProvider * operation_provider = nullptr; + FaultProvider * fault_provider = nullptr; }; /// Disable a plugin after a lifecycle error (nulls providers, resets plugin). @@ -192,6 +229,8 @@ class PluginManager { ScriptProvider * first_script_provider_ = nullptr; ResourceSamplerRegistry * sampler_registry_ = nullptr; TransportRegistry * transport_registry_ = nullptr; + /// Entity ID -> plugin name mapping (populated from IntrospectionProvider results) + std::unordered_map entity_ownership_; bool shutdown_called_ = false; mutable std::shared_mutex plugins_mutex_; }; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/providers/data_provider.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/providers/data_provider.hpp new file mode 100644 index 000000000..179fc8be9 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/providers/data_provider.hpp @@ -0,0 +1,84 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include + +namespace ros2_medkit_gateway { + +enum class DataProviderError { + EntityNotFound, + ResourceNotFound, + ReadOnly, + WriteOnly, + TransportError, + Timeout, + InvalidValue, + Internal +}; + +struct DataProviderErrorInfo { + DataProviderError code; + std::string message; + int http_status{500}; ///< Suggested HTTP status code +}; + +/** + * @brief Provider interface for entity data resources + * + * Typed provider interface for plugins that serve SOVD data resources + * (GET /{entity_type}/{id}/data, GET /{entity_type}/{id}/data/{name}). + * Unlike LogProvider/ScriptProvider (singletons), multiple DataProvider + * plugins can coexist - each handles its own set of entities. + * + * Entity ownership is determined by IntrospectionProvider: entities + * created by a plugin's introspect() are routed to that plugin's + * DataProvider. + * + * @par Thread safety + * All methods may be called from multiple HTTP handler threads concurrently. + * Implementations must provide their own synchronization. + * + * @see GatewayPlugin for the base class all plugins must also implement + * @see OperationProvider for the operations counterpart + */ +class DataProvider { + public: + virtual ~DataProvider() = default; + + /// List available data resources for an entity + /// @param entity_id SOVD entity ID (e.g., "openbsw_demo_ecu") + /// @return JSON with {"items": [...]} array of data resource descriptors + virtual tl::expected list_data(const std::string & entity_id) = 0; + + /// Read a specific data resource + /// @param entity_id SOVD entity ID + /// @param resource_name Data resource name (e.g., "hardcoded_data") + /// @return JSON response body for the data resource + virtual tl::expected read_data(const std::string & entity_id, + const std::string & resource_name) = 0; + + /// Write a data resource value + /// @param entity_id SOVD entity ID + /// @param resource_name Data resource name + /// @param value JSON value to write + /// @return JSON response body confirming the write + virtual tl::expected + write_data(const std::string & entity_id, const std::string & resource_name, const nlohmann::json & value) = 0; +}; + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/providers/fault_provider.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/providers/fault_provider.hpp new file mode 100644 index 000000000..e129e0038 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/providers/fault_provider.hpp @@ -0,0 +1,71 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include + +namespace ros2_medkit_gateway { + +enum class FaultProviderError { EntityNotFound, FaultNotFound, TransportError, Timeout, Internal }; + +struct FaultProviderErrorInfo { + FaultProviderError code; + std::string message; + int http_status{500}; ///< Suggested HTTP status code +}; + +/** + * @brief Provider interface for entity fault/DTC resources + * + * Typed provider interface for plugins that serve SOVD faults + * (GET /{entity_type}/{id}/faults, GET /{entity_type}/{id}/faults/{code}). + * Per-entity routing like DataProvider. + * + * Plugins implementing this interface query their backend (e.g., UDS + * ReadDTCInformation 0x19) on demand and return faults in SOVD format. + * + * @par Thread safety + * All methods may be called concurrently. Implementations must synchronize. + * + * @see GatewayPlugin for the base class all plugins must also implement + * @see DataProvider for the data counterpart + */ +class FaultProvider { + public: + virtual ~FaultProvider() = default; + + /// List faults for an entity + /// @param entity_id SOVD entity ID + /// @return JSON with {"items": [...]} array of fault descriptors + virtual tl::expected list_faults(const std::string & entity_id) = 0; + + /// Get a specific fault with environment data + /// @param entity_id SOVD entity ID + /// @param fault_code Fault code (e.g., DTC identifier) + /// @return JSON response with fault detail + environment data + virtual tl::expected get_fault(const std::string & entity_id, + const std::string & fault_code) = 0; + + /// Clear a fault + /// @param entity_id SOVD entity ID + /// @param fault_code Fault code to clear + /// @return JSON response confirming the clear + virtual tl::expected clear_fault(const std::string & entity_id, + const std::string & fault_code) = 0; +}; + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/providers/operation_provider.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/providers/operation_provider.hpp new file mode 100644 index 000000000..963be5af9 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/providers/operation_provider.hpp @@ -0,0 +1,71 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include + +namespace ros2_medkit_gateway { + +enum class OperationProviderError { + EntityNotFound, + OperationNotFound, + InvalidParameters, + TransportError, + Timeout, + Rejected, + Internal +}; + +struct OperationProviderErrorInfo { + OperationProviderError code; + std::string message; + int http_status{500}; ///< Suggested HTTP status code +}; + +/** + * @brief Provider interface for entity operations + * + * Typed provider interface for plugins that serve SOVD operations + * (GET /{entity_type}/{id}/operations, POST /{entity_type}/{id}/operations/{name}). + * Per-entity routing like DataProvider. + * + * @par Thread safety + * All methods may be called concurrently. Implementations must synchronize. + * + * @see GatewayPlugin for the base class all plugins must also implement + * @see DataProvider for the data counterpart + */ +class OperationProvider { + public: + virtual ~OperationProvider() = default; + + /// List available operations for an entity + /// @param entity_id SOVD entity ID + /// @return JSON with {"items": [...]} array of operation descriptors + virtual tl::expected list_operations(const std::string & entity_id) = 0; + + /// Execute an operation + /// @param entity_id SOVD entity ID + /// @param operation_name Operation name (e.g., "session_control") + /// @param parameters JSON parameters from request body + /// @return JSON response body with operation result + virtual tl::expected + execute_operation(const std::string & entity_id, const std::string & operation_name, + const nlohmann::json & parameters) = 0; +}; + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/discovery/layers/plugin_layer.cpp b/src/ros2_medkit_gateway/src/discovery/layers/plugin_layer.cpp index 72e4610b0..22c8fde59 100644 --- a/src/ros2_medkit_gateway/src/discovery/layers/plugin_layer.cpp +++ b/src/ros2_medkit_gateway/src/discovery/layers/plugin_layer.cpp @@ -69,6 +69,20 @@ LayerOutput PluginLayer::discover() { output.apps = std::move(result.new_entities.apps); output.functions = std::move(result.new_entities.functions); + // Tag all plugin-created entities with source="plugin" + for (auto & area : output.areas) { + area.source = "plugin"; + } + for (auto & comp : output.components) { + comp.source = "plugin"; + } + for (auto & app : output.apps) { + app.source = "plugin"; + } + for (auto & func : output.functions) { + func.source = "plugin"; + } + // Validate entity IDs from plugin validate_entities(output.areas, name_, logger_); validate_entities(output.components, name_, logger_); diff --git a/src/ros2_medkit_gateway/src/entity_validation.cpp b/src/ros2_medkit_gateway/src/entity_validation.cpp new file mode 100644 index 000000000..b026d6010 --- /dev/null +++ b/src/ros2_medkit_gateway/src/entity_validation.cpp @@ -0,0 +1,56 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ros2_medkit_gateway/entity_validation.hpp" + +#include +#include + +namespace ros2_medkit_gateway { + +tl::expected validate_entity_id(const std::string & entity_id) { + if (entity_id.empty()) { + return tl::unexpected("Entity ID cannot be empty"); + } + + if (entity_id.length() > 256) { + return tl::unexpected("Entity ID too long (max 256 characters)"); + } + + // Allow: alphanumeric (a-z, A-Z, 0-9), underscore (_), hyphen (-) + // Reject: forward slash (conflicts with URL routing), special characters, escape sequences + // Note: Hyphens are allowed in manifest entity IDs (e.g., "engine-ecu", "front-left-door") + for (char c : entity_id) { + bool is_alphanumeric = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'); + bool is_allowed_special = (c == '_' || c == '-'); + + if (!is_alphanumeric && !is_allowed_special) { + std::string char_repr; + if (c < 32 || c > 126) { + std::ostringstream oss; + oss << "0x" << std::hex << std::setfill('0') << std::setw(2) + << static_cast(static_cast(c)); + char_repr = oss.str(); + } else { + char_repr = std::string(1, c); + } + return tl::unexpected("Entity ID contains invalid character: '" + char_repr + + "'. Only alphanumeric, underscore and hyphen are allowed"); + } + } + + return {}; +} + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/gateway_node.cpp b/src/ros2_medkit_gateway/src/gateway_node.cpp index 823764185..9ac84af55 100644 --- a/src/ros2_medkit_gateway/src/gateway_node.cpp +++ b/src/ros2_medkit_gateway/src/gateway_node.cpp @@ -632,15 +632,37 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki plugin_mgr_->set_context(*plugin_ctx_); RCLCPP_INFO(get_logger(), "Loaded %zu plugin(s)", loaded); - // Register IntrospectionProvider plugins as pipeline layers (hybrid mode only) - if (discovery_mgr_->get_mode() == DiscoveryMode::HYBRID) { - auto providers = plugin_mgr_->get_named_introspection_providers(); - for (auto & [name, provider] : providers) { - discovery_mgr_->add_plugin_layer(name, provider); - } - if (!providers.empty()) { + // Register IntrospectionProvider plugins + auto introspection_providers = plugin_mgr_->get_named_introspection_providers(); + if (!introspection_providers.empty()) { + if (discovery_mgr_->get_mode() == DiscoveryMode::HYBRID) { + // Hybrid: add as pipeline layers for merge + for (auto & [name, provider] : introspection_providers) { + discovery_mgr_->add_plugin_layer(name, provider); + } discovery_mgr_->refresh_pipeline(); } + // Non-hybrid modes: plugin entities are injected during refresh_cache() + + // Register entity ownership for per-entity provider routing + for (auto & [name, provider] : introspection_providers) { + IntrospectionInput input; + auto result = provider->introspect(input); + std::vector entity_ids; + for (const auto & area : result.new_entities.areas) { + entity_ids.push_back(area.id); + } + for (const auto & comp : result.new_entities.components) { + entity_ids.push_back(comp.id); + } + for (const auto & app : result.new_entities.apps) { + entity_ids.push_back(app.id); + } + for (const auto & func : result.new_entities.functions) { + entity_ids.push_back(func.id); + } + plugin_mgr_->register_entity_ownership(name, entity_ids); + } } // Initialize log manager (subscribes to /rosout, delegates to plugin if available) @@ -1536,6 +1558,61 @@ void GatewayNode::refresh_cache() { aggregation_mgr_->update_routing_table(peer_routing_table); } + // Inject plugin entities for non-hybrid modes. + // In hybrid mode, plugin entities are merged via the pipeline (PluginLayer). + // In runtime_only/manifest_only modes, we append them directly. + if (discovery_mgr_->get_mode() != DiscoveryMode::HYBRID && plugin_mgr_ && plugin_mgr_->has_plugins()) { + auto providers = plugin_mgr_->get_named_introspection_providers(); + for (auto & [name, provider] : providers) { + IntrospectionInput input; + auto result = provider->introspect(input); + for (auto & area : result.new_entities.areas) { + area.source = "plugin"; + areas.push_back(std::move(area)); + } + for (auto & comp : result.new_entities.components) { + comp.source = "plugin"; + all_components.push_back(std::move(comp)); + } + for (auto & app : result.new_entities.apps) { + app.source = "plugin"; + apps.push_back(std::move(app)); + } + for (auto & func : result.new_entities.functions) { + func.source = "plugin"; + functions.push_back(std::move(func)); + } + } + } + + // Refresh entity ownership for per-entity provider routing. + // Re-register ownership each cycle to pick up dynamically discovered entities + // and remove stale entries for entities that are no longer reported. + if (plugin_mgr_ && plugin_mgr_->has_plugins()) { + auto providers = plugin_mgr_->get_named_introspection_providers(); + for (auto & [name, provider] : providers) { + IntrospectionInput input; + auto result = provider->introspect(input); + std::vector entity_ids; + entity_ids.reserve(result.new_entities.areas.size() + result.new_entities.components.size() + + result.new_entities.apps.size() + result.new_entities.functions.size()); + for (const auto & area : result.new_entities.areas) { + entity_ids.push_back(area.id); + } + for (const auto & comp : result.new_entities.components) { + entity_ids.push_back(comp.id); + } + for (const auto & app : result.new_entities.apps) { + entity_ids.push_back(app.id); + } + for (const auto & func : result.new_entities.functions) { + entity_ids.push_back(func.id); + } + plugin_mgr_->clear_entity_ownership(name); + plugin_mgr_->register_entity_ownership(name, entity_ids); + } + } + // Filter ROS 2 internal nodes (underscore prefix convention). // Controlled by discovery.runtime.filter_internal_nodes parameter (default: true). // Covers local heuristic apps (which bypass the merge pipeline orphan filter diff --git a/src/ros2_medkit_gateway/src/http/handlers/data_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/data_handlers.cpp index 1949f9de0..2da0af95f 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/data_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/data_handlers.cpp @@ -21,6 +21,8 @@ #include "ros2_medkit_gateway/http/error_codes.hpp" #include "ros2_medkit_gateway/http/http_utils.hpp" #include "ros2_medkit_gateway/http/x_medkit.hpp" +#include "ros2_medkit_gateway/plugins/plugin_manager.hpp" +#include "ros2_medkit_gateway/providers/data_provider.hpp" using json = nlohmann::json; @@ -44,6 +46,26 @@ void DataHandlers::handle_list_data(const httplib::Request & req, httplib::Respo } auto entity_info = *entity_opt; + // Delegate to plugin DataProvider if entity is plugin-owned + if (entity_info.is_plugin) { + auto * pmgr = ctx_.node()->get_plugin_manager(); + auto * data_prov = pmgr ? pmgr->get_data_provider_for_entity(entity_id) : nullptr; + if (data_prov) { + auto result = data_prov->list_data(entity_id); + if (result) { + HandlerContext::send_json(res, *result); + } else { + auto status = std::clamp(result.error().http_status, 400, 599); + auto msg = result.error().message.size() > 512 ? result.error().message.substr(0, 512) + "..." + : result.error().message; + HandlerContext::send_error(res, status, ERR_PLUGIN_ERROR, msg); + } + return; + } + HandlerContext::send_json(res, json{{"items", json::array()}}); + return; + } + // Use unified cache method to get aggregated data const auto & cache = ctx_.node()->get_thread_safe_cache(); auto aggregated = cache.get_entity_data(entity_id); @@ -127,6 +149,27 @@ void DataHandlers::handle_get_data_item(const httplib::Request & req, httplib::R return; // Response already sent (error or forwarded to peer) } + // Delegate to plugin DataProvider if entity is plugin-owned + if (entity_opt->is_plugin) { + auto * pmgr = ctx_.node()->get_plugin_manager(); + auto * data_prov = pmgr ? pmgr->get_data_provider_for_entity(entity_id) : nullptr; + if (data_prov) { + auto result = data_prov->read_data(entity_id, topic_name); + if (result) { + HandlerContext::send_json(res, *result); + } else { + auto status = std::clamp(result.error().http_status, 400, 599); + auto msg = result.error().message.size() > 512 ? result.error().message.substr(0, 512) + "..." + : result.error().message; + HandlerContext::send_error(res, status, ERR_PLUGIN_ERROR, msg); + } + return; + } + HandlerContext::send_error(res, 404, ERR_RESOURCE_NOT_FOUND, + "No data provider for plugin entity '" + entity_id + "'"); + return; + } + // Determine the full ROS topic path std::string full_topic_path; if (topic_name.empty() || topic_name[0] == '/') { @@ -208,11 +251,40 @@ void DataHandlers::handle_put_data_item(const httplib::Request & req, httplib::R return; // Response already sent (error or forwarded to peer) } - // Check lock access for data + // Check lock access for data (before plugin delegation - locks apply to all entities) if (ctx_.validate_lock_access(req, res, *entity_opt, "data")) { return; } + // Delegate to plugin DataProvider if entity is plugin-owned + if (entity_opt->is_plugin) { + auto * pmgr = ctx_.node()->get_plugin_manager(); + auto * data_prov = pmgr ? pmgr->get_data_provider_for_entity(entity_id) : nullptr; + if (data_prov) { + json value; + if (!req.body.empty()) { + value = json::parse(req.body, nullptr, false); + if (value.is_discarded()) { + HandlerContext::send_error(res, 400, ERR_INVALID_REQUEST, "Invalid JSON body"); + return; + } + } + auto result = data_prov->write_data(entity_id, topic_name, value); + if (result) { + HandlerContext::send_json(res, *result); + } else { + auto status = std::clamp(result.error().http_status, 400, 599); + auto msg = result.error().message.size() > 512 ? result.error().message.substr(0, 512) + "..." + : result.error().message; + HandlerContext::send_error(res, status, ERR_PLUGIN_ERROR, msg); + } + return; + } + HandlerContext::send_error(res, 404, ERR_RESOURCE_NOT_FOUND, + "No data provider for plugin entity '" + entity_id + "'"); + return; + } + // Parse request body json body; try { diff --git a/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp index 0b039adb1..5dd38a62e 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp @@ -22,6 +22,7 @@ #include "ros2_medkit_gateway/http/handlers/capability_builder.hpp" #include "ros2_medkit_gateway/http/http_utils.hpp" #include "ros2_medkit_gateway/http/x_medkit.hpp" +#include "ros2_medkit_gateway/plugins/plugin_manager.hpp" using json = nlohmann::json; @@ -30,18 +31,45 @@ namespace handlers { namespace { +/// Check if a capability name is already present in the capabilities array +bool has_capability(const json & capabilities, const std::string & name) { + for (const auto & cap : capabilities) { + if (cap.contains("name") && cap["name"] == name) { + return true; + } + } + return false; +} + /// Append plugin-registered capabilities to a capabilities JSON array void append_plugin_capabilities(json & capabilities, const std::string & entity_type_path, const std::string & entity_id, SovdEntityType entity_type, const GatewayNode * node) { auto * pmgr = node->get_plugin_manager(); - if (!pmgr || !pmgr->get_context()) { + if (!pmgr) { return; } - auto * ctx = pmgr->get_context(); + std::string href_prefix; href_prefix.reserve(64); href_prefix.append("/api/v1/").append(entity_type_path).append("/").append(entity_id).append("/"); + // Auto-add standard capabilities based on registered providers + if (pmgr->get_data_provider_for_entity(entity_id) && !has_capability(capabilities, "data")) { + capabilities.push_back({{"name", "data"}, {"href", href_prefix + "data"}}); + } + if (pmgr->get_operation_provider_for_entity(entity_id) && !has_capability(capabilities, "operations")) { + capabilities.push_back({{"name", "operations"}, {"href", href_prefix + "operations"}}); + } + if (pmgr->get_fault_provider_for_entity(entity_id) && !has_capability(capabilities, "faults")) { + capabilities.push_back({{"name", "faults"}, {"href", href_prefix + "faults"}}); + } + + // Plugin-registered custom capabilities (via PluginContext) + auto * ctx = pmgr->get_context(); + if (!ctx) { + return; + } + // Type-level capabilities (registered for all entities of this type) for (const auto & cap_name : ctx->get_type_capabilities(entity_type)) { capabilities.push_back({{"name", cap_name}, {"href", href_prefix + cap_name}}); diff --git a/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp index 5ed1baf99..9dc9e90cb 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp @@ -29,6 +29,8 @@ #include "ros2_medkit_gateway/http/error_codes.hpp" #include "ros2_medkit_gateway/http/http_utils.hpp" #include "ros2_medkit_gateway/http/x_medkit.hpp" +#include "ros2_medkit_gateway/plugins/plugin_manager.hpp" +#include "ros2_medkit_gateway/providers/fault_provider.hpp" using json = nlohmann::json; @@ -313,6 +315,26 @@ void FaultHandlers::handle_list_faults(const httplib::Request & req, httplib::Re } auto entity_info = *entity_opt; + // Delegate to plugin FaultProvider if entity is plugin-owned + if (entity_info.is_plugin) { + auto * pmgr = ctx_.node()->get_plugin_manager(); + auto * fault_prov = pmgr ? pmgr->get_fault_provider_for_entity(entity_id) : nullptr; + if (fault_prov) { + auto result = fault_prov->list_faults(entity_id); + if (result) { + HandlerContext::send_json(res, *result); + } else { + auto status = std::clamp(result.error().http_status, 400, 599); + auto msg = result.error().message.size() > 512 ? result.error().message.substr(0, 512) + "..." + : result.error().message; + HandlerContext::send_error(res, status, ERR_PLUGIN_ERROR, msg); + } + return; + } + HandlerContext::send_json(res, json{{"items", json::array()}}); + return; + } + // Validate entity type supports faults collection (SOVD Table 8) if (auto err = HandlerContext::validate_collection_access(entity_info, ResourceCollection::FAULTS)) { HandlerContext::send_error(res, 400, ERR_COLLECTION_NOT_SUPPORTED, *err); @@ -556,6 +578,27 @@ void FaultHandlers::handle_get_fault(const httplib::Request & req, httplib::Resp } auto entity_info = *entity_opt; + // Delegate to plugin FaultProvider if entity is plugin-owned + if (entity_info.is_plugin) { + auto * pmgr = ctx_.node()->get_plugin_manager(); + auto * fault_prov = pmgr ? pmgr->get_fault_provider_for_entity(entity_id) : nullptr; + if (fault_prov) { + auto result = fault_prov->get_fault(entity_id, fault_code); + if (result) { + HandlerContext::send_json(res, *result); + } else { + auto status = std::clamp(result.error().http_status, 400, 599); + auto msg = result.error().message.size() > 512 ? result.error().message.substr(0, 512) + "..." + : result.error().message; + HandlerContext::send_error(res, status, ERR_PLUGIN_ERROR, msg); + } + return; + } + HandlerContext::send_error(res, 404, ERR_RESOURCE_NOT_FOUND, + "No fault provider for plugin entity '" + entity_id + "'"); + return; + } + // Fault codes may contain dots and underscores, validate basic constraints if (fault_code.empty() || fault_code.length() > 256) { HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Invalid fault code", @@ -615,11 +658,32 @@ void FaultHandlers::handle_clear_fault(const httplib::Request & req, httplib::Re } auto entity_info = *entity_opt; - // Check lock access for faults + // Check lock access for faults (before plugin delegation - locks apply to all entities) if (ctx_.validate_lock_access(req, res, entity_info, "faults")) { return; } + // Delegate to plugin FaultProvider if entity is plugin-owned + if (entity_info.is_plugin) { + auto * pmgr = ctx_.node()->get_plugin_manager(); + auto * fault_prov = pmgr ? pmgr->get_fault_provider_for_entity(entity_id) : nullptr; + if (fault_prov) { + auto result = fault_prov->clear_fault(entity_id, fault_code); + if (result) { + HandlerContext::send_json(res, *result); + } else { + auto status = std::clamp(result.error().http_status, 400, 599); + auto msg = result.error().message.size() > 512 ? result.error().message.substr(0, 512) + "..." + : result.error().message; + HandlerContext::send_error(res, status, ERR_PLUGIN_ERROR, msg); + } + return; + } + HandlerContext::send_error(res, 404, ERR_RESOURCE_NOT_FOUND, + "No fault provider for plugin entity '" + entity_id + "'"); + return; + } + // Validate fault code if (fault_code.empty() || fault_code.length() > 256) { HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Invalid fault code", diff --git a/src/ros2_medkit_gateway/src/http/handlers/handler_context.cpp b/src/ros2_medkit_gateway/src/http/handlers/handler_context.cpp index c5c272ad0..7be2b5467 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/handler_context.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/handler_context.cpp @@ -15,6 +15,7 @@ #include "ros2_medkit_gateway/http/handlers/handler_context.hpp" #include "ros2_medkit_gateway/aggregation/aggregation_manager.hpp" +#include "ros2_medkit_gateway/entity_validation.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" #include "ros2_medkit_gateway/http/error_codes.hpp" #include "ros2_medkit_gateway/lock_manager.hpp" @@ -27,41 +28,7 @@ namespace ros2_medkit_gateway { namespace handlers { tl::expected HandlerContext::validate_entity_id(const std::string & entity_id) const { - // Check for empty string - if (entity_id.empty()) { - return tl::unexpected("Entity ID cannot be empty"); - } - - // Check length (reasonable limit to prevent abuse) - if (entity_id.length() > 256) { - return tl::unexpected("Entity ID too long (max 256 characters)"); - } - - // Validate characters according to naming conventions - // Allow: alphanumeric (a-z, A-Z, 0-9), underscore (_), hyphen (-) - // Reject: forward slash (conflicts with URL routing), special characters, escape sequences - // Note: Hyphens are allowed in manifest entity IDs (e.g., "engine-ecu", "front-left-door") - for (char c : entity_id) { - bool is_alphanumeric = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'); - bool is_allowed_special = (c == '_' || c == '-'); - - if (!is_alphanumeric && !is_allowed_special) { - // For non-printable characters, show the character code - std::string char_repr; - if (c < 32 || c > 126) { - std::ostringstream oss; - oss << "0x" << std::hex << std::setfill('0') << std::setw(2) - << static_cast(static_cast(c)); - char_repr = oss.str(); - } else { - char_repr = std::string(1, c); - } - return tl::unexpected("Entity ID contains invalid character: '" + char_repr + - "'. Only alphanumeric, underscore and hyphen are allowed"); - } - } - - return {}; + return ::ros2_medkit_gateway::validate_entity_id(entity_id); } tl::expected @@ -89,6 +56,17 @@ EntityInfo HandlerContext::get_entity_info(const std::string & entity_id, SovdEn ei.peer_url = aggregation_mgr_->get_peer_url(*peer); } } + // Check plugin ownership (only if not already a remote entity) + if (!ei.is_remote) { + auto * pmgr = node_->get_plugin_manager(); + if (pmgr) { + auto owner = pmgr->get_entity_owner(ei.id); + if (owner) { + ei.is_plugin = true; + ei.plugin_name = *owner; + } + } + } }; // If expected_type is specified, search ONLY in that collection diff --git a/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp index 6f08c2b26..be5697507 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp @@ -14,6 +14,7 @@ #include "ros2_medkit_gateway/http/handlers/operation_handlers.hpp" +#include #include #include "ros2_medkit_gateway/gateway_node.hpp" @@ -21,6 +22,8 @@ #include "ros2_medkit_gateway/http/http_utils.hpp" #include "ros2_medkit_gateway/http/x_medkit.hpp" #include "ros2_medkit_gateway/operation_manager.hpp" +#include "ros2_medkit_gateway/plugins/plugin_manager.hpp" +#include "ros2_medkit_gateway/providers/operation_provider.hpp" using json = nlohmann::json; @@ -44,6 +47,26 @@ void OperationHandlers::handle_list_operations(const httplib::Request & req, htt } auto entity_info = *entity_opt; + // Delegate to plugin OperationProvider if entity is plugin-owned + if (entity_info.is_plugin) { + auto * pmgr = ctx_.node()->get_plugin_manager(); + auto * op_prov = pmgr ? pmgr->get_operation_provider_for_entity(entity_id) : nullptr; + if (op_prov) { + auto result = op_prov->list_operations(entity_id); + if (result) { + HandlerContext::send_json(res, *result); + } else { + auto status = std::clamp(result.error().http_status, 400, 599); + auto msg = result.error().message.size() > 512 ? result.error().message.substr(0, 512) + "..." + : result.error().message; + HandlerContext::send_error(res, status, ERR_PLUGIN_ERROR, msg); + } + return; + } + HandlerContext::send_json(res, json{{"items", json::array()}}); + return; + } + // Use ThreadSafeEntityCache for O(1) entity lookup and aggregated operations const auto & cache = ctx_.node()->get_thread_safe_cache(); @@ -344,11 +367,40 @@ void OperationHandlers::handle_create_execution(const httplib::Request & req, ht } auto entity_info = *entity_opt; - // Check lock access for operations + // Check lock access for operations (before plugin delegation - locks apply to all entities) if (ctx_.validate_lock_access(req, res, entity_info, "operations")) { return; } + // Delegate to plugin OperationProvider if entity is plugin-owned + if (entity_info.is_plugin) { + auto * pmgr = ctx_.node()->get_plugin_manager(); + auto * op_prov = pmgr ? pmgr->get_operation_provider_for_entity(entity_id) : nullptr; + if (op_prov) { + json params = json::object(); + if (!req.body.empty()) { + params = json::parse(req.body, nullptr, false); + if (params.is_discarded()) { + HandlerContext::send_error(res, 400, ERR_INVALID_REQUEST, "Invalid JSON body"); + return; + } + } + auto result = op_prov->execute_operation(entity_id, operation_id, params); + if (result) { + HandlerContext::send_json(res, *result); + } else { + auto status = std::clamp(result.error().http_status, 400, 599); + auto msg = result.error().message.size() > 512 ? result.error().message.substr(0, 512) + "..." + : result.error().message; + HandlerContext::send_error(res, status, ERR_PLUGIN_ERROR, msg); + } + return; + } + HandlerContext::send_error(res, 404, ERR_OPERATION_NOT_FOUND, + "No operation provider for plugin entity '" + entity_id + "'"); + return; + } + // Parse request body json body = json::object(); if (!req.body.empty()) { diff --git a/src/ros2_medkit_gateway/src/plugins/plugin_context.cpp b/src/ros2_medkit_gateway/src/plugins/plugin_context.cpp index 90e236dd2..0a84bb7a1 100644 --- a/src/ros2_medkit_gateway/src/plugins/plugin_context.cpp +++ b/src/ros2_medkit_gateway/src/plugins/plugin_context.cpp @@ -18,6 +18,8 @@ #include #include +#include "ros2_medkit_gateway/entity_validation.hpp" + #include "ros2_medkit_gateway/condition_evaluator.hpp" #include "ros2_medkit_gateway/fault_manager.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" @@ -100,9 +102,9 @@ class GatewayPluginContext : public PluginContext { std::optional validate_entity_for_route(const PluginRequest & req, PluginResponse & res, const std::string & entity_id) const override { - // Validate entity ID format - if (entity_id.empty() || entity_id.size() > 256) { - res.send_error(400, ERR_INVALID_PARAMETER, "Invalid entity ID"); + // Validate entity ID format (shared validation with HandlerContext) + if (auto err = validate_entity_id(entity_id); !err) { + res.send_error(400, ERR_INVALID_PARAMETER, err.error()); return std::nullopt; } diff --git a/src/ros2_medkit_gateway/src/plugins/plugin_http_types.cpp b/src/ros2_medkit_gateway/src/plugins/plugin_http_types.cpp index a4310b653..addf02beb 100644 --- a/src/ros2_medkit_gateway/src/plugins/plugin_http_types.cpp +++ b/src/ros2_medkit_gateway/src/plugins/plugin_http_types.cpp @@ -46,6 +46,10 @@ const std::string & PluginRequest::body() const { return static_cast(impl_)->body; } +std::string PluginRequest::query_param(const std::string & name) const { + return static_cast(impl_)->get_param_value(name); +} + // --- PluginResponse --- PluginResponse::PluginResponse(void * impl) : impl_(impl) { diff --git a/src/ros2_medkit_gateway/src/plugins/plugin_loader.cpp b/src/ros2_medkit_gateway/src/plugins/plugin_loader.cpp index 56b324ba1..521471684 100644 --- a/src/ros2_medkit_gateway/src/plugins/plugin_loader.cpp +++ b/src/ros2_medkit_gateway/src/plugins/plugin_loader.cpp @@ -15,7 +15,10 @@ #include "ros2_medkit_gateway/plugins/plugin_loader.hpp" #include "ros2_medkit_gateway/plugins/plugin_types.hpp" +#include "ros2_medkit_gateway/providers/data_provider.hpp" +#include "ros2_medkit_gateway/providers/fault_provider.hpp" #include "ros2_medkit_gateway/providers/introspection_provider.hpp" +#include "ros2_medkit_gateway/providers/operation_provider.hpp" #include "ros2_medkit_gateway/providers/script_provider.hpp" #include "ros2_medkit_gateway/providers/update_provider.hpp" @@ -45,11 +48,17 @@ GatewayPluginLoadResult::GatewayPluginLoadResult(GatewayPluginLoadResult && othe , introspection_provider(other.introspection_provider) , log_provider(other.log_provider) , script_provider(other.script_provider) + , data_provider(other.data_provider) + , operation_provider(other.operation_provider) + , fault_provider(other.fault_provider) , handle_(other.handle_) { other.update_provider = nullptr; other.introspection_provider = nullptr; other.log_provider = nullptr; other.script_provider = nullptr; + other.data_provider = nullptr; + other.operation_provider = nullptr; + other.fault_provider = nullptr; other.handle_ = nullptr; } @@ -60,6 +69,9 @@ GatewayPluginLoadResult & GatewayPluginLoadResult::operator=(GatewayPluginLoadRe introspection_provider = nullptr; log_provider = nullptr; script_provider = nullptr; + data_provider = nullptr; + operation_provider = nullptr; + fault_provider = nullptr; plugin.reset(); if (handle_) { dlclose(handle_); @@ -71,12 +83,18 @@ GatewayPluginLoadResult & GatewayPluginLoadResult::operator=(GatewayPluginLoadRe introspection_provider = other.introspection_provider; log_provider = other.log_provider; script_provider = other.script_provider; + data_provider = other.data_provider; + operation_provider = other.operation_provider; + fault_provider = other.fault_provider; handle_ = other.handle_; other.update_provider = nullptr; other.introspection_provider = nullptr; other.log_provider = nullptr; other.script_provider = nullptr; + other.data_provider = nullptr; + other.operation_provider = nullptr; + other.fault_provider = nullptr; other.handle_ = nullptr; } return *this; @@ -227,6 +245,48 @@ tl::expected PluginLoader::load(const std: } } + using DataProviderFn = DataProvider * (*)(GatewayPlugin *); + auto data_fn = reinterpret_cast(dlsym(handle, "get_data_provider")); + if (data_fn) { + try { + result.data_provider = data_fn(raw_plugin); + } catch (const std::exception & e) { + RCLCPP_WARN(rclcpp::get_logger("plugin_loader"), "get_data_provider threw in %s: %s", plugin_path.c_str(), + e.what()); + } catch (...) { + RCLCPP_WARN(rclcpp::get_logger("plugin_loader"), "get_data_provider threw unknown exception in %s", + plugin_path.c_str()); + } + } + + using OperationProviderFn = OperationProvider * (*)(GatewayPlugin *); + auto operation_fn = reinterpret_cast(dlsym(handle, "get_operation_provider")); + if (operation_fn) { + try { + result.operation_provider = operation_fn(raw_plugin); + } catch (const std::exception & e) { + RCLCPP_WARN(rclcpp::get_logger("plugin_loader"), "get_operation_provider threw in %s: %s", plugin_path.c_str(), + e.what()); + } catch (...) { + RCLCPP_WARN(rclcpp::get_logger("plugin_loader"), "get_operation_provider threw unknown exception in %s", + plugin_path.c_str()); + } + } + + using FaultProviderFn = FaultProvider * (*)(GatewayPlugin *); + auto fault_fn = reinterpret_cast(dlsym(handle, "get_fault_provider")); + if (fault_fn) { + try { + result.fault_provider = fault_fn(raw_plugin); + } catch (const std::exception & e) { + RCLCPP_WARN(rclcpp::get_logger("plugin_loader"), "get_fault_provider threw in %s: %s", plugin_path.c_str(), + e.what()); + } catch (...) { + RCLCPP_WARN(rclcpp::get_logger("plugin_loader"), "get_fault_provider threw unknown exception in %s", + plugin_path.c_str()); + } + } + // Transfer handle ownership to result (disarm scope guard) result.handle_ = handle_guard.release(); return result; diff --git a/src/ros2_medkit_gateway/src/plugins/plugin_manager.cpp b/src/ros2_medkit_gateway/src/plugins/plugin_manager.cpp index 0192f8159..9d4ec49ef 100644 --- a/src/ros2_medkit_gateway/src/plugins/plugin_manager.cpp +++ b/src/ros2_medkit_gateway/src/plugins/plugin_manager.cpp @@ -19,6 +19,7 @@ #include +#include "ros2_medkit_gateway/http/error_codes.hpp" #include "ros2_medkit_gateway/plugins/plugin_http_types.hpp" namespace ros2_medkit_gateway { @@ -62,6 +63,9 @@ void PluginManager::add_plugin(std::unique_ptr plugin) { lp.introspection_provider = dynamic_cast(plugin.get()); lp.log_provider = dynamic_cast(plugin.get()); lp.script_provider = dynamic_cast(plugin.get()); + lp.data_provider = dynamic_cast(plugin.get()); + lp.operation_provider = dynamic_cast(plugin.get()); + lp.fault_provider = dynamic_cast(plugin.get()); // Cache first UpdateProvider, warn on duplicates if (lp.update_provider) { @@ -112,6 +116,9 @@ size_t PluginManager::load_plugins(const std::vector & configs) { // IntrospectionProvider mechanism - safe across the dlopen boundary). lp.log_provider = result->log_provider; lp.script_provider = result->script_provider; + lp.data_provider = result->data_provider; + lp.operation_provider = result->operation_provider; + lp.fault_provider = result->fault_provider; // Cache first UpdateProvider, warn on duplicates if (lp.update_provider) { @@ -196,10 +203,16 @@ void PluginManager::disable_plugin(LoadedPlugin & lp) { lp.introspection_provider = nullptr; lp.log_provider = nullptr; lp.script_provider = nullptr; + lp.data_provider = nullptr; + lp.operation_provider = nullptr; + lp.fault_provider = nullptr; lp.load_result.update_provider = nullptr; lp.load_result.introspection_provider = nullptr; lp.load_result.log_provider = nullptr; lp.load_result.script_provider = nullptr; + lp.load_result.data_provider = nullptr; + lp.load_result.operation_provider = nullptr; + lp.load_result.fault_provider = nullptr; lp.load_result.plugin.reset(); } @@ -285,12 +298,12 @@ void PluginManager::register_routes(httplib::Server & server, const std::string RCLCPP_ERROR(rclcpp::get_logger("plugin_manager"), "Plugin '%s' handler threw on %s: %s", plugin_name.c_str(), full_pattern.c_str(), e.what()); PluginResponse plugin_res(&res); - plugin_res.send_error(500, "x-medkit-plugin-error", "Internal plugin error"); + plugin_res.send_error(500, ERR_PLUGIN_ERROR, "Internal plugin error"); } catch (...) { RCLCPP_ERROR(rclcpp::get_logger("plugin_manager"), "Plugin '%s' handler threw unknown exception on %s", plugin_name.c_str(), full_pattern.c_str()); PluginResponse plugin_res(&res); - plugin_res.send_error(500, "x-medkit-plugin-error", "Internal plugin error"); + plugin_res.send_error(500, ERR_PLUGIN_ERROR, "Internal plugin error"); } }; @@ -415,6 +428,81 @@ std::vector PluginManager::plugin_names() const { return names; } +void PluginManager::register_entity_ownership(const std::string & plugin_name, + const std::vector & entity_ids) { + std::unique_lock lock(plugins_mutex_); + for (const auto & eid : entity_ids) { + auto it = entity_ownership_.find(eid); + if (it != entity_ownership_.end() && it->second != plugin_name) { + RCLCPP_WARN(logger(), "Entity '%s' ownership transferred from plugin '%s' to '%s'", eid.c_str(), + it->second.c_str(), plugin_name.c_str()); + } + entity_ownership_[eid] = plugin_name; + } +} + +void PluginManager::clear_entity_ownership(const std::string & plugin_name) { + std::unique_lock lock(plugins_mutex_); + for (auto it = entity_ownership_.begin(); it != entity_ownership_.end();) { + if (it->second == plugin_name) { + it = entity_ownership_.erase(it); + } else { + ++it; + } + } +} + +DataProvider * PluginManager::get_data_provider_for_entity(const std::string & entity_id) const { + std::shared_lock lock(plugins_mutex_); + auto own_it = entity_ownership_.find(entity_id); + if (own_it == entity_ownership_.end()) { + return nullptr; + } + for (const auto & lp : plugins_) { + if (lp.load_result.plugin && lp.load_result.plugin->name() == own_it->second) { + return lp.data_provider; + } + } + return nullptr; +} + +OperationProvider * PluginManager::get_operation_provider_for_entity(const std::string & entity_id) const { + std::shared_lock lock(plugins_mutex_); + auto own_it = entity_ownership_.find(entity_id); + if (own_it == entity_ownership_.end()) { + return nullptr; + } + for (const auto & lp : plugins_) { + if (lp.load_result.plugin && lp.load_result.plugin->name() == own_it->second) { + return lp.operation_provider; + } + } + return nullptr; +} + +FaultProvider * PluginManager::get_fault_provider_for_entity(const std::string & entity_id) const { + std::shared_lock lock(plugins_mutex_); + auto own_it = entity_ownership_.find(entity_id); + if (own_it == entity_ownership_.end()) { + return nullptr; + } + for (const auto & lp : plugins_) { + if (lp.load_result.plugin && lp.load_result.plugin->name() == own_it->second) { + return lp.fault_provider; + } + } + return nullptr; +} + +std::optional PluginManager::get_entity_owner(const std::string & entity_id) const { + std::shared_lock lock(plugins_mutex_); + auto it = entity_ownership_.find(entity_id); + if (it != entity_ownership_.end()) { + return it->second; + } + return std::nullopt; +} + std::vector PluginManager::collect_route_descriptions() const { std::vector all_descriptions; std::shared_lock lock(plugins_mutex_); diff --git a/src/ros2_medkit_gateway/test/test_plugin_config.cpp b/src/ros2_medkit_gateway/test/test_plugin_config.cpp index 861ca4d16..294af568f 100644 --- a/src/ros2_medkit_gateway/test/test_plugin_config.cpp +++ b/src/ros2_medkit_gateway/test/test_plugin_config.cpp @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -// @verifies REQ_INTEROP_098 /// Tests that plugin config from --params-file YAML reaches the gateway plugin framework. /// /// The bug: extract_plugin_config() read from get_node_options().parameter_overrides(), diff --git a/src/ros2_medkit_gateway/test/test_plugin_entity_routing.cpp b/src/ros2_medkit_gateway/test/test_plugin_entity_routing.cpp new file mode 100644 index 000000000..e1e72bb1c --- /dev/null +++ b/src/ros2_medkit_gateway/test/test_plugin_entity_routing.cpp @@ -0,0 +1,349 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include + +#include "ros2_medkit_gateway/plugins/plugin_manager.hpp" +#include "ros2_medkit_gateway/providers/data_provider.hpp" +#include "ros2_medkit_gateway/providers/fault_provider.hpp" +#include "ros2_medkit_gateway/providers/operation_provider.hpp" + +using namespace ros2_medkit_gateway; +using json = nlohmann::json; + +// --- Mock plugin implementing DataProvider + OperationProvider --- + +class MockDataOpPlugin : public GatewayPlugin, public DataProvider, public OperationProvider { + public: + std::string name() const override { + return name_; + } + void configure(const json &) override { + } + void shutdown() override { + } + + // DataProvider + tl::expected list_data(const std::string & entity_id) override { + return json{{"items", json::array({{{"id", "test_data"}, {"entity", entity_id}}})}}; + } + tl::expected read_data(const std::string &, const std::string & resource) override { + return json{{"value", resource}}; + } + tl::expected write_data(const std::string &, const std::string &, + const json &) override { + return json{{"status", "ok"}}; + } + + // OperationProvider + tl::expected list_operations(const std::string & entity_id) override { + return json{{"items", json::array({{{"id", "test_op"}, {"entity", entity_id}}})}}; + } + tl::expected execute_operation(const std::string &, const std::string & op, + const json &) override { + return json{{"executed", op}}; + } + + std::string name_ = "test_plugin"; +}; + +// --- Mock plugin without providers --- + +class MockBarePlugin : public GatewayPlugin { + public: + std::string name() const override { + return "bare_plugin"; + } + void configure(const json &) override { + } + void shutdown() override { + } +}; + +// ============================================================================= +// Entity Ownership Tests +// ============================================================================= + +TEST(PluginEntityRouting, UnknownEntityReturnsNullopt) { + PluginManager mgr; + EXPECT_FALSE(mgr.get_entity_owner("nonexistent").has_value()); + EXPECT_EQ(mgr.get_data_provider_for_entity("nonexistent"), nullptr); + EXPECT_EQ(mgr.get_operation_provider_for_entity("nonexistent"), nullptr); +} + +TEST(PluginEntityRouting, RegisteredEntityReturnsOwner) { + PluginManager mgr; + mgr.register_entity_ownership("uds_gateway", {"ecu1", "ecu2"}); + + auto owner1 = mgr.get_entity_owner("ecu1"); + ASSERT_TRUE(owner1.has_value()); + EXPECT_EQ(*owner1, "uds_gateway"); + + auto owner2 = mgr.get_entity_owner("ecu2"); + ASSERT_TRUE(owner2.has_value()); + EXPECT_EQ(*owner2, "uds_gateway"); + + EXPECT_FALSE(mgr.get_entity_owner("ecu3").has_value()); +} + +TEST(PluginEntityRouting, MultiplePluginsOwnDifferentEntities) { + PluginManager mgr; + mgr.register_entity_ownership("uds_gateway", {"ecu1"}); + mgr.register_entity_ownership("opcua_gateway", {"plc1"}); + + EXPECT_EQ(*mgr.get_entity_owner("ecu1"), "uds_gateway"); + EXPECT_EQ(*mgr.get_entity_owner("plc1"), "opcua_gateway"); +} + +// ============================================================================= +// Provider Routing Tests (with in-process plugins via add_plugin) +// ============================================================================= + +TEST(PluginEntityRouting, DataProviderResolvedForOwnedEntity) { + PluginManager mgr; + + auto plugin = std::make_unique(); + auto * raw = plugin.get(); + mgr.add_plugin(std::move(plugin)); + + // Register ownership + mgr.register_entity_ownership("test_plugin", {"my_ecu"}); + + // Should resolve to the plugin's DataProvider + auto * dp = mgr.get_data_provider_for_entity("my_ecu"); + ASSERT_NE(dp, nullptr); + EXPECT_EQ(dp, static_cast(raw)); + + // Verify it actually works + auto result = dp->list_data("my_ecu"); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ((*result)["items"][0]["entity"], "my_ecu"); +} + +TEST(PluginEntityRouting, OperationProviderResolvedForOwnedEntity) { + PluginManager mgr; + + auto plugin = std::make_unique(); + auto * raw = plugin.get(); + mgr.add_plugin(std::move(plugin)); + + mgr.register_entity_ownership("test_plugin", {"my_ecu"}); + + auto * op = mgr.get_operation_provider_for_entity("my_ecu"); + ASSERT_NE(op, nullptr); + EXPECT_EQ(op, static_cast(raw)); + + auto result = op->execute_operation("my_ecu", "reset", json::object()); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ((*result)["executed"], "reset"); +} + +TEST(PluginEntityRouting, BarePluginReturnsNullProviders) { + PluginManager mgr; + + auto plugin = std::make_unique(); + mgr.add_plugin(std::move(plugin)); + + mgr.register_entity_ownership("bare_plugin", {"entity1"}); + + // Entity is owned, but plugin doesn't implement providers + auto owner = mgr.get_entity_owner("entity1"); + ASSERT_TRUE(owner.has_value()); + EXPECT_EQ(*owner, "bare_plugin"); + + EXPECT_EQ(mgr.get_data_provider_for_entity("entity1"), nullptr); + EXPECT_EQ(mgr.get_operation_provider_for_entity("entity1"), nullptr); +} + +TEST(PluginEntityRouting, UnownedEntityReturnsNullProviders) { + PluginManager mgr; + + auto plugin = std::make_unique(); + mgr.add_plugin(std::move(plugin)); + + // Don't register ownership - entity is not owned by any plugin + EXPECT_EQ(mgr.get_data_provider_for_entity("unowned"), nullptr); + EXPECT_EQ(mgr.get_operation_provider_for_entity("unowned"), nullptr); +} + +// ============================================================================= +// FaultProvider Routing Tests +// ============================================================================= + +class MockFaultPlugin : public GatewayPlugin, public FaultProvider { + public: + std::string name() const override { + return "fault_plugin"; + } + void configure(const json &) override { + } + void shutdown() override { + } + + tl::expected list_faults(const std::string & entity_id) override { + return json{{"items", json::array({{{"code", "DTC_001"}, {"entity", entity_id}}})}}; + } + tl::expected get_fault(const std::string &, const std::string & code) override { + return json{{"code", code}, {"status", "pending"}}; + } + tl::expected clear_fault(const std::string &, const std::string & code) override { + return json{{"code", code}, {"cleared", true}}; + } +}; + +TEST(PluginEntityRouting, FaultProviderResolvedForOwnedEntity) { + PluginManager mgr; + + auto plugin = std::make_unique(); + auto * raw = plugin.get(); + mgr.add_plugin(std::move(plugin)); + + mgr.register_entity_ownership("fault_plugin", {"my_ecu"}); + + auto * fp = mgr.get_fault_provider_for_entity("my_ecu"); + ASSERT_NE(fp, nullptr); + EXPECT_EQ(fp, static_cast(raw)); + + auto result = fp->list_faults("my_ecu"); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ((*result)["items"][0]["code"], "DTC_001"); +} + +TEST(PluginEntityRouting, BarePluginReturnsNullFaultProvider) { + PluginManager mgr; + + auto plugin = std::make_unique(); + mgr.add_plugin(std::move(plugin)); + + mgr.register_entity_ownership("bare_plugin", {"entity1"}); + EXPECT_EQ(mgr.get_fault_provider_for_entity("entity1"), nullptr); +} + +TEST(PluginEntityRouting, UnownedEntityReturnsNullFaultProvider) { + PluginManager mgr; + + auto plugin = std::make_unique(); + mgr.add_plugin(std::move(plugin)); + + EXPECT_EQ(mgr.get_fault_provider_for_entity("unowned"), nullptr); +} + +// ============================================================================= +// Error Propagation Tests +// ============================================================================= + +class MockErrorPlugin : public GatewayPlugin, public DataProvider, public FaultProvider { + public: + std::string name() const override { + return "error_plugin"; + } + void configure(const json &) override { + } + void shutdown() override { + } + + tl::expected list_data(const std::string &) override { + return tl::make_unexpected(DataProviderErrorInfo{DataProviderError::TransportError, "backend unavailable", 503}); + } + tl::expected read_data(const std::string &, const std::string &) override { + return tl::make_unexpected(DataProviderErrorInfo{DataProviderError::ResourceNotFound, "no such resource", 404}); + } + tl::expected write_data(const std::string &, const std::string &, + const json &) override { + return tl::make_unexpected(DataProviderErrorInfo{DataProviderError::ReadOnly, "read-only entity", 403}); + } + + tl::expected list_faults(const std::string &) override { + return tl::make_unexpected(FaultProviderErrorInfo{FaultProviderError::TransportError, "not reachable", 503}); + } + tl::expected get_fault(const std::string &, const std::string &) override { + return tl::make_unexpected(FaultProviderErrorInfo{FaultProviderError::FaultNotFound, "unknown fault", 404}); + } + tl::expected clear_fault(const std::string &, const std::string &) override { + return tl::make_unexpected(FaultProviderErrorInfo{FaultProviderError::Internal, "cannot clear", 409}); + } +}; + +TEST(PluginEntityRouting, DataProviderErrorPropagation) { + PluginManager mgr; + + auto plugin = std::make_unique(); + mgr.add_plugin(std::move(plugin)); + mgr.register_entity_ownership("error_plugin", {"bad_ecu"}); + + auto * dp = mgr.get_data_provider_for_entity("bad_ecu"); + ASSERT_NE(dp, nullptr); + + auto result = dp->list_data("bad_ecu"); + ASSERT_FALSE(result.has_value()); + EXPECT_EQ(result.error().http_status, 503); + EXPECT_EQ(result.error().message, "backend unavailable"); + EXPECT_EQ(result.error().code, DataProviderError::TransportError); +} + +TEST(PluginEntityRouting, FaultProviderErrorPropagation) { + PluginManager mgr; + + auto plugin = std::make_unique(); + mgr.add_plugin(std::move(plugin)); + mgr.register_entity_ownership("error_plugin", {"bad_ecu"}); + + auto * fp = mgr.get_fault_provider_for_entity("bad_ecu"); + ASSERT_NE(fp, nullptr); + + auto result = fp->get_fault("bad_ecu", "DTC_999"); + ASSERT_FALSE(result.has_value()); + EXPECT_EQ(result.error().http_status, 404); + EXPECT_EQ(result.error().message, "unknown fault"); +} + +// ============================================================================= +// Entity Ownership Conflict Tests +// ============================================================================= + +TEST(PluginEntityRouting, OwnershipConflictLastWins) { + PluginManager mgr; + mgr.register_entity_ownership("plugin_a", {"shared_entity"}); + mgr.register_entity_ownership("plugin_b", {"shared_entity"}); + + auto owner = mgr.get_entity_owner("shared_entity"); + ASSERT_TRUE(owner.has_value()); + EXPECT_EQ(*owner, "plugin_b"); +} + +TEST(PluginEntityRouting, ClearEntityOwnership) { + PluginManager mgr; + mgr.register_entity_ownership("plugin_a", {"ent1", "ent2"}); + mgr.register_entity_ownership("plugin_b", {"ent3"}); + + mgr.clear_entity_ownership("plugin_a"); + + EXPECT_FALSE(mgr.get_entity_owner("ent1").has_value()); + EXPECT_FALSE(mgr.get_entity_owner("ent2").has_value()); + // plugin_b's entity should be unaffected + EXPECT_EQ(*mgr.get_entity_owner("ent3"), "plugin_b"); +} + +TEST(PluginEntityRouting, ClearAndReregisterOwnership) { + PluginManager mgr; + mgr.register_entity_ownership("plugin_a", {"ent1", "ent2"}); + + // Simulate refresh: entity2 disappeared, entity3 appeared + mgr.clear_entity_ownership("plugin_a"); + mgr.register_entity_ownership("plugin_a", {"ent1", "ent3"}); + + EXPECT_TRUE(mgr.get_entity_owner("ent1").has_value()); + EXPECT_FALSE(mgr.get_entity_owner("ent2").has_value()); // removed + EXPECT_TRUE(mgr.get_entity_owner("ent3").has_value()); // added +} diff --git a/src/ros2_medkit_gateway/test/test_plugin_http_types.cpp b/src/ros2_medkit_gateway/test/test_plugin_http_types.cpp index afcb587ed..930f9159b 100644 --- a/src/ros2_medkit_gateway/test/test_plugin_http_types.cpp +++ b/src/ros2_medkit_gateway/test/test_plugin_http_types.cpp @@ -63,6 +63,17 @@ TEST(PluginRequestTest, Body) { EXPECT_EQ(preq.body(), R"({"key": "value"})"); } +TEST(PluginRequestTest, QueryParam) { + httplib::Request req; + req.params.emplace("filter", "active"); + req.params.emplace("limit", "10"); + + PluginRequest preq(&req); + EXPECT_EQ(preq.query_param("filter"), "active"); + EXPECT_EQ(preq.query_param("limit"), "10"); + EXPECT_EQ(preq.query_param("nonexistent"), ""); +} + TEST(PluginResponseTest, SendJson) { httplib::Response res; diff --git a/src/ros2_medkit_msgs/design/index.rst b/src/ros2_medkit_msgs/design/index.rst index f2997615f..7b5cd2e64 100644 --- a/src/ros2_medkit_msgs/design/index.rst +++ b/src/ros2_medkit_msgs/design/index.rst @@ -35,6 +35,8 @@ Message Definitions - Muted fault tracking (code, entity, mute reason, expiry) * - ``MedkitDiscoveryHint.msg`` - Beacon discovery hint published by nodes for the topic beacon plugin + * - ``EntityInfo.msg`` + - SOVD entity representation for service interface: ID, type, capabilities, metadata Service Definitions ------------------- @@ -61,6 +63,12 @@ Service Definitions - Retrieve rosbag file path for download * - ``ListRosbags.srv`` - List all available rosbag snapshots + * - ``ListEntities.srv`` + - List entities with optional type filtering (for SOVD service interface) + * - ``GetEntityData.srv`` + - Get data items for a specific entity (for SOVD service interface) + * - ``GetCapabilities.srv`` + - Get capabilities/resource collections for a specific entity (for SOVD service interface) Design Decisions ---------------- diff --git a/src/ros2_medkit_plugins/ros2_medkit_sovd_service_interface/CMakeLists.txt b/src/ros2_medkit_plugins/ros2_medkit_sovd_service_interface/CMakeLists.txt index 6690f38ad..df09dc63d 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_sovd_service_interface/CMakeLists.txt +++ b/src/ros2_medkit_plugins/ros2_medkit_sovd_service_interface/CMakeLists.txt @@ -31,16 +31,6 @@ find_package(ros2_medkit_gateway REQUIRED) find_package(ros2_medkit_msgs REQUIRED) find_package(rclcpp REQUIRED) find_package(nlohmann_json REQUIRED) -find_package(OpenSSL REQUIRED) - -# cpp-httplib via multi-distro compatibility macro. -# On Humble the system package is too old (0.10.x); fall back to gateway's -# vendored copy which is installed alongside the gateway package. -set(_gw_vendored "${ros2_medkit_gateway_DIR}/../vendored/cpp_httplib") -medkit_find_cpp_httplib(VENDORED_DIR "${_gw_vendored}") - -# Enable OpenSSL support for cpp-httplib -add_compile_definitions(CPPHTTPLIB_OPENSSL_SUPPORT) # MODULE target: loaded via dlopen at runtime by PluginManager. # Symbols from gateway_lib are resolved from the host process at runtime. @@ -66,8 +56,6 @@ target_link_options(sovd_service_interface PRIVATE target_link_libraries(sovd_service_interface nlohmann_json::nlohmann_json - cpp_httplib_target - OpenSSL::SSL OpenSSL::Crypto ) install(TARGETS sovd_service_interface @@ -110,8 +98,6 @@ if(BUILD_TESTING) ) target_link_libraries(test_sovd_service_interface nlohmann_json::nlohmann_json - cpp_httplib_target - OpenSSL::SSL OpenSSL::Crypto ) medkit_set_test_domain(test_sovd_service_interface) endif() diff --git a/src/ros2_medkit_plugins/ros2_medkit_sovd_service_interface/design/index.rst b/src/ros2_medkit_plugins/ros2_medkit_sovd_service_interface/design/index.rst new file mode 100644 index 000000000..0e7d9cb41 --- /dev/null +++ b/src/ros2_medkit_plugins/ros2_medkit_sovd_service_interface/design/index.rst @@ -0,0 +1,83 @@ +ros2_medkit_sovd_service_interface +==================================== + +Overview +-------- + +The ``ros2_medkit_sovd_service_interface`` package implements a gateway plugin that +exposes the medkit entity tree and fault data via standard ROS 2 services. This enables +other ROS 2 nodes to query gateway entities and faults without going through the HTTP API. + +The plugin creates three ROS 2 services: + +- ``~/list_entities`` - lists entities by type (areas, components, apps, functions) +- ``~/get_entity_data`` - returns data items for a specific entity +- ``~/get_capabilities`` - returns the capability set for a specific entity + +Architecture +------------ + +The plugin runs as a gateway MODULE (loaded via dlopen) and uses ``PluginContext`` to +access the entity cache and fault manager. It does not implement any provider interfaces +(DataProvider, OperationProvider, etc.) - instead it reads data from the gateway's +existing managers and exposes it via ROS 2 service interfaces. + +.. code-block:: text + + Gateway Process + +------------------------------------------------------+ + | GatewayNode | + | +-- PluginManager | + | | +-- SovdServiceInterface (MODULE .so) | + | | |-- ~/list_entities service | + | | |-- ~/get_entity_data service | + | | |-- ~/get_capabilities service | + | | +-- PluginContext (read-only access) | + | +-- EntityCache <---+ | + | +-- FaultManager <--+ | + +------------------------------------------------------+ + +Key Components +-------------- + +**SovdServiceInterface** (``src/sovd_service_interface.cpp``) + Main plugin class inheriting ``GatewayPlugin``. Creates ROS 2 services during + ``configure()`` and destroys them during ``shutdown()``. Service callbacks query + the ``PluginContext`` entity cache for data. + +**Service Exports** (``src/service_exports.cpp``) + Standard plugin C API: ``plugin_api_version()``, ``create_plugin()``, + ``destroy_plugin()``. + +Message Types +------------- + +The plugin uses custom message types from ``ros2_medkit_msgs``: + +- ``EntityInfo.msg`` - entity representation with ID, type, name, capabilities +- ``ListEntities.srv`` - request/response for entity listing with type filter +- ``GetEntityData.srv`` - request/response for entity data retrieval +- ``GetCapabilities.srv`` - request/response for entity capability queries + +Design Decisions +---------------- + +ROS 2 Services over Topics +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Services were chosen over topics because entity queries are inherently request-response. +Topics would require a pub/sub pattern that adds complexity without benefit for +point-in-time queries. + +No Provider Interfaces +~~~~~~~~~~~~~~~~~~~~~~ + +This plugin reads from the gateway's entity cache rather than implementing DataProvider +or OperationProvider. It is a consumer of gateway data, not a provider of new data sources. +This keeps it simple and avoids circular dependencies. + +PluginContext Read-Only Access +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The plugin only uses read-only ``PluginContext`` methods (``list_entities()``, +``get_entity()``, ``get_entity_data()``). It never mutates gateway state. diff --git a/src/ros2_medkit_plugins/ros2_medkit_sovd_service_interface/package.xml b/src/ros2_medkit_plugins/ros2_medkit_sovd_service_interface/package.xml index 86d12d3a8..0677f13d0 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_sovd_service_interface/package.xml +++ b/src/ros2_medkit_plugins/ros2_medkit_sovd_service_interface/package.xml @@ -15,8 +15,6 @@ ros2_medkit_msgs ros2_medkit_gateway nlohmann-json-dev - libcpp-httplib-dev - libssl-dev ament_lint_auto ament_lint_common diff --git a/src/ros2_medkit_plugins/ros2_medkit_sovd_service_interface/src/service_exports.cpp b/src/ros2_medkit_plugins/ros2_medkit_sovd_service_interface/src/service_exports.cpp index d24386b06..dc516851e 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_sovd_service_interface/src/service_exports.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_sovd_service_interface/src/service_exports.cpp @@ -18,7 +18,7 @@ using namespace ros2_medkit_gateway; extern "C" GATEWAY_PLUGIN_EXPORT int plugin_api_version() { - return PLUGIN_API_VERSION; // Must return 4 (exact match required) + return PLUGIN_API_VERSION; // Must match PLUGIN_API_VERSION (exact match required) } extern "C" GATEWAY_PLUGIN_EXPORT GatewayPlugin * create_plugin() {