From d5ba505b70f586e142910572855fab277eafc9b0 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 6 Apr 2026 09:09:07 +0200 Subject: [PATCH 01/12] add DataProvider and OperationProvider interfaces New provider interfaces for plugins that serve standard SOVD data and operation resources on plugin-created entities. Per-entity routing (unlike singleton Log/Script/Update providers) allows multiple plugins to each handle their own entity sets. --- .../providers/data_provider.hpp | 84 +++++++++++++++++++ .../providers/operation_provider.hpp | 71 ++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/providers/data_provider.hpp create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/providers/operation_provider.hpp 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 00000000..179fc8be --- /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/operation_provider.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/providers/operation_provider.hpp new file mode 100644 index 00000000..963be5af --- /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 From 1dcd49f9bd2af1c9c2c96edf7fd4dc48594298bf Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 6 Apr 2026 09:11:54 +0200 Subject: [PATCH 02/12] discover DataProvider and OperationProvider via dlsym PluginLoader queries get_data_provider() and get_operation_provider() C exports from plugin .so files, following the same pattern as existing get_log_provider/get_script_provider queries. --- .../plugins/plugin_loader.hpp | 11 +++++++ .../plugins/plugin_manager.hpp | 4 +++ .../src/plugins/plugin_loader.cpp | 30 +++++++++++++++++++ .../src/plugins/plugin_manager.cpp | 8 +++++ 4 files changed, 53 insertions(+) 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 059232c1..bb963c34 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,8 @@ class UpdateProvider; class IntrospectionProvider; class LogProvider; class ScriptProvider; +class DataProvider; +class OperationProvider; /** * @brief Result of loading a gateway plugin. @@ -63,6 +65,13 @@ 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; + /// Get the dlopen handle (for dlsym queries by PluginManager) void * dl_handle() const { return handle_; @@ -85,6 +94,8 @@ 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); * * 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 d1649e69..2f5c69ca 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,10 @@ #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/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" @@ -179,6 +181,8 @@ class PluginManager { IntrospectionProvider * introspection_provider = nullptr; LogProvider * log_provider = nullptr; ScriptProvider * script_provider = nullptr; + DataProvider * data_provider = nullptr; + OperationProvider * operation_provider = nullptr; }; /// Disable a plugin after a lifecycle error (nulls providers, resets plugin). diff --git a/src/ros2_medkit_gateway/src/plugins/plugin_loader.cpp b/src/ros2_medkit_gateway/src/plugins/plugin_loader.cpp index 56b324ba..4e9d0e01 100644 --- a/src/ros2_medkit_gateway/src/plugins/plugin_loader.cpp +++ b/src/ros2_medkit_gateway/src/plugins/plugin_loader.cpp @@ -15,7 +15,9 @@ #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/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" @@ -227,6 +229,34 @@ 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()); + } + } + // 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 0192f815..c8cee0f3 100644 --- a/src/ros2_medkit_gateway/src/plugins/plugin_manager.cpp +++ b/src/ros2_medkit_gateway/src/plugins/plugin_manager.cpp @@ -62,6 +62,8 @@ 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()); // Cache first UpdateProvider, warn on duplicates if (lp.update_provider) { @@ -112,6 +114,8 @@ 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; // Cache first UpdateProvider, warn on duplicates if (lp.update_provider) { @@ -196,10 +200,14 @@ 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.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.plugin.reset(); } From 42a61988d741cacd29431dd5193ce39b944c1d7a Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 6 Apr 2026 09:13:03 +0200 Subject: [PATCH 03/12] add per-entity plugin ownership tracking to PluginManager Entity ownership map (entity_id -> plugin_name) enables per-entity routing to DataProvider/OperationProvider. Populated from IntrospectionProvider results. --- .../plugins/plugin_manager.hpp | 26 +++++++++++ .../src/plugins/plugin_manager.cpp | 45 +++++++++++++++++++ 2 files changed, 71 insertions(+) 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 2f5c69ca..1ecaf6c4 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 @@ -30,8 +30,10 @@ #include #include +#include #include #include +#include #include namespace httplib { @@ -167,6 +169,28 @@ 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); + + /// 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; + + /// 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; @@ -196,6 +220,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/src/plugins/plugin_manager.cpp b/src/ros2_medkit_gateway/src/plugins/plugin_manager.cpp index c8cee0f3..6b788065 100644 --- a/src/ros2_medkit_gateway/src/plugins/plugin_manager.cpp +++ b/src/ros2_medkit_gateway/src/plugins/plugin_manager.cpp @@ -423,6 +423,51 @@ 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) { + entity_ownership_[eid] = plugin_name; + } +} + +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; +} + +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_); From 2b3571936fefe6862edd1e681387ac873e720bd3 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 6 Apr 2026 09:25:41 +0200 Subject: [PATCH 04/12] enable IntrospectionProvider in all discovery modes Plugin entities now appear in the entity cache regardless of discovery mode (runtime_only, manifest_only, hybrid). In hybrid mode, plugins participate in the merge pipeline as before. In other modes, entities are injected during the regular cache refresh cycle. All plugin entities get source='plugin'. Entity ownership is tracked in PluginManager for per-entity provider routing. --- .../src/discovery/layers/plugin_layer.cpp | 14 +++++ src/ros2_medkit_gateway/src/gateway_node.cpp | 63 ++++++++++++++++--- 2 files changed, 70 insertions(+), 7 deletions(-) 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 72e4610b..22c8fde5 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/gateway_node.cpp b/src/ros2_medkit_gateway/src/gateway_node.cpp index 82376418..1968d6f7 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,33 @@ 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)); + } + } + } + // 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 From 9fb1d712379dd31c64dc422e96a5bb11d3f695ad Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 6 Apr 2026 09:27:11 +0200 Subject: [PATCH 05/12] add plugin routing fields to EntityInfo EntityInfo now has is_plugin and plugin_name fields, populated during entity lookup from PluginManager's ownership map. Enables handlers to check entity ownership without querying PluginManager directly. --- .../http/handlers/handler_context.hpp | 4 ++++ .../src/http/handlers/handler_context.cpp | 11 +++++++++++ 2 files changed, 15 insertions(+) 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 afd3473e..e0688c04 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/src/http/handlers/handler_context.cpp b/src/ros2_medkit_gateway/src/http/handlers/handler_context.cpp index c5c272ad..3f96dbec 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/handler_context.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/handler_context.cpp @@ -89,6 +89,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 From 1898a3801c7ad425f85179d5b5a47e0971361a1c Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 6 Apr 2026 09:29:44 +0200 Subject: [PATCH 06/12] delegate data requests to DataProvider for plugin entities Data handlers (list, read, write) check EntityInfo.is_plugin and delegate to the owning plugin's DataProvider instead of querying ROS 2 topics via DataAccessManager. --- .../src/http/handlers/data_handlers.cpp | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) 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 1949f9de..accb3fc2 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,23 @@ 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 { + HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message); + } + 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 +146,24 @@ 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 { + HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message); + } + 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,6 +245,32 @@ void DataHandlers::handle_put_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) { + 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 { + HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message); + } + return; + } + HandlerContext::send_error(res, 404, ERR_RESOURCE_NOT_FOUND, + "No data provider for plugin entity '" + entity_id + "'"); + return; + } + // Check lock access for data if (ctx_.validate_lock_access(req, res, *entity_opt, "data")) { return; From 57882892196beb2ba2541e3ab4924dd6a4473707 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 6 Apr 2026 09:31:33 +0200 Subject: [PATCH 07/12] delegate operation requests to OperationProvider for plugin entities Operation handlers (list, execute) check EntityInfo.is_plugin and delegate to the owning plugin's OperationProvider instead of looking up ROS 2 services/actions via OperationManager. --- .../src/http/handlers/operation_handlers.cpp | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) 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 6f08c2b2..da014154 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp @@ -21,6 +21,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 +46,23 @@ 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 { + HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message); + } + 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,6 +363,32 @@ void OperationHandlers::handle_create_execution(const httplib::Request & req, ht } 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) { + 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 { + HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message); + } + return; + } + HandlerContext::send_error(res, 404, ERR_OPERATION_NOT_FOUND, + "No operation provider for plugin entity '" + entity_id + "'"); + return; + } + // Check lock access for operations if (ctx_.validate_lock_access(req, res, entity_info, "operations")) { return; From 167a4d6193145f3282d2bfc5ee17b11fc00b491c Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 6 Apr 2026 09:32:31 +0200 Subject: [PATCH 08/12] auto-deduce data/operations capabilities from plugin providers When a plugin entity has DataProvider or OperationProvider registered, the entity detail response automatically includes 'data' and 'operations' in the capabilities array. --- .../src/http/handlers/discovery_handlers.cpp | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) 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 0b039adb..8d19ccb7 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,42 @@ 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"}}); + } + + // 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}}); From 81db09cf28739b5187149bccd2acf9cd4ea2aa31 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 6 Apr 2026 09:41:39 +0200 Subject: [PATCH 09/12] add tests for plugin entity ownership routing Tests verify entity ownership tracking in PluginManager: registration, lookup, multi-plugin coexistence, provider resolution via add_plugin (in-process dynamic_cast), and nullptr returns for unknown entities and plugins without providers. --- src/ros2_medkit_gateway/CMakeLists.txt | 4 + .../test/test_plugin_entity_routing.cpp | 177 ++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 src/ros2_medkit_gateway/test/test_plugin_entity_routing.cpp diff --git a/src/ros2_medkit_gateway/CMakeLists.txt b/src/ros2_medkit_gateway/CMakeLists.txt index 5a84a8ac..fdd15e1e 100644 --- a/src/ros2_medkit_gateway/CMakeLists.txt +++ b/src/ros2_medkit_gateway/CMakeLists.txt @@ -598,6 +598,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/test/test_plugin_entity_routing.cpp b/src/ros2_medkit_gateway/test/test_plugin_entity_routing.cpp new file mode 100644 index 00000000..4cfae96d --- /dev/null +++ b/src/ros2_medkit_gateway/test/test_plugin_entity_routing.cpp @@ -0,0 +1,177 @@ +// 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/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); +} From ffd1d2ed2826ff13bc6a9d8a03e138fe09980e38 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 6 Apr 2026 15:28:37 +0200 Subject: [PATCH 10/12] fix: include data/operation providers in GatewayPluginLoadResult move ops The move constructor and move assignment operator were missing data_provider and operation_provider fields, causing them to be silently lost during plugin loading. This made get_data_provider_for_entity() always return nullptr even though dlsym successfully discovered the providers. --- src/ros2_medkit_gateway/src/plugins/plugin_loader.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/ros2_medkit_gateway/src/plugins/plugin_loader.cpp b/src/ros2_medkit_gateway/src/plugins/plugin_loader.cpp index 4e9d0e01..3df39674 100644 --- a/src/ros2_medkit_gateway/src/plugins/plugin_loader.cpp +++ b/src/ros2_medkit_gateway/src/plugins/plugin_loader.cpp @@ -47,11 +47,15 @@ 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) , 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.handle_ = nullptr; } @@ -62,6 +66,8 @@ GatewayPluginLoadResult & GatewayPluginLoadResult::operator=(GatewayPluginLoadRe introspection_provider = nullptr; log_provider = nullptr; script_provider = nullptr; + data_provider = nullptr; + operation_provider = nullptr; plugin.reset(); if (handle_) { dlclose(handle_); @@ -73,12 +79,16 @@ 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; 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.handle_ = nullptr; } return *this; From a30ec6d58f0f85da73a9002ed6a0cfa1344c6031 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 6 Apr 2026 17:19:13 +0200 Subject: [PATCH 11/12] add FaultProvider interface for plugin fault/DTC resources New provider interface enabling plugins to serve SOVD fault endpoints (GET /faults, GET /faults/{code}, DELETE /faults/{code}) on their entities. Follows the same per-entity routing pattern as DataProvider and OperationProvider. Fault handlers delegate to FaultProvider for plugin-owned entities. Capabilities auto-deduction includes 'faults' when FaultProvider is registered. --- .../plugins/plugin_loader.hpp | 5 ++ .../plugins/plugin_manager.hpp | 5 ++ .../providers/fault_provider.hpp | 71 +++++++++++++++++++ .../src/http/handlers/discovery_handlers.cpp | 3 + .../src/http/handlers/fault_handlers.cpp | 55 ++++++++++++++ .../src/plugins/plugin_loader.cpp | 20 ++++++ .../src/plugins/plugin_manager.cpp | 18 +++++ 7 files changed, 177 insertions(+) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/providers/fault_provider.hpp 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 bb963c34..88cd04bb 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 @@ -28,6 +28,7 @@ class IntrospectionProvider; class LogProvider; class ScriptProvider; class DataProvider; +class FaultProvider; class OperationProvider; /** @@ -72,6 +73,9 @@ struct GatewayPluginLoadResult { /// 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_; @@ -96,6 +100,7 @@ struct GatewayPluginLoadResult { * 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 1ecaf6c4..3b6be61f 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 @@ -20,6 +20,7 @@ #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" @@ -187,6 +188,9 @@ class PluginManager { /// 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; @@ -207,6 +211,7 @@ class PluginManager { 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). 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 00000000..e129e003 --- /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/src/http/handlers/discovery_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp index 8d19ccb7..5dd38a62 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp @@ -60,6 +60,9 @@ void append_plugin_capabilities(json & capabilities, const std::string & entity_ 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(); 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 5ed1baf9..6789a443 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,23 @@ 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 { + HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message); + } + 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 +575,24 @@ 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 { + HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message); + } + 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,6 +652,24 @@ void FaultHandlers::handle_clear_fault(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->clear_fault(entity_id, fault_code); + if (result) { + HandlerContext::send_json(res, *result); + } else { + HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message); + } + return; + } + HandlerContext::send_error(res, 404, ERR_RESOURCE_NOT_FOUND, + "No fault provider for plugin entity '" + entity_id + "'"); + return; + } + // Check lock access for faults if (ctx_.validate_lock_access(req, res, entity_info, "faults")) { return; diff --git a/src/ros2_medkit_gateway/src/plugins/plugin_loader.cpp b/src/ros2_medkit_gateway/src/plugins/plugin_loader.cpp index 3df39674..52147168 100644 --- a/src/ros2_medkit_gateway/src/plugins/plugin_loader.cpp +++ b/src/ros2_medkit_gateway/src/plugins/plugin_loader.cpp @@ -16,6 +16,7 @@ #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" @@ -49,6 +50,7 @@ GatewayPluginLoadResult::GatewayPluginLoadResult(GatewayPluginLoadResult && othe , 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; @@ -56,6 +58,7 @@ GatewayPluginLoadResult::GatewayPluginLoadResult(GatewayPluginLoadResult && othe other.script_provider = nullptr; other.data_provider = nullptr; other.operation_provider = nullptr; + other.fault_provider = nullptr; other.handle_ = nullptr; } @@ -68,6 +71,7 @@ GatewayPluginLoadResult & GatewayPluginLoadResult::operator=(GatewayPluginLoadRe script_provider = nullptr; data_provider = nullptr; operation_provider = nullptr; + fault_provider = nullptr; plugin.reset(); if (handle_) { dlclose(handle_); @@ -81,6 +85,7 @@ GatewayPluginLoadResult & GatewayPluginLoadResult::operator=(GatewayPluginLoadRe 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; @@ -89,6 +94,7 @@ GatewayPluginLoadResult & GatewayPluginLoadResult::operator=(GatewayPluginLoadRe other.script_provider = nullptr; other.data_provider = nullptr; other.operation_provider = nullptr; + other.fault_provider = nullptr; other.handle_ = nullptr; } return *this; @@ -267,6 +273,20 @@ tl::expected PluginLoader::load(const std: } } + 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 6b788065..45d6a61e 100644 --- a/src/ros2_medkit_gateway/src/plugins/plugin_manager.cpp +++ b/src/ros2_medkit_gateway/src/plugins/plugin_manager.cpp @@ -64,6 +64,7 @@ void PluginManager::add_plugin(std::unique_ptr plugin) { 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) { @@ -116,6 +117,7 @@ size_t PluginManager::load_plugins(const std::vector & configs) { 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) { @@ -202,12 +204,14 @@ void PluginManager::disable_plugin(LoadedPlugin & lp) { 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(); } @@ -459,6 +463,20 @@ OperationProvider * PluginManager::get_operation_provider_for_entity(const std:: 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); From 775fe9e117961e619c7181b05bf974adaedb3174 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 6 Apr 2026 21:43:58 +0200 Subject: [PATCH 12/12] fix: harden plugin entity framework - lock enforcement, validation, error handling - Move validate_lock_access before plugin delegation in mutating handlers (write_data, execute_operation, clear_fault) so SOVD locks apply to plugin-owned entities - Refresh entity ownership during cache update cycle, not just at startup, so dynamically discovered entities get proper provider routing - Add RCLCPP_WARN when entity ownership is transferred between plugins - Extract entity ID character validation to shared utility and apply in PluginContext (was missing alphanumeric-only check) - Sanitize plugin error responses: truncate messages to 512 chars, clamp http_status to 400-599 range - Define ERR_PLUGIN_ERROR in error_codes.hpp, replace all string literals - Add PluginRequest::query_param() for SOVD query parameter access - Remove unnecessary cpp-httplib/OpenSSL deps from sovd_service_interface - Add 11 new tests: FaultProvider routing, error propagation, ownership conflicts, clear/re-register, query_param - Update plugin-system.rst with DataProvider/OperationProvider/FaultProvider docs and entity ownership model - Add EntityInfo.msg and 3 new srv definitions to msgs design doc - Create sovd_service_interface design doc with architecture overview --- docs/design/index.rst | 1 + .../design/ros2_medkit_sovd_service_interface | 1 + docs/tutorials/plugin-system.rst | 32 +++- src/ros2_medkit_gateway/CMakeLists.txt | 1 + .../ros2_medkit_gateway/entity_validation.hpp | 29 +++ .../ros2_medkit_gateway/http/error_codes.hpp | 3 + .../plugins/plugin_http_types.hpp | 3 + .../plugins/plugin_manager.hpp | 4 + .../src/entity_validation.cpp | 56 ++++++ src/ros2_medkit_gateway/src/gateway_node.cpp | 28 +++ .../src/http/handlers/data_handlers.cpp | 25 ++- .../src/http/handlers/fault_handlers.cpp | 25 ++- .../src/http/handlers/handler_context.cpp | 37 +--- .../src/http/handlers/operation_handlers.cpp | 21 ++- .../src/plugins/plugin_context.cpp | 8 +- .../src/plugins/plugin_http_types.cpp | 4 + .../src/plugins/plugin_manager.cpp | 21 ++- .../test/test_plugin_config.cpp | 1 - .../test/test_plugin_entity_routing.cpp | 172 ++++++++++++++++++ .../test/test_plugin_http_types.cpp | 11 ++ src/ros2_medkit_msgs/design/index.rst | 8 + .../CMakeLists.txt | 14 -- .../design/index.rst | 83 +++++++++ .../package.xml | 2 - .../src/service_exports.cpp | 2 +- 25 files changed, 510 insertions(+), 82 deletions(-) create mode 120000 docs/design/ros2_medkit_sovd_service_interface create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/entity_validation.hpp create mode 100644 src/ros2_medkit_gateway/src/entity_validation.cpp create mode 100644 src/ros2_medkit_plugins/ros2_medkit_sovd_service_interface/design/index.rst diff --git a/docs/design/index.rst b/docs/design/index.rst index df2bd45e..915d17f9 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 00000000..f130faad --- /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 7d56440e..fb262c89 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 fdd15e1e..036988aa 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 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 00000000..73cca8b7 --- /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 2e432202..05d88144 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/plugins/plugin_http_types.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/plugins/plugin_http_types.hpp index a6392ae6..643e43d4 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_manager.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/plugins/plugin_manager.hpp index 3b6be61f..45c7bf22 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 @@ -178,6 +178,10 @@ class PluginManager { /// 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 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 00000000..b026d601 --- /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 1968d6f7..9ac84af5 100644 --- a/src/ros2_medkit_gateway/src/gateway_node.cpp +++ b/src/ros2_medkit_gateway/src/gateway_node.cpp @@ -1585,6 +1585,34 @@ void GatewayNode::refresh_cache() { } } + // 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 accb3fc2..2da0af95 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/data_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/data_handlers.cpp @@ -55,7 +55,10 @@ void DataHandlers::handle_list_data(const httplib::Request & req, httplib::Respo if (result) { HandlerContext::send_json(res, *result); } else { - HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message); + 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; } @@ -155,7 +158,10 @@ void DataHandlers::handle_get_data_item(const httplib::Request & req, httplib::R if (result) { HandlerContext::send_json(res, *result); } else { - HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message); + 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; } @@ -245,6 +251,11 @@ 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 (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(); @@ -262,7 +273,10 @@ void DataHandlers::handle_put_data_item(const httplib::Request & req, httplib::R if (result) { HandlerContext::send_json(res, *result); } else { - HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message); + 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; } @@ -271,11 +285,6 @@ void DataHandlers::handle_put_data_item(const httplib::Request & req, httplib::R return; } - // Check lock access for data - if (ctx_.validate_lock_access(req, res, *entity_opt, "data")) { - return; - } - // Parse request body json body; try { 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 6789a443..9dc9e90c 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp @@ -324,7 +324,10 @@ void FaultHandlers::handle_list_faults(const httplib::Request & req, httplib::Re if (result) { HandlerContext::send_json(res, *result); } else { - HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message); + 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; } @@ -584,7 +587,10 @@ void FaultHandlers::handle_get_fault(const httplib::Request & req, httplib::Resp if (result) { HandlerContext::send_json(res, *result); } else { - HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message); + 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; } @@ -652,6 +658,11 @@ void FaultHandlers::handle_clear_fault(const httplib::Request & req, httplib::Re } auto entity_info = *entity_opt; + // 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(); @@ -661,7 +672,10 @@ void FaultHandlers::handle_clear_fault(const httplib::Request & req, httplib::Re if (result) { HandlerContext::send_json(res, *result); } else { - HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message); + 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; } @@ -670,11 +684,6 @@ void FaultHandlers::handle_clear_fault(const httplib::Request & req, httplib::Re return; } - // Check lock access for faults - if (ctx_.validate_lock_access(req, res, entity_info, "faults")) { - 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 3f96dbec..7be2b546 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 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 da014154..be569750 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" @@ -55,7 +56,10 @@ void OperationHandlers::handle_list_operations(const httplib::Request & req, htt if (result) { HandlerContext::send_json(res, *result); } else { - HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message); + 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; } @@ -363,6 +367,11 @@ void OperationHandlers::handle_create_execution(const httplib::Request & req, ht } auto entity_info = *entity_opt; + // 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(); @@ -380,7 +389,10 @@ void OperationHandlers::handle_create_execution(const httplib::Request & req, ht if (result) { HandlerContext::send_json(res, *result); } else { - HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message); + 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; } @@ -389,11 +401,6 @@ void OperationHandlers::handle_create_execution(const httplib::Request & req, ht return; } - // Check lock access for operations - if (ctx_.validate_lock_access(req, res, entity_info, "operations")) { - 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 90e236dd..0a84bb7a 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 a4310b65..addf02be 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_manager.cpp b/src/ros2_medkit_gateway/src/plugins/plugin_manager.cpp index 45d6a61e..9d4ec49e 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 { @@ -297,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"); } }; @@ -431,10 +432,26 @@ 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); diff --git a/src/ros2_medkit_gateway/test/test_plugin_config.cpp b/src/ros2_medkit_gateway/test/test_plugin_config.cpp index 861ca4d1..294af568 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 index 4cfae96d..e1e72bb1 100644 --- a/src/ros2_medkit_gateway/test/test_plugin_entity_routing.cpp +++ b/src/ros2_medkit_gateway/test/test_plugin_entity_routing.cpp @@ -16,6 +16,7 @@ #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; @@ -175,3 +176,174 @@ TEST(PluginEntityRouting, UnownedEntityReturnsNullProviders) { 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 afcb587e..930f9159 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 f2997615..7b5cd2e6 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 6690f38a..df09dc63 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 00000000..0e7d9cb4 --- /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 86d12d3a..0677f13d 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 d24386b0..dc516851 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() {