From 61d383c034f70cad309ad3a9fc38a89927e41de2 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 7 Mar 2026 12:17:45 +0100 Subject: [PATCH 01/10] docs(tutorials): fix manifest-discovery param names and unmanifested_nodes section The param names in the launch/YAML/CLI examples were already correct (discovery.mode, discovery.manifest_path), so no changes needed there. Replaced the "Handling Unmanifested Nodes" section which documented the nonexistent config.unmanifested_nodes parameter (with ignore/warn/error/ include_as_orphan policies) with "Controlling Gap-Fill in Hybrid Mode" documenting the actual discovery.merge_pipeline.gap_fill.* parameters. Added a note block to the Runtime Linking section explaining the layered merge pipeline architecture. --- docs/tutorials/manifest-discovery.rst | 47 +++++++++++++++------------ 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/docs/tutorials/manifest-discovery.rst b/docs/tutorials/manifest-discovery.rst index 1a45369e..509f5460 100644 --- a/docs/tutorials/manifest-discovery.rst +++ b/docs/tutorials/manifest-discovery.rst @@ -265,6 +265,13 @@ In hybrid mode, the ``GET /health`` response includes full discovery diagnostics Runtime Linking ~~~~~~~~~~~~~~~ +.. note:: + + In hybrid mode, the gateway uses a layered merge pipeline: manifest entities, + runtime-discovered entities, and plugin-contributed entities are merged per + field-group before linking. See :doc:`/config/discovery-options` for merge + pipeline configuration. + ROS Binding Configuration ~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -389,30 +396,30 @@ The manifest defines a hierarchical structure: - controller-node - localization-node -Handling Unmanifested Nodes ---------------------------- +Controlling Gap-Fill in Hybrid Mode +------------------------------------ -In hybrid mode, the gateway may discover ROS 2 nodes that aren't declared -in the manifest. The ``config.unmanifested_nodes`` setting controls this: +In hybrid mode, the runtime layer can create heuristic entities for namespaces +not covered by the manifest. The ``merge_pipeline.gap_fill`` parameters control +this behavior: .. code-block:: yaml - config: - # Options: ignore, warn, error, include_as_orphan - unmanifested_nodes: warn - -**Policies:** - -- ``ignore``: Don't expose unmanifested nodes at all -- ``warn`` (default): Log warning, include nodes as orphans -- ``error``: Fail startup if orphan nodes detected -- ``include_as_orphan``: Include with ``source: "orphan"`` - -.. note:: - In hybrid mode with gap-fill configuration (see :doc:`/config/discovery-options`), - namespace filtering controls which runtime entities enter the pipeline. - ``unmanifested_nodes`` controls how runtime nodes that passed gap-fill - but did not match any manifest app are handled by the RuntimeLinker. + discovery: + merge_pipeline: + gap_fill: + allow_heuristic_areas: true # Create areas from namespaces + allow_heuristic_components: true # Create synthetic components + allow_heuristic_apps: true # Create apps from unbound nodes + allow_heuristic_functions: false # Don't create heuristic functions + # namespace_blacklist: ["/rosout"] # Exclude specific namespaces + # namespace_whitelist: [] # If set, only allow these namespaces + +When all ``allow_heuristic_*`` options are ``false``, only manifest-declared +entities appear. This is effectively the same as ``manifest_only`` mode but +with the benefit of runtime data (topics, services) on manifest entities. + +See :doc:`/config/discovery-options` for the full merge pipeline reference. Hot Reloading ------------- From d92875c92ab521af43509830b54ad8a208054ca3 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 7 Mar 2026 12:21:19 +0100 Subject: [PATCH 02/10] docs(config): add Logging and Rate Limiting sections to server reference --- docs/config/server.rst | 99 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/docs/config/server.rst b/docs/config/server.rst index 6eb8e7d1..550dbc1c 100644 --- a/docs/config/server.rst +++ b/docs/config/server.rst @@ -155,6 +155,37 @@ Data Access Settings - ``1.0`` - Timeout for sampling topics with active publishers. Range: 0.1-30.0. +Logging Configuration +--------------------- + +Configure the in-memory log buffer that collects ``/rosout`` messages. + +.. list-table:: + :header-rows: 1 + :widths: 25 10 15 50 + + * - Parameter + - Type + - Default + - Description + * - ``logs.buffer_size`` + - int + - ``200`` + - Maximum log entries retained per node. Valid range: 1-100000 + (values outside this range are clamped with a warning). + +Example: + +.. code-block:: yaml + + ros2_medkit_gateway: + ros__parameters: + logs: + buffer_size: 500 + +A ``LogProvider`` plugin can replace the default ``/rosout`` backend. +See :doc:`/tutorials/plugin-system` for details. + Performance Tuning ------------------ @@ -219,7 +250,7 @@ Example: .. note:: - The gateway uses native rclcpp APIs for all ROS 2 interactions—no ROS 2 CLI + The gateway uses native rclcpp APIs for all ROS 2 interactions - no ROS 2 CLI dependencies. Topic discovery, sampling, publishing, service calls, and action operations are implemented in pure C++ using ros2_medkit_serialization. @@ -255,6 +286,60 @@ Example: max_clients: 10 max_subscriptions: 100 +Rate Limiting +------------- + +Token-bucket-based rate limiting for API requests. Disabled by default. + +.. list-table:: + :header-rows: 1 + :widths: 35 10 15 40 + + * - Parameter + - Type + - Default + - Description + * - ``rate_limiting.enabled`` + - bool + - ``false`` + - Enable rate limiting. + * - ``rate_limiting.global_requests_per_minute`` + - int + - ``600`` + - Maximum RPM across all clients combined. + * - ``rate_limiting.client_requests_per_minute`` + - int + - ``60`` + - Maximum RPM per client IP. + * - ``rate_limiting.endpoint_limits`` + - string[] + - ``[]`` + - Per-endpoint overrides as ``"pattern:rpm"`` strings. + Pattern uses ``*`` as single-segment wildcard + (e.g., ``"/api/v1/*/operations/*:10"``). + * - ``rate_limiting.client_cleanup_interval_seconds`` + - int + - ``300`` + - How often to scan and remove idle client tracking entries (seconds). + * - ``rate_limiting.client_max_idle_seconds`` + - int + - ``600`` + - Remove client entries idle longer than this (seconds). + +Example: + +.. code-block:: yaml + + ros2_medkit_gateway: + ros__parameters: + rate_limiting: + enabled: true + global_requests_per_minute: 600 + client_requests_per_minute: 60 + endpoint_limits: ["/api/v1/*/operations/*:10"] + +See :doc:`/api/rest` for rate limiting response headers and 429 behavior. + Plugin Framework ---------------- @@ -365,6 +450,18 @@ Complete Example max_clients: 10 max_subscriptions: 100 + logs: + buffer_size: 200 + + plugins: ["my_ota_plugin"] + plugins.my_ota_plugin.path: "/opt/ros2_medkit/lib/libmy_ota_plugin.so" + + updates: + enabled: true + + rate_limiting: + enabled: false + See Also -------- From 1422a1c09f5dcb8fbcb0cb609081609ffefda212 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 7 Mar 2026 12:21:28 +0100 Subject: [PATCH 03/10] docs(config): add merge pipeline reference to discovery options --- docs/config/discovery-options.rst | 183 +++++++++++++++++++----------- 1 file changed, 118 insertions(+), 65 deletions(-) diff --git a/docs/config/discovery-options.rst b/docs/config/discovery-options.rst index d8cc5bcd..f9624c23 100644 --- a/docs/config/discovery-options.rst +++ b/docs/config/discovery-options.rst @@ -87,19 +87,88 @@ The ``min_topics_for_component`` parameter (default: 1) sets the minimum number of topics required before creating a component. This can filter out namespaces with only a few stray topics. -Merge Pipeline Options (Hybrid Mode) -------------------------------------- +Merge Pipeline (Hybrid Mode) +----------------------------- -When using ``hybrid`` mode, the merge pipeline controls how entities from -different discovery layers are combined. The ``merge_pipeline`` section -configures gap-fill behavior for runtime-discovered entities. +In hybrid mode, the gateway uses a layered merge pipeline to combine entities +from multiple sources. Three layers contribute entities independently: -Gap-Fill Configuration -^^^^^^^^^^^^^^^^^^^^^^ +- **ManifestLayer** - entities declared in the YAML manifest (highest priority for identity/hierarchy) +- **RuntimeLayer** - entities discovered from the ROS 2 graph (highest priority for live data/status) +- **PluginLayer** - entities contributed by ``IntrospectionProvider`` plugins -In hybrid mode, the manifest is the source of truth. Runtime (heuristic) discovery -fills gaps - entities that exist at runtime but aren't in the manifest. Gap-fill -controls restrict what the runtime layer is allowed to create: +Each layer's contribution is merged per **field-group** with configurable policies. + +Field Groups +^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :widths: 20 80 + + * - Field Group + - Contents + * - ``identity`` + - id, name, translation_id, description, tags + * - ``hierarchy`` + - area, component_id, parent references, depends_on, hosts + * - ``live_data`` + - topics, services, actions + * - ``status`` + - is_online, health, runtime state + * - ``metadata`` + - source, category, custom metadata fields + +Merge Policies +^^^^^^^^^^^^^^ + +Each layer declares a policy per field-group: + +.. list-table:: + :header-rows: 1 + :widths: 20 80 + + * - Policy + - Behavior + * - ``authoritative`` + - This layer's value wins over lower-priority layers. + * - ``enrichment`` + - Fill empty fields only; don't override existing values. + * - ``fallback`` + - Use only if no other layer provides the value. + +**Defaults:** + +- Manifest: ``authoritative`` for identity/hierarchy/metadata, ``enrichment`` for live_data, ``fallback`` for status +- Runtime: ``authoritative`` for live_data/status, ``enrichment`` for metadata, ``fallback`` for identity/hierarchy + +Override per-layer policies in ``gateway_params.yaml``. Empty string means +"use layer default": + +.. code-block:: yaml + + discovery: + merge_pipeline: + layers: + manifest: + identity: "" # authoritative (default) + hierarchy: "" # authoritative (default) + live_data: "" # enrichment (default) + status: "" # fallback (default) + metadata: "" # authoritative (default) + runtime: + identity: "" # fallback (default) + hierarchy: "" # fallback (default) + live_data: "" # authoritative (default) + status: "" # authoritative (default) + metadata: "" # enrichment (default) + +Gap-Fill +^^^^^^^^ + +In hybrid mode, the runtime layer can create heuristic entities for namespaces +not covered by the manifest. Gap-fill controls what the runtime layer is allowed +to create: .. code-block:: yaml @@ -110,10 +179,10 @@ controls restrict what the runtime layer is allowed to create: allow_heuristic_components: true allow_heuristic_apps: true allow_heuristic_functions: false - namespace_whitelist: [] - namespace_blacklist: [] + # namespace_blacklist: ["/rosout"] + # namespace_whitelist: [] -.. list-table:: Gap-Fill Options +.. list-table:: :header-rows: 1 :widths: 35 15 50 @@ -122,62 +191,48 @@ controls restrict what the runtime layer is allowed to create: - Description * - ``allow_heuristic_areas`` - ``true`` - - Allow runtime layer to create Area entities not in the manifest + - Create areas from namespaces not in manifest. * - ``allow_heuristic_components`` - ``true`` - - Allow runtime layer to create Component entities not in the manifest + - Create synthetic components for unmanifested namespaces. * - ``allow_heuristic_apps`` - ``true`` - - Allow runtime layer to create App entities not in the manifest + - Create apps for nodes without manifest ``ros_binding``. * - ``allow_heuristic_functions`` - ``false`` - - Allow runtime layer to create Function entities (rarely useful at runtime) - * - ``namespace_whitelist`` - - ``[]`` - - If non-empty, only allow gap-fill from these ROS 2 namespaces (Areas and Components only) + - Create heuristic functions (not recommended). * - ``namespace_blacklist`` - ``[]`` - - Exclude gap-fill from these ROS 2 namespaces (Areas and Components only) - -When both whitelist and blacklist are empty, all namespaces are eligible for gap-fill. -If whitelist is non-empty, only listed namespaces pass. Blacklist is always applied. - -Namespace matching uses path-segment boundaries: ``/nav`` matches ``/nav`` and ``/nav/sub`` -but NOT ``/navigation``. Both lists only filter Areas and Components (which carry -``namespace_path``). Apps and Functions are not namespace-filtered. - - -Merge Policies -^^^^^^^^^^^^^^ - -Each discovery layer declares a ``MergePolicy`` per field group. When two layers -provide the same entity (matched by ID), policies determine which values win: - -.. list-table:: Merge Policies - :header-rows: 1 - :widths: 25 75 - - * - Policy - - Description - * - ``AUTHORITATIVE`` - - This layer's value wins over lower-priority layers. - If two AUTHORITATIVE layers conflict, a warning is logged and the - higher-priority (earlier) layer wins. - * - ``ENRICHMENT`` - - Fill empty fields from this layer. Non-empty target values are preserved. - Two ENRICHMENT layers merge additively (collections are unioned). - * - ``FALLBACK`` - - Use only if no other layer provides the value. Two FALLBACK layers - merge additively (first non-empty fills gaps). - -**Built-in layer policies:** - -- **ManifestLayer** (priority 1): IDENTITY, HIERARCHY, METADATA are AUTHORITATIVE. - LIVE_DATA is ENRICHMENT (runtime topics/services take precedence). - STATUS is FALLBACK (manifest cannot know online state). -- **RuntimeLayer** (priority 2): LIVE_DATA and STATUS are AUTHORITATIVE. - METADATA is ENRICHMENT. IDENTITY and HIERARCHY are FALLBACK. -- **PluginLayer** (priority 3+): All field groups ENRICHMENT + - Namespaces excluded from gap-fill (e.g., ``["/rosout"]``). + * - ``namespace_whitelist`` + - ``[]`` + - If non-empty, only these namespaces are eligible for gap-fill. + +Health Endpoint +^^^^^^^^^^^^^^^ + +``GET /api/v1/health`` includes a ``discovery`` section in hybrid mode with +pipeline stats, linking results, and merge warnings: + +.. code-block:: json + + { + "status": "healthy", + "discovery": { + "mode": "hybrid", + "strategy": "HybridDiscoveryStrategy", + "pipeline": { + "layers": ["manifest", "runtime", "plugin"], + "entity_source": {"lidar-driver": "manifest", "orphan_node": "runtime"}, + "conflicts": [] + }, + "linking": { + "linked_count": 5, + "orphan_count": 1, + "binding_conflicts": 0 + } + } + } Configuration Example --------------------- @@ -201,15 +256,13 @@ Complete YAML configuration for runtime discovery: topic_only_policy: "create_component" min_topics_for_component: 2 # Require at least 2 topics - # Note: merge_pipeline settings only apply when mode is "hybrid" + # Merge pipeline (hybrid mode only) merge_pipeline: gap_fill: allow_heuristic_areas: true allow_heuristic_components: true allow_heuristic_apps: true - allow_heuristic_functions: false - namespace_whitelist: [] - namespace_blacklist: ["/rosout", "/parameter_events"] + namespace_blacklist: ["/rosout"] Command Line Override --------------------- From 8fb76ebb6233086c04f7a8ad3de07ed42c69582b Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 7 Mar 2026 12:23:07 +0100 Subject: [PATCH 04/10] docs(api): update server capabilities example response --- docs/api/rest.rst | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/api/rest.rst b/docs/api/rest.rst index 982f0fac..ccd13da4 100644 --- a/docs/api/rest.rst +++ b/docs/api/rest.rst @@ -25,11 +25,14 @@ Server Capabilities { "api_version": "1.0.0", - "gateway_version": "0.1.0", + "gateway_version": "0.3.0", "endpoints": [ {"path": "/areas", "supported_methods": ["GET"]}, {"path": "/components", "supported_methods": ["GET"]}, - {"path": "/apps", "supported_methods": ["GET"]} + {"path": "/apps", "supported_methods": ["GET"]}, + {"path": "/functions", "supported_methods": ["GET"]}, + {"path": "/faults", "supported_methods": ["GET", "DELETE"]}, + {"path": "/updates", "supported_methods": ["GET", "POST"]} ] } From 0f089c8aabcf6fbfb8f8eb895e89cfc925c39883 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 7 Mar 2026 12:23:53 +0100 Subject: [PATCH 05/10] docs(tutorials): update plugin system - IntrospectionProvider wired, add LogProvider --- docs/tutorials/plugin-system.rst | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/tutorials/plugin-system.rst b/docs/tutorials/plugin-system.rst index 5f1b74c2..2eef7f98 100644 --- a/docs/tutorials/plugin-system.rst +++ b/docs/tutorials/plugin-system.rst @@ -11,8 +11,11 @@ Plugins implement the ``GatewayPlugin`` C++ base class plus one or more typed pr - **UpdateProvider** - software update backend (CRUD, prepare/execute, automated, status) - **IntrospectionProvider** - enriches discovered entities with platform-specific metadata - via the merge pipeline. In hybrid mode, each IntrospectionProvider is wrapped as a - ``PluginLayer`` and added to the pipeline with ENRICHMENT merge policy. + and can introduce new entities. Called during each discovery cycle by the merge pipeline's + PluginLayer. See :doc:`/config/discovery-options` for merge pipeline configuration. +- **LogProvider** - replaces or augments the default ``/rosout`` log backend. + Can operate in observer mode (receives log entries) or full-ingestion mode + (owns the entire log pipeline). See the ``/logs`` endpoints in :doc:`/api/rest`. 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). @@ -64,6 +67,7 @@ Writing a Plugin #include "ros2_medkit_gateway/plugins/gateway_plugin.hpp" #include "ros2_medkit_gateway/plugins/plugin_types.hpp" #include "ros2_medkit_gateway/providers/update_provider.hpp" + #include "ros2_medkit_gateway/providers/log_provider.hpp" using namespace ros2_medkit_gateway; @@ -127,6 +131,11 @@ Writing a Plugin return static_cast(p); } + // Required if your plugin implements LogProvider: + extern "C" GATEWAY_PLUGIN_EXPORT LogProvider* get_log_provider(GatewayPlugin* p) { + return static_cast(p); + } + The ``get_update_provider`` and ``get_introspection_provider`` functions use ``extern "C"`` to avoid RTTI issues across shared library boundaries. The ``static_cast`` is safe because these functions execute inside the plugin's own ``.so`` where the type hierarchy is known. @@ -223,7 +232,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()`` +4. Provider interfaces are queried via ``get_update_provider()`` / ``get_introspection_provider()`` / ``get_log_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. ``register_routes()`` allows registering custom REST endpoints @@ -301,12 +310,9 @@ Multiple Plugins Multiple plugins can be loaded simultaneously: - **UpdateProvider**: Only one plugin's UpdateProvider is used (first in config order) -- **IntrospectionProvider**: All plugins are added as PluginLayers to the merge pipeline. - Each plugin's entities are merged with ENRICHMENT policy - they fill empty fields but - never override manifest or runtime values. Plugins are added after all built-in layers, - and the pipeline is refreshed once after all plugins are registered (batch registration). - The ``introspect()`` method receives an ``IntrospectionInput`` populated with all entities - from previous layers (manifest + runtime), enabling context-aware metadata and discovery. +- **IntrospectionProvider**: All plugins' results are merged via the PluginLayer in the discovery pipeline +- **LogProvider**: Only the first plugin's LogProvider is used for queries (same as UpdateProvider). + All LogProvider plugins receive ``on_log_entry()`` calls as observers. - **Custom routes**: All plugins can register endpoints (use unique path prefixes) Error Handling From 68b3f9306096f41dd2e5b76b0e70aad12755de6b Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 7 Mar 2026 12:59:30 +0100 Subject: [PATCH 06/10] fix(api): complete handle_root endpoint/capability list, fix version-info format - Add missing endpoint categories to handle_root: logs, bulk-data, cyclic-subscriptions, updates (conditional), DELETE /faults (global) - Remove ghost snapshot endpoints (listed but never registered) - Add missing capabilities: logs, bulk_data, cyclic_subscriptions, updates - Fix hardcoded version "0.1.0" -> "0.3.0" in handle_root and version-info - Change version-info response key from "sovd_info" to "items" (SOVD standard) - Add bulk-data, logs, cyclic-subscriptions URIs to entity capability responses - Update rest.rst: fix Server Capabilities example format, remove phantom /manifest/status, document DELETE /{entity}/faults, update SOVD compliance section, add areas/functions resource collection notes - Update tests, integration tests, and Postman collection for sovd_info->items --- docs/api/rest.rst | 60 +++++++++++++---- ...os2-medkit-gateway.postman_collection.json | 2 +- .../src/http/handlers/discovery_handlers.cpp | 6 ++ .../src/http/handlers/health_handlers.cpp | 66 ++++++++++++++++--- .../test/test_gateway_node.cpp | 12 ++-- .../test/test_health_handlers.cpp | 12 ++-- .../test/features/test_health.test.py | 12 ++-- .../test/features/test_tls.test.py | 10 +-- 8 files changed, 133 insertions(+), 47 deletions(-) diff --git a/docs/api/rest.rst b/docs/api/rest.rst index ccd13da4..dd38ba60 100644 --- a/docs/api/rest.rst +++ b/docs/api/rest.rst @@ -24,16 +24,32 @@ Server Capabilities .. code-block:: json { - "api_version": "1.0.0", - "gateway_version": "0.3.0", + "name": "ROS 2 Medkit Gateway", + "version": "0.3.0", + "api_base": "/api/v1", "endpoints": [ - {"path": "/areas", "supported_methods": ["GET"]}, - {"path": "/components", "supported_methods": ["GET"]}, - {"path": "/apps", "supported_methods": ["GET"]}, - {"path": "/functions", "supported_methods": ["GET"]}, - {"path": "/faults", "supported_methods": ["GET", "DELETE"]}, - {"path": "/updates", "supported_methods": ["GET", "POST"]} - ] + "GET /api/v1/health", + "GET /api/v1/areas", + "GET /api/v1/components", + "GET /api/v1/apps", + "GET /api/v1/functions", + "GET /api/v1/faults", + "..." + ], + "capabilities": { + "discovery": true, + "data_access": true, + "operations": true, + "async_actions": true, + "configurations": true, + "faults": true, + "logs": true, + "bulk_data": true, + "cyclic_subscriptions": true, + "updates": false, + "authentication": false, + "tls": false + } } ``GET /api/v1/version-info`` @@ -74,6 +90,9 @@ Areas ``GET /api/v1/areas/{area_id}/components`` List components in a specific area. + Areas also support the same resource collections as components: ``/data``, ``/operations``, + ``/configurations``, ``/faults``, ``/bulk-data``. See the corresponding sections below. + Components ~~~~~~~~~~ @@ -90,8 +109,7 @@ Components "id": "temp_sensor", "name": "temp_sensor", "self": "/api/v1/components/temp_sensor", - "area": "powertrain", - "resource_collections": ["data", "operations", "configurations", "faults"] + "area": "powertrain" } ] } @@ -131,6 +149,9 @@ Functions ``GET /api/v1/functions/{function_id}/hosts`` List apps that host this function. + Functions also support ``/data``, ``/operations``, ``/configurations``, ``/faults``, + and ``/bulk-data``. See the corresponding sections below. + Data Endpoints -------------- @@ -480,6 +501,16 @@ Query and manage faults. - **204:** Fault cleared - **404:** Fault not found +``DELETE /api/v1/components/{id}/faults`` + Clear all faults for an entity. + + Accepts the optional ``?status=`` query parameter (same values as ``GET /faults``). + Without it, clears pending and confirmed faults. + + - **204:** Faults cleared (or none to clear) + - **400:** Invalid status parameter + - **503:** Fault manager unavailable + ``DELETE /api/v1/faults`` Clear all faults across the system *(ros2_medkit extension, not SOVD)*. @@ -1200,16 +1231,17 @@ The gateway implements a subset of the SOVD (Service-Oriented Vehicle Diagnostic - Operations (``/operations``, ``/executions``) - Configurations (``/configurations``) - Faults (``/faults``) with ``environment_data`` and SOVD status object +- Logs (``/logs``) with severity filtering and per-entity configuration - Bulk Data (``/bulk-data``) for binary data downloads (rosbags, logs) - Software Updates (``/updates``) with async prepare/execute lifecycle - Cyclic Subscriptions (``/cyclic-subscriptions``) with SSE-based periodic data delivery **ros2_medkit Extensions:** -- ``/health`` - Health check endpoint +- ``/health`` - Health check with discovery pipeline stats - ``/version-info`` - Gateway version information -- ``/manifest/status`` - Manifest discovery status -- SSE fault streaming - Real-time fault notifications +- ``DELETE /faults`` - Clear all faults globally +- ``GET /faults/stream`` - SSE real-time fault notifications - ``x-medkit`` extension fields in responses See Also diff --git a/postman/collections/ros2-medkit-gateway.postman_collection.json b/postman/collections/ros2-medkit-gateway.postman_collection.json index 983958dc..b20de216 100644 --- a/postman/collections/ros2-medkit-gateway.postman_collection.json +++ b/postman/collections/ros2-medkit-gateway.postman_collection.json @@ -324,7 +324,7 @@ "version-info" ] }, - "description": "Get SOVD server version information. Returns sovd_info array with supported SOVD versions, base URIs, and vendor information. Response format: { sovd_info: [{ version, base_uri, vendor_info: { version, name } }] }" + "description": "Get SOVD server version information. Returns items array with supported SOVD versions, base URIs, and vendor information. Response format: { items: [{ version, base_uri, vendor_info: { version, name } }] }" }, "response": [] }, 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 2f0645ea..c06f7d1b 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp @@ -457,6 +457,9 @@ void DiscoveryHandlers::handle_get_component(const httplib::Request & req, httpl response["faults"] = base + "/faults"; response["subcomponents"] = base + "/subcomponents"; response["hosts"] = base + "/hosts"; + response["logs"] = base + "/logs"; + response["bulk-data"] = base + "/bulk-data"; + response["cyclic-subscriptions"] = base + "/cyclic-subscriptions"; if (!comp.depends_on.empty()) { response["depends-on"] = base + "/depends-on"; @@ -805,6 +808,9 @@ void DiscoveryHandlers::handle_get_app(const httplib::Request & req, httplib::Re response["operations"] = base_uri + "/operations"; response["configurations"] = base_uri + "/configurations"; response["faults"] = base_uri + "/faults"; + response["logs"] = base_uri + "/logs"; + response["bulk-data"] = base_uri + "/bulk-data"; + response["cyclic-subscriptions"] = base_uri + "/cyclic-subscriptions"; if (!app.component_id.empty()) { response["is-located-on"] = "/api/v1/components/" + app.component_id; diff --git a/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp index 04353583..b70946db 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp @@ -99,7 +99,6 @@ void HealthHandlers::handle_root(const httplib::Request & req, httplib::Response "GET /api/v1/areas/{area_id}/faults/{fault_code}", "DELETE /api/v1/areas/{area_id}/faults/{fault_code}", "DELETE /api/v1/areas/{area_id}/faults", - "GET /api/v1/areas/{area_id}/faults/{fault_code}/snapshots", // Components "GET /api/v1/components", "GET /api/v1/components/{component_id}", @@ -125,7 +124,6 @@ void HealthHandlers::handle_root(const httplib::Request & req, httplib::Response "GET /api/v1/components/{component_id}/faults/{fault_code}", "DELETE /api/v1/components/{component_id}/faults/{fault_code}", "DELETE /api/v1/components/{component_id}/faults", - "GET /api/v1/components/{component_id}/faults/{fault_code}/snapshots", // Apps "GET /api/v1/apps", "GET /api/v1/apps/{app_id}", @@ -151,7 +149,6 @@ void HealthHandlers::handle_root(const httplib::Request & req, httplib::Response "GET /api/v1/apps/{app_id}/faults/{fault_code}", "DELETE /api/v1/apps/{app_id}/faults/{fault_code}", "DELETE /api/v1/apps/{app_id}/faults", - "GET /api/v1/apps/{app_id}/faults/{fault_code}/snapshots", // Functions "GET /api/v1/functions", "GET /api/v1/functions/{function_id}", @@ -175,12 +172,47 @@ void HealthHandlers::handle_root(const httplib::Request & req, httplib::Response "GET /api/v1/functions/{function_id}/faults/{fault_code}", "DELETE /api/v1/functions/{function_id}/faults/{fault_code}", "DELETE /api/v1/functions/{function_id}/faults", - "GET /api/v1/functions/{function_id}/faults/{fault_code}/snapshots", + // Logs + "GET /api/v1/components/{component_id}/logs", + "GET /api/v1/components/{component_id}/logs/configuration", + "PUT /api/v1/components/{component_id}/logs/configuration", + "GET /api/v1/apps/{app_id}/logs", + "GET /api/v1/apps/{app_id}/logs/configuration", + "PUT /api/v1/apps/{app_id}/logs/configuration", + // Bulk Data + "GET /api/v1/areas/{area_id}/bulk-data", + "GET /api/v1/areas/{area_id}/bulk-data/{category}", + "GET /api/v1/areas/{area_id}/bulk-data/{category}/{item_id}", + "GET /api/v1/components/{component_id}/bulk-data", + "GET /api/v1/components/{component_id}/bulk-data/{category}", + "GET /api/v1/components/{component_id}/bulk-data/{category}/{item_id}", + "POST /api/v1/components/{component_id}/bulk-data/{category}", + "DELETE /api/v1/components/{component_id}/bulk-data/{category}/{item_id}", + "GET /api/v1/apps/{app_id}/bulk-data", + "GET /api/v1/apps/{app_id}/bulk-data/{category}", + "GET /api/v1/apps/{app_id}/bulk-data/{category}/{item_id}", + "POST /api/v1/apps/{app_id}/bulk-data/{category}", + "DELETE /api/v1/apps/{app_id}/bulk-data/{category}/{item_id}", + "GET /api/v1/functions/{function_id}/bulk-data", + "GET /api/v1/functions/{function_id}/bulk-data/{category}", + "GET /api/v1/functions/{function_id}/bulk-data/{category}/{item_id}", + // Cyclic Subscriptions + "POST /api/v1/components/{component_id}/cyclic-subscriptions", + "GET /api/v1/components/{component_id}/cyclic-subscriptions", + "GET /api/v1/components/{component_id}/cyclic-subscriptions/{subscription_id}", + "PUT /api/v1/components/{component_id}/cyclic-subscriptions/{subscription_id}", + "DELETE /api/v1/components/{component_id}/cyclic-subscriptions/{subscription_id}", + "GET /api/v1/components/{component_id}/cyclic-subscriptions/{subscription_id}/events", + "POST /api/v1/apps/{app_id}/cyclic-subscriptions", + "GET /api/v1/apps/{app_id}/cyclic-subscriptions", + "GET /api/v1/apps/{app_id}/cyclic-subscriptions/{subscription_id}", + "PUT /api/v1/apps/{app_id}/cyclic-subscriptions/{subscription_id}", + "DELETE /api/v1/apps/{app_id}/cyclic-subscriptions/{subscription_id}", + "GET /api/v1/apps/{app_id}/cyclic-subscriptions/{subscription_id}/events", // Global Faults "GET /api/v1/faults", "GET /api/v1/faults/stream", - "GET /api/v1/faults/{fault_code}/snapshots", - "GET /api/v1/faults/{fault_code}/snapshots/bag", + "DELETE /api/v1/faults", }); const auto & auth_config = ctx_.auth_config(); @@ -193,6 +225,18 @@ void HealthHandlers::handle_root(const httplib::Request & req, httplib::Response endpoints.push_back("POST /api/v1/auth/revoke"); } + // Add update endpoints if updates are available + if (ctx_.node() && ctx_.node()->get_update_manager()) { + endpoints.push_back("GET /api/v1/updates"); + endpoints.push_back("POST /api/v1/updates"); + endpoints.push_back("GET /api/v1/updates/{id}"); + endpoints.push_back("DELETE /api/v1/updates/{id}"); + endpoints.push_back("GET /api/v1/updates/{id}/status"); + endpoints.push_back("PUT /api/v1/updates/{id}/prepare"); + endpoints.push_back("PUT /api/v1/updates/{id}/execute"); + endpoints.push_back("PUT /api/v1/updates/{id}/automated"); + } + json capabilities = { {"discovery", true}, {"data_access", true}, @@ -200,12 +244,16 @@ void HealthHandlers::handle_root(const httplib::Request & req, httplib::Response {"async_actions", true}, {"configurations", true}, {"faults", true}, + {"logs", true}, + {"bulk_data", true}, + {"cyclic_subscriptions", true}, + {"updates", ctx_.node() && ctx_.node()->get_update_manager() != nullptr}, {"authentication", auth_config.enabled}, {"tls", tls_config.enabled}, }; json response = { - {"name", "ROS 2 Medkit Gateway"}, {"version", "0.1.0"}, {"api_base", API_BASE_PATH}, + {"name", "ROS 2 Medkit Gateway"}, {"version", "0.3.0"}, {"api_base", API_BASE_PATH}, {"endpoints", endpoints}, {"capabilities", capabilities}, }; @@ -243,10 +291,10 @@ void HealthHandlers::handle_version_info(const httplib::Request & req, httplib:: json sovd_info_entry = { {"version", "1.0.0"}, // SOVD standard version {"base_uri", API_BASE_PATH}, // Version-specific base URI - {"vendor_info", {{"version", "0.1.0"}, {"name", "ros2_medkit"}}} // Vendor-specific info + {"vendor_info", {{"version", "0.3.0"}, {"name", "ros2_medkit"}}} // Vendor-specific info }; - json response = {{"sovd_info", json::array({sovd_info_entry})}}; + json response = {{"items", json::array({sovd_info_entry})}}; HandlerContext::send_json(res, response); } catch (const std::exception & e) { diff --git a/src/ros2_medkit_gateway/test/test_gateway_node.cpp b/src/ros2_medkit_gateway/test/test_gateway_node.cpp index 8120aad6..9b89fb54 100644 --- a/src/ros2_medkit_gateway/test/test_gateway_node.cpp +++ b/src/ros2_medkit_gateway/test/test_gateway_node.cpp @@ -126,13 +126,13 @@ TEST_F(TestGatewayNode, test_version_info_endpoint) { EXPECT_EQ(res->get_header_value("Content-Type"), "application/json"); auto json_response = nlohmann::json::parse(res->body); - // Check for sovd_info array - EXPECT_TRUE(json_response.contains("sovd_info")); - EXPECT_TRUE(json_response["sovd_info"].is_array()); - EXPECT_GE(json_response["sovd_info"].size(), 1); + // Check for items array (SOVD-standard wrapper key) + EXPECT_TRUE(json_response.contains("items")); + EXPECT_TRUE(json_response["items"].is_array()); + EXPECT_GE(json_response["items"].size(), 1); - // Check first sovd_info entry - const auto & info = json_response["sovd_info"][0]; + // Check first items entry + const auto & info = json_response["items"][0]; EXPECT_TRUE(info.contains("version")); EXPECT_TRUE(info.contains("base_uri")); EXPECT_TRUE(info.contains("vendor_info")); diff --git a/src/ros2_medkit_gateway/test/test_health_handlers.cpp b/src/ros2_medkit_gateway/test/test_health_handlers.cpp index 2dc95392..575b71ed 100644 --- a/src/ros2_medkit_gateway/test/test_health_handlers.cpp +++ b/src/ros2_medkit_gateway/test/test_health_handlers.cpp @@ -83,16 +83,16 @@ TEST_F(HealthHandlersTest, HandleHealthResponseIsValidJson) { TEST_F(HealthHandlersTest, HandleVersionInfoContainsSovdInfoArray) { handlers_.handle_version_info(req_, res_); auto body = json::parse(res_.body); - ASSERT_TRUE(body.contains("sovd_info")); - ASSERT_TRUE(body["sovd_info"].is_array()); - EXPECT_FALSE(body["sovd_info"].empty()); + ASSERT_TRUE(body.contains("items")); + ASSERT_TRUE(body["items"].is_array()); + EXPECT_FALSE(body["items"].empty()); } // @verifies REQ_INTEROP_001 TEST_F(HealthHandlersTest, HandleVersionInfoSovdEntryHasVersionField) { handlers_.handle_version_info(req_, res_); auto body = json::parse(res_.body); - auto & entry = body["sovd_info"][0]; + auto & entry = body["items"][0]; EXPECT_TRUE(entry.contains("version")); EXPECT_TRUE(entry["version"].is_string()); EXPECT_FALSE(entry["version"].get().empty()); @@ -102,7 +102,7 @@ TEST_F(HealthHandlersTest, HandleVersionInfoSovdEntryHasVersionField) { TEST_F(HealthHandlersTest, HandleVersionInfoSovdEntryHasBaseUri) { handlers_.handle_version_info(req_, res_); auto body = json::parse(res_.body); - auto & entry = body["sovd_info"][0]; + auto & entry = body["items"][0]; EXPECT_TRUE(entry.contains("base_uri")); } @@ -110,7 +110,7 @@ TEST_F(HealthHandlersTest, HandleVersionInfoSovdEntryHasBaseUri) { TEST_F(HealthHandlersTest, HandleVersionInfoSovdEntryHasVendorInfo) { handlers_.handle_version_info(req_, res_); auto body = json::parse(res_.body); - auto & entry = body["sovd_info"][0]; + auto & entry = body["items"][0]; EXPECT_TRUE(entry.contains("vendor_info")); EXPECT_TRUE(entry["vendor_info"].contains("name")); EXPECT_EQ(entry["vendor_info"]["name"], "ros2_medkit"); diff --git a/src/ros2_medkit_integration_tests/test/features/test_health.test.py b/src/ros2_medkit_integration_tests/test/features/test_health.test.py index b1aaeb59..de67fc05 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_health.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_health.test.py @@ -98,13 +98,13 @@ def test_version_endpoint(self): """ data = self.get_json('/version-info') - # Check sovd_info array - self.assertIn('sovd_info', data) - self.assertIsInstance(data['sovd_info'], list) - self.assertGreaterEqual(len(data['sovd_info']), 1) + # Check items array (SOVD-standard wrapper key) + self.assertIn('items', data) + self.assertIsInstance(data['items'], list) + self.assertGreaterEqual(len(data['items']), 1) - # Check first sovd_info entry - info = data['sovd_info'][0] + # Check first items entry + info = data['items'][0] self.assertIn('version', info) self.assertIn('base_uri', info) self.assertIn('vendor_info', info) diff --git a/src/ros2_medkit_integration_tests/test/features/test_tls.test.py b/src/ros2_medkit_integration_tests/test/features/test_tls.test.py index 2461f981..0f787106 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_tls.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_tls.test.py @@ -234,11 +234,11 @@ def test_https_version_info_endpoint(self): self.assertEqual(response.status_code, 200) data = response.json() - # sovd_info array with version, base_uri, vendor_info - self.assertIn('sovd_info', data) - self.assertIsInstance(data['sovd_info'], list) - self.assertGreater(len(data['sovd_info']), 0) - info = data['sovd_info'][0] + # items array (SOVD-standard wrapper key) + self.assertIn('items', data) + self.assertIsInstance(data['items'], list) + self.assertGreater(len(data['items']), 0) + info = data['items'][0] self.assertIn('version', info) self.assertIn('base_uri', info) From 50473219f3dd1b92a3a0f756ccfd4cdf731a9b7c Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 7 Mar 2026 18:44:46 +0100 Subject: [PATCH 07/10] fix: address PR #258 review comments and CI failure Code fixes: - Remove areas/functions bulk-data from handle_root (validation rejects them) - Rename test HandleVersionInfoContainsSovdInfoArray -> HandleVersionInfoContainsItemsArray - Fix test_root_endpoint_includes_snapshots: verify legacy snapshot endpoints are NOT listed Docs fixes: - rest.rst: fix self -> href in area/component list examples - rest.rst: remove /bulk-data from areas and functions resource collections - plugin-system.rst: remove LogProvider include/export from UpdateProvider example - plugin-system.rst: clarify IntrospectionProvider metadata is plugin-internal - discovery-options.rst: fix Field Groups table (status, metadata fields) - discovery-options.rst: fix health endpoint JSON to match MergeReport::to_json() - manifest-discovery.rst: fix gap-fill disabled description --- docs/api/rest.rst | 10 +++++----- docs/config/discovery-options.rst | 12 +++++++---- docs/tutorials/manifest-discovery.rst | 5 +++-- docs/tutorials/plugin-system.rst | 20 +++++-------------- .../src/http/handlers/health_handlers.cpp | 6 ------ .../test/test_health_handlers.cpp | 2 +- .../test/features/test_snapshots_api.test.py | 13 +++++++----- 7 files changed, 30 insertions(+), 38 deletions(-) diff --git a/docs/api/rest.rst b/docs/api/rest.rst index dd38ba60..957b38d7 100644 --- a/docs/api/rest.rst +++ b/docs/api/rest.rst @@ -76,7 +76,7 @@ Areas { "id": "powertrain", "name": "Powertrain", - "self": "/api/v1/areas/powertrain" + "href": "/api/v1/areas/powertrain" } ] } @@ -91,7 +91,7 @@ Areas List components in a specific area. Areas also support the same resource collections as components: ``/data``, ``/operations``, - ``/configurations``, ``/faults``, ``/bulk-data``. See the corresponding sections below. + ``/configurations``, and ``/faults``. See the corresponding sections below. Components ~~~~~~~~~~ @@ -108,7 +108,7 @@ Components { "id": "temp_sensor", "name": "temp_sensor", - "self": "/api/v1/components/temp_sensor", + "href": "/api/v1/components/temp_sensor", "area": "powertrain" } ] @@ -149,8 +149,8 @@ Functions ``GET /api/v1/functions/{function_id}/hosts`` List apps that host this function. - Functions also support ``/data``, ``/operations``, ``/configurations``, ``/faults``, - and ``/bulk-data``. See the corresponding sections below. + Functions also support ``/data``, ``/operations``, ``/configurations``, and ``/faults``. + See the corresponding sections below. Data Endpoints -------------- diff --git a/docs/config/discovery-options.rst b/docs/config/discovery-options.rst index f9624c23..3d1a551a 100644 --- a/docs/config/discovery-options.rst +++ b/docs/config/discovery-options.rst @@ -115,9 +115,9 @@ Field Groups * - ``live_data`` - topics, services, actions * - ``status`` - - is_online, health, runtime state + - is_online, bound_fqn * - ``metadata`` - - source, category, custom metadata fields + - source, x-medkit extensions, custom metadata fields Merge Policies ^^^^^^^^^^^^^^ @@ -223,8 +223,12 @@ pipeline stats, linking results, and merge warnings: "strategy": "HybridDiscoveryStrategy", "pipeline": { "layers": ["manifest", "runtime", "plugin"], - "entity_source": {"lidar-driver": "manifest", "orphan_node": "runtime"}, - "conflicts": [] + "total_entities": 6, + "enriched_count": 5, + "conflict_count": 0, + "conflicts": [], + "id_collisions": 0, + "filtered_by_gap_fill": 0 }, "linking": { "linked_count": 5, diff --git a/docs/tutorials/manifest-discovery.rst b/docs/tutorials/manifest-discovery.rst index 509f5460..c4a3f847 100644 --- a/docs/tutorials/manifest-discovery.rst +++ b/docs/tutorials/manifest-discovery.rst @@ -416,8 +416,9 @@ this behavior: # namespace_whitelist: [] # If set, only allow these namespaces When all ``allow_heuristic_*`` options are ``false``, only manifest-declared -entities appear. This is effectively the same as ``manifest_only`` mode but -with the benefit of runtime data (topics, services) on manifest entities. +entities appear. Runtime nodes are still discovered for linking, but no +heuristic entities (areas, components, apps, functions) are created from +unmatched namespaces or nodes. See :doc:`/config/discovery-options` for the full merge pipeline reference. diff --git a/docs/tutorials/plugin-system.rst b/docs/tutorials/plugin-system.rst index 2eef7f98..9bccee88 100644 --- a/docs/tutorials/plugin-system.rst +++ b/docs/tutorials/plugin-system.rst @@ -10,9 +10,10 @@ Overview Plugins implement the ``GatewayPlugin`` C++ base class plus one or more typed provider interfaces: - **UpdateProvider** - software update backend (CRUD, prepare/execute, automated, status) -- **IntrospectionProvider** - enriches discovered entities with platform-specific metadata - and can introduce new entities. Called during each discovery cycle by the merge pipeline's - PluginLayer. See :doc:`/config/discovery-options` for merge pipeline configuration. +- **IntrospectionProvider** - provides platform-specific metadata and can introduce new + entities into the entity cache. Called during each discovery cycle by the merge pipeline's + PluginLayer. Plugin-provided metadata is accessible via the plugin API, not automatically + merged into entity responses. See :doc:`/config/discovery-options` for merge pipeline configuration. - **LogProvider** - replaces or augments the default ``/rosout`` log backend. Can operate in observer mode (receives log entries) or full-ingestion mode (owns the entire log pipeline). See the ``/logs`` endpoints in :doc:`/api/rest`. @@ -67,7 +68,6 @@ Writing a Plugin #include "ros2_medkit_gateway/plugins/gateway_plugin.hpp" #include "ros2_medkit_gateway/plugins/plugin_types.hpp" #include "ros2_medkit_gateway/providers/update_provider.hpp" - #include "ros2_medkit_gateway/providers/log_provider.hpp" using namespace ros2_medkit_gateway; @@ -126,17 +126,7 @@ Writing a Plugin return static_cast(p); } - // Required if your plugin implements IntrospectionProvider: - extern "C" GATEWAY_PLUGIN_EXPORT IntrospectionProvider* get_introspection_provider(GatewayPlugin* p) { - return static_cast(p); - } - - // Required if your plugin implements LogProvider: - extern "C" GATEWAY_PLUGIN_EXPORT LogProvider* get_log_provider(GatewayPlugin* p) { - return static_cast(p); - } - -The ``get_update_provider`` and ``get_introspection_provider`` functions use ``extern "C"`` +The ``get_update_provider`` (and ``get_introspection_provider``, ``get_log_provider``) functions use ``extern "C"`` to avoid RTTI issues across shared library boundaries. The ``static_cast`` is safe because these functions execute inside the plugin's own ``.so`` where the type hierarchy is known. diff --git a/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp index b70946db..8a031026 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp @@ -180,9 +180,6 @@ void HealthHandlers::handle_root(const httplib::Request & req, httplib::Response "GET /api/v1/apps/{app_id}/logs/configuration", "PUT /api/v1/apps/{app_id}/logs/configuration", // Bulk Data - "GET /api/v1/areas/{area_id}/bulk-data", - "GET /api/v1/areas/{area_id}/bulk-data/{category}", - "GET /api/v1/areas/{area_id}/bulk-data/{category}/{item_id}", "GET /api/v1/components/{component_id}/bulk-data", "GET /api/v1/components/{component_id}/bulk-data/{category}", "GET /api/v1/components/{component_id}/bulk-data/{category}/{item_id}", @@ -193,9 +190,6 @@ void HealthHandlers::handle_root(const httplib::Request & req, httplib::Response "GET /api/v1/apps/{app_id}/bulk-data/{category}/{item_id}", "POST /api/v1/apps/{app_id}/bulk-data/{category}", "DELETE /api/v1/apps/{app_id}/bulk-data/{category}/{item_id}", - "GET /api/v1/functions/{function_id}/bulk-data", - "GET /api/v1/functions/{function_id}/bulk-data/{category}", - "GET /api/v1/functions/{function_id}/bulk-data/{category}/{item_id}", // Cyclic Subscriptions "POST /api/v1/components/{component_id}/cyclic-subscriptions", "GET /api/v1/components/{component_id}/cyclic-subscriptions", diff --git a/src/ros2_medkit_gateway/test/test_health_handlers.cpp b/src/ros2_medkit_gateway/test/test_health_handlers.cpp index 575b71ed..7030e23b 100644 --- a/src/ros2_medkit_gateway/test/test_health_handlers.cpp +++ b/src/ros2_medkit_gateway/test/test_health_handlers.cpp @@ -80,7 +80,7 @@ TEST_F(HealthHandlersTest, HandleHealthResponseIsValidJson) { // --- handle_version_info --- // @verifies REQ_INTEROP_001 -TEST_F(HealthHandlersTest, HandleVersionInfoContainsSovdInfoArray) { +TEST_F(HealthHandlersTest, HandleVersionInfoContainsItemsArray) { handlers_.handle_version_info(req_, res_); auto body = json::parse(res_.body); ASSERT_TRUE(body.contains("items")); diff --git a/src/ros2_medkit_integration_tests/test/features/test_snapshots_api.test.py b/src/ros2_medkit_integration_tests/test/features/test_snapshots_api.test.py index 41d6b228..43423242 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_snapshots_api.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_snapshots_api.test.py @@ -46,20 +46,23 @@ class TestSnapshotsApi(GatewayTestCase): MIN_EXPECTED_APPS = 1 REQUIRED_APPS = {'lidar_sensor'} - def test_root_endpoint_includes_snapshots(self): - """Root endpoint lists snapshots endpoints. + def test_root_endpoint_excludes_legacy_snapshots(self): + """Root endpoint does not list removed legacy snapshot endpoints. + + Legacy snapshot endpoints were removed in favor of inline snapshots + in fault responses and bulk-data endpoints. @verifies REQ_INTEROP_088 """ data = self.get_json('/') - # Verify snapshots endpoints are listed + # Verify legacy snapshot endpoints are NOT listed (removed) self.assertIn('endpoints', data) - self.assertIn( + self.assertNotIn( 'GET /api/v1/faults/{fault_code}/snapshots', data['endpoints'] ) - self.assertIn( + self.assertNotIn( 'GET /api/v1/components/{component_id}/faults/{fault_code}/snapshots', data['endpoints'] ) From 80091bc3c32bc688420da70c83820e2974fa405c Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 7 Mar 2026 19:40:32 +0100 Subject: [PATCH 08/10] fix: address self-review findings - rest.rst: add /version-info example response, remove stale `area` field from components list example - rest.rst: document sovd_info -> items rename in CHANGELOG as breaking change - discovery-options.rst: add local TOC, note case-sensitivity for policy values, fix strategy name "HybridDiscoveryStrategy" -> "hybrid" - manifest-discovery.rst, migration-to-manifest.rst: fix jq commands (.[] -> .items[]) - CHANGELOG.rst: add Breaking Changes section and new 0.3.0 features - test_plugin_vendor_extensions.test.py: add @verifies REQ_INTEROP_003 --- docs/api/rest.rst | 20 +++++++++++++++++-- docs/config/discovery-options.rst | 9 +++++++-- docs/tutorials/manifest-discovery.rst | 2 +- docs/tutorials/migration-to-manifest.rst | 2 +- src/ros2_medkit_gateway/CHANGELOG.rst | 15 ++++++++++++++ .../test_plugin_vendor_extensions.test.py | 5 ++++- 6 files changed, 46 insertions(+), 7 deletions(-) diff --git a/docs/api/rest.rst b/docs/api/rest.rst index 957b38d7..18dfda67 100644 --- a/docs/api/rest.rst +++ b/docs/api/rest.rst @@ -55,6 +55,23 @@ Server Capabilities ``GET /api/v1/version-info`` Get gateway version and status information. + **Example Response:** + + .. code-block:: json + + { + "items": [ + { + "version": "1.0.0", + "base_uri": "/api/v1", + "vendor_info": { + "version": "0.3.0", + "name": "ros2_medkit" + } + } + ] + } + ``GET /api/v1/health`` Health check endpoint. Returns HTTP 200 if gateway is operational. @@ -108,8 +125,7 @@ Components { "id": "temp_sensor", "name": "temp_sensor", - "href": "/api/v1/components/temp_sensor", - "area": "powertrain" + "href": "/api/v1/components/temp_sensor" } ] } diff --git a/docs/config/discovery-options.rst b/docs/config/discovery-options.rst index 3d1a551a..2f16877d 100644 --- a/docs/config/discovery-options.rst +++ b/docs/config/discovery-options.rst @@ -1,6 +1,10 @@ Discovery Options Reference =========================== +.. contents:: + :local: + :depth: 2 + This document describes configuration options for the gateway's discovery system. The discovery system maps ROS 2 graph entities (nodes, topics, services, actions) to SOVD entities (areas, components, apps). @@ -143,7 +147,8 @@ Each layer declares a policy per field-group: - Runtime: ``authoritative`` for live_data/status, ``enrichment`` for metadata, ``fallback`` for identity/hierarchy Override per-layer policies in ``gateway_params.yaml``. Empty string means -"use layer default": +"use layer default". Policy values are **case-sensitive** and must be lowercase +(``authoritative``, ``enrichment``, ``fallback``): .. code-block:: yaml @@ -220,7 +225,7 @@ pipeline stats, linking results, and merge warnings: "status": "healthy", "discovery": { "mode": "hybrid", - "strategy": "HybridDiscoveryStrategy", + "strategy": "hybrid", "pipeline": { "layers": ["manifest", "runtime", "plugin"], "total_entities": 6, diff --git a/docs/tutorials/manifest-discovery.rst b/docs/tutorials/manifest-discovery.rst index c4a3f847..382b2cbc 100644 --- a/docs/tutorials/manifest-discovery.rst +++ b/docs/tutorials/manifest-discovery.rst @@ -320,7 +320,7 @@ Check which apps are online: .. code-block:: bash - curl http://localhost:8080/api/v1/apps | jq '.[] | {id, name, is_online}' + curl http://localhost:8080/api/v1/apps | jq '.items[] | {id, name, is_online}' Example response: diff --git a/docs/tutorials/migration-to-manifest.rst b/docs/tutorials/migration-to-manifest.rst index 263993c3..b4eb3137 100644 --- a/docs/tutorials/migration-to-manifest.rst +++ b/docs/tutorials/migration-to-manifest.rst @@ -319,7 +319,7 @@ Step 7: Test in Hybrid Mode .. code-block:: bash - curl http://localhost:8080/api/v1/apps | jq '.[] | {id, name, is_online}' + curl http://localhost:8080/api/v1/apps | jq '.items[] | {id, name, is_online}' 5. **Check for orphan nodes** (warnings in gateway logs): diff --git a/src/ros2_medkit_gateway/CHANGELOG.rst b/src/ros2_medkit_gateway/CHANGELOG.rst index 660eff0c..b9b7e688 100644 --- a/src/ros2_medkit_gateway/CHANGELOG.rst +++ b/src/ros2_medkit_gateway/CHANGELOG.rst @@ -4,6 +4,21 @@ Changelog for package ros2_medkit_gateway 0.3.0 (2026-02-27) ------------------ + +**Breaking Changes:** + +* ``GET /version-info`` response key renamed from ``sovd_info`` to ``items`` for SOVD alignment (`#258 `_) +* ``GET /`` root endpoint restructured: ``endpoints`` is now a flat string array, added ``capabilities`` object, ``api_base`` field, and ``name``/``version`` top-level fields (`#258 `_) +* Default rosbag storage format changed from ``sqlite3`` to ``mcap`` (`#258 `_) + +**Features:** + +* Layered merge pipeline for hybrid discovery with per-layer, per-field-group merge policies (`#258 `_) +* Gap-fill configuration: control heuristic entity creation with ``allow_heuristic_*`` options and namespace filtering (`#258 `_) +* Plugin layer: ``IntrospectionProvider`` now wired into discovery pipeline via ``PluginLayer`` (`#258 `_) +* ``LogProvider`` plugin interface for custom log backends (`#258 `_) +* ``/health`` endpoint includes merge pipeline diagnostics (layers, conflicts, gap-fill stats) (`#258 `_) +* Entity detail responses now include ``logs``, ``bulk-data``, ``cyclic-subscriptions`` URIs (`#258 `_) * Gateway plugin framework with dynamic C++ plugin loading (`#237 `_) * Software updates plugin with 8 SOVD-compliant endpoints (`#237 `_, `#231 `_) * SSE-based periodic data subscriptions for real-time streaming without polling (`#223 `_) diff --git a/src/ros2_medkit_integration_tests/test/features/test_plugin_vendor_extensions.test.py b/src/ros2_medkit_integration_tests/test/features/test_plugin_vendor_extensions.test.py index 17372882..e0a0d260 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_plugin_vendor_extensions.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_plugin_vendor_extensions.test.py @@ -59,7 +59,10 @@ def generate_test_description(): class TestPluginVendorExtensions(GatewayTestCase): - """Vendor extension endpoint tests via test_gateway_plugin.""" + """Vendor extension endpoint tests via test_gateway_plugin. + + @verifies REQ_INTEROP_003 + """ MIN_EXPECTED_APPS = 2 REQUIRED_APPS = {'temp_sensor', 'rpm_sensor'} From 49f8d80dc345a02877d28034e806f1b98cabc077 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 7 Mar 2026 20:23:01 +0100 Subject: [PATCH 09/10] fix(discovery): prevent topic components leaking in manifest_only mode refresh_cache() was calling discover_topic_components() for both RUNTIME_ONLY and MANIFEST_ONLY modes. In manifest_only mode this added synthetic components from the runtime ROS 2 graph, violating the intent of "only manifest entities." Invert the condition so only RUNTIME_ONLY merges topic components. MANIFEST_ONLY and HYBRID both use discover_components() directly. --- src/ros2_medkit_gateway/src/gateway_node.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/ros2_medkit_gateway/src/gateway_node.cpp b/src/ros2_medkit_gateway/src/gateway_node.cpp index 234e42b7..31ffd5e7 100644 --- a/src/ros2_medkit_gateway/src/gateway_node.cpp +++ b/src/ros2_medkit_gateway/src/gateway_node.cpp @@ -620,15 +620,16 @@ void GatewayNode::refresh_cache() { auto functions = discovery_mgr_->discover_functions(); std::vector all_components; - if (discovery_mgr_->get_mode() == DiscoveryMode::HYBRID) { - // Pipeline already merges node and topic components - all_components = discovery_mgr_->discover_components(); - } else { + if (discovery_mgr_->get_mode() == DiscoveryMode::RUNTIME_ONLY) { + // RUNTIME_ONLY: merge node + topic components (no pipeline) auto node_components = discovery_mgr_->discover_components(); auto topic_components = discovery_mgr_->discover_topic_components(); all_components.reserve(node_components.size() + topic_components.size()); all_components.insert(all_components.end(), node_components.begin(), node_components.end()); all_components.insert(all_components.end(), topic_components.begin(), topic_components.end()); + } else { + // HYBRID: pipeline merges all sources; MANIFEST_ONLY: manifest components only + all_components = discovery_mgr_->discover_components(); } // Capture sizes for logging From 94818bb9955afd7024295706c85da5976ad23dc9 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 7 Mar 2026 20:53:49 +0100 Subject: [PATCH 10/10] test: rename SovdEntry tests to ItemsEntry, add manifest_only regression test Rename 3 unit tests referencing old "SovdEntry" naming to "ItemsEntry" to match the sovd_info->items rename in handle_version_info. Add regression test verifying that topic-based components do not leak into the entity cache in manifest_only discovery mode (validates the fix in gateway_node.cpp discover_components). --- .../test/test_health_handlers.cpp | 6 ++--- .../test_scenario_discovery_manifest.test.py | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/ros2_medkit_gateway/test/test_health_handlers.cpp b/src/ros2_medkit_gateway/test/test_health_handlers.cpp index 7030e23b..45ab65e3 100644 --- a/src/ros2_medkit_gateway/test/test_health_handlers.cpp +++ b/src/ros2_medkit_gateway/test/test_health_handlers.cpp @@ -89,7 +89,7 @@ TEST_F(HealthHandlersTest, HandleVersionInfoContainsItemsArray) { } // @verifies REQ_INTEROP_001 -TEST_F(HealthHandlersTest, HandleVersionInfoSovdEntryHasVersionField) { +TEST_F(HealthHandlersTest, HandleVersionInfoItemsEntryHasVersionField) { handlers_.handle_version_info(req_, res_); auto body = json::parse(res_.body); auto & entry = body["items"][0]; @@ -99,7 +99,7 @@ TEST_F(HealthHandlersTest, HandleVersionInfoSovdEntryHasVersionField) { } // @verifies REQ_INTEROP_001 -TEST_F(HealthHandlersTest, HandleVersionInfoSovdEntryHasBaseUri) { +TEST_F(HealthHandlersTest, HandleVersionInfoItemsEntryHasBaseUri) { handlers_.handle_version_info(req_, res_); auto body = json::parse(res_.body); auto & entry = body["items"][0]; @@ -107,7 +107,7 @@ TEST_F(HealthHandlersTest, HandleVersionInfoSovdEntryHasBaseUri) { } // @verifies REQ_INTEROP_001 -TEST_F(HealthHandlersTest, HandleVersionInfoSovdEntryHasVendorInfo) { +TEST_F(HealthHandlersTest, HandleVersionInfoItemsEntryHasVendorInfo) { handlers_.handle_version_info(req_, res_); auto body = json::parse(res_.body); auto & entry = body["items"][0]; diff --git a/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_discovery_manifest.test.py b/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_discovery_manifest.test.py index b10a5cb8..167bd9ff 100644 --- a/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_discovery_manifest.test.py +++ b/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_discovery_manifest.test.py @@ -361,6 +361,30 @@ def test_24_function_operations(self): data = self.get_json('/functions/engine-calibration/operations') self.assertIn('items', data) + # ========================================================================= + # Regression: no synthetic/topic components leak into manifest_only mode + # ========================================================================= + + def test_25_no_topic_components_in_manifest_only(self): + """Components list contains only manifest-defined IDs. + + Regression test: in manifest_only mode, runtime-discovered + topic-based components must not appear in the entity cache. + All component IDs must come from the manifest. + """ + manifest_component_ids = { + 'engine-ecu', 'temp-sensor-hw', 'rpm-sensor-hw', + 'brake-ecu', 'brake-pressure-sensor-hw', 'brake-actuator-hw', + 'door-sensor-hw', 'light-module', 'lidar-unit', + } + data = self.get_json('/components') + actual_ids = {c['id'] for c in data['items']} + extra = actual_ids - manifest_component_ids + self.assertEqual( + extra, set(), + f'Non-manifest components found in manifest_only mode: {extra}' + ) + # ========================================================================= # Error Cases # =========================================================================