From b9be38fd502ae8a76fdc416ab41ba87217deb888 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 7 Mar 2026 12:17:45 +0100 Subject: [PATCH 01/15] 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 1a45369e8..509f54601 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 4e7384e8344f5b129f0cc18b4427df489a8b44b4 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 7 Mar 2026 12:21:19 +0100 Subject: [PATCH 02/15] 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 da1584e88..dab3a9e63 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. @@ -260,6 +291,60 @@ Example: max_subscriptions: 100 max_duration_sec: 3600 +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 ---------------- @@ -371,6 +456,18 @@ Complete Example max_subscriptions: 100 max_duration_sec: 3600 + 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 494c36d040de8015b1983eb7a669fb00b6ccadc7 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 7 Mar 2026 12:21:28 +0100 Subject: [PATCH 03/15] 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 d8cc5bcdf..f9624c23f 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 571a262fc6811b68999b99c4185e2fab3f788fd5 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 7 Mar 2026 12:23:07 +0100 Subject: [PATCH 04/15] 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 793ac01d3..4bc24fa26 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 5e454b1e456a083eec6f2e4596860eabb85106f3 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 7 Mar 2026 12:23:53 +0100 Subject: [PATCH 05/15] 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 0562c690a..610ac92aa 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 @@ -337,12 +346,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 1d9a21b2798ecfb19a003d7617395f9651567190 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 7 Mar 2026 12:59:30 +0100 Subject: [PATCH 06/15] 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 4bc24fa26..9feae0471 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)*. @@ -1250,16 +1281,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 983958dc1..b20de2168 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 2f0645ea4..c06f7d1b5 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 043535836..b70946db0 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 8120aad66..9b89fb541 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 2dc953927..575b71ed4 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 b1aaeb59f..de67fc057 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 2461f9811..0f7871065 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 f9695238d23fd05cc71c13ce3791d3a20c848be2 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 7 Mar 2026 18:44:46 +0100 Subject: [PATCH 07/15] 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 9feae0471..10a9b7afb 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 f9624c23f..3d1a551a3 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 509f54601..c4a3f847b 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 610ac92aa..aa8ba7840 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 b70946db0..8a031026a 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 575b71ed4..7030e23ba 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 41d6b2285..434232428 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 b358a41df41695e687c2af4b981f2386e61e5029 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 7 Mar 2026 19:40:32 +0100 Subject: [PATCH 08/15] 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 10a9b7afb..de43ec61e 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 3d1a551a3..2f16877d5 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 c4a3f847b..382b2cbc4 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 263993c3a..b4eb31371 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 660eff0cf..b9b7e688c 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 173728829..e0a0d260c 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 a7eee5aaed9d4d78365a34352445c8611478361d Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 7 Mar 2026 20:23:01 +0100 Subject: [PATCH 09/15] 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 aa81bf86f..9bc48ef21 100644 --- a/src/ros2_medkit_gateway/src/gateway_node.cpp +++ b/src/ros2_medkit_gateway/src/gateway_node.cpp @@ -809,15 +809,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 e745c3f46c000e4f3c5d20d85125bb9531febf5b Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 7 Mar 2026 20:53:49 +0100 Subject: [PATCH 10/15] 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 7030e23ba..45ab65e35 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 b10a5cb8a..167bd9ff2 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 # ========================================================================= From 04d8b3b05875e65a7a5acecd4c13795061755415 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Fri, 13 Mar 2026 07:43:19 +0100 Subject: [PATCH 11/15] feat: extend entity capabilities and add log endpoints for areas/functions Enable resource collections (data, operations, configurations, faults, logs, bulk-data) on areas and functions. SOVD defines these only for apps/components - this is a pragmatic ros2_medkit extension. Add log routes for areas (namespace prefix match) and functions (aggregate from hosted apps). Update capability responses to include logs and bulk-data URIs. Fix entity_capabilities.cpp to match actual route registrations. --- .../http/handlers/log_handlers.hpp | 7 ++- .../src/http/handlers/discovery_handlers.cpp | 10 ++- .../src/http/handlers/health_handlers.cpp | 6 ++ .../src/http/handlers/log_handlers.cpp | 63 ++++++++++++++++++- .../src/http/rest_server.cpp | 35 +++++++++++ .../src/models/entity_capabilities.cpp | 25 +++++--- .../test/test_entity_resource_model.cpp | 31 ++++++--- .../test/features/test_hateoas.test.py | 42 +++++++++++++ .../test/features/test_logging_api.test.py | 36 +++++++++++ .../test_scenario_discovery_manifest.test.py | 61 +++++++++++++++++- 10 files changed, 288 insertions(+), 28 deletions(-) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/log_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/log_handlers.hpp index 9a968e261..eb35a3eed 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/log_handlers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/log_handlers.hpp @@ -27,14 +27,15 @@ namespace handlers { * - GET /{entity-path}/logs/configuration - Get log configuration for an entity * - PUT /{entity-path}/logs/configuration - Update log configuration for an entity * - * Supported entity types: components, apps. + * Supported entity types: components, apps, areas, functions. * * Log entries are sourced from the /rosout ring buffer by default. * If a LogProvider plugin is registered, queries are delegated to it. * * @par Component vs App semantics - * Components use prefix matching: all nodes whose FQN starts with the - * component namespace are included. Apps use exact FQN matching. + * Components and areas use prefix matching: all nodes whose FQN starts + * with the entity namespace are included. Apps use exact FQN matching. + * Functions aggregate logs from all hosted apps. */ class LogHandlers { public: 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 c06f7d1b5..d252a2735 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp @@ -149,10 +149,12 @@ void DiscoveryHandlers::handle_get_area(const httplib::Request & req, httplib::R 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"; using Cap = CapabilityBuilder::Capability; - std::vector caps = {Cap::SUBAREAS, Cap::CONTAINS, Cap::DATA, - Cap::OPERATIONS, Cap::CONFIGURATIONS, Cap::FAULTS}; + std::vector caps = {Cap::SUBAREAS, Cap::CONTAINS, Cap::DATA, Cap::OPERATIONS, + Cap::CONFIGURATIONS, Cap::FAULTS, Cap::LOGS}; response["capabilities"] = CapabilityBuilder::build_capabilities("areas", area.id, caps); append_plugin_capabilities(response["capabilities"], "areas", area.id, SovdEntityType::AREA, ctx_.node()); @@ -1020,9 +1022,11 @@ void DiscoveryHandlers::handle_get_function(const httplib::Request & req, httpli 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"; using Cap = CapabilityBuilder::Capability; - std::vector caps = {Cap::HOSTS, Cap::DATA, Cap::OPERATIONS, Cap::CONFIGURATIONS, Cap::FAULTS}; + std::vector caps = {Cap::HOSTS, Cap::DATA, Cap::OPERATIONS, Cap::CONFIGURATIONS, Cap::FAULTS, Cap::LOGS}; response["capabilities"] = CapabilityBuilder::build_capabilities("functions", func.id, caps); append_plugin_capabilities(response["capabilities"], "functions", func.id, SovdEntityType::FUNCTION, ctx_.node()); 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 8a031026a..884e48176 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp @@ -179,6 +179,12 @@ void HealthHandlers::handle_root(const httplib::Request & req, httplib::Response "GET /api/v1/apps/{app_id}/logs", "GET /api/v1/apps/{app_id}/logs/configuration", "PUT /api/v1/apps/{app_id}/logs/configuration", + "GET /api/v1/areas/{area_id}/logs", + "GET /api/v1/areas/{area_id}/logs/configuration", + "PUT /api/v1/areas/{area_id}/logs/configuration", + "GET /api/v1/functions/{function_id}/logs", + "GET /api/v1/functions/{function_id}/logs/configuration", + "PUT /api/v1/functions/{function_id}/logs/configuration", // Bulk Data "GET /api/v1/components/{component_id}/bulk-data", "GET /api/v1/components/{component_id}/bulk-data/{category}", diff --git a/src/ros2_medkit_gateway/src/http/handlers/log_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/log_handlers.cpp index be2aaddb2..a74d88742 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/log_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/log_handlers.cpp @@ -51,9 +51,66 @@ void LogHandlers::handle_get_logs(const httplib::Request & req, httplib::Respons return; } - // Components use prefix matching (all nodes under the component namespace); - // Apps use exact matching (single node FQN). - const bool prefix_match = (entity.type == EntityType::COMPONENT); + // Components and areas use prefix matching (all nodes under the namespace); + // Apps use exact matching (single node FQN); + // Functions aggregate logs from all hosted apps. + if (entity.type == EntityType::FUNCTION) { + // Aggregate logs from all hosted apps + const auto & cache = ctx_.node()->get_thread_safe_cache(); + auto func = cache.get_function(entity_id); + if (!func || func->hosts.empty()) { + json result; + result["items"] = json::array(); + HandlerContext::send_json(res, result); + return; + } + + // Collect FQNs of hosted apps + std::vector host_fqns; + for (const auto & app_id : func->hosts) { + auto app = cache.get_app(app_id); + if (app && app->bound_fqn.has_value() && !app->bound_fqn->empty()) { + host_fqns.push_back(*app->bound_fqn); + } + } + + if (host_fqns.empty()) { + json result; + result["items"] = json::array(); + HandlerContext::send_json(res, result); + return; + } + + // Optional query parameters + const std::string min_severity = req.get_param_value("severity"); + const std::string context_filter = req.get_param_value("context"); + + if (!min_severity.empty() && !LogManager::is_valid_severity(min_severity)) { + HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, + "Invalid severity value: must be one of debug, info, warning, error, fatal"); + return; + } + + static constexpr size_t kMaxContextFilterLen = 256; + if (context_filter.size() > kMaxContextFilterLen) { + HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "context filter exceeds maximum length of 256"); + return; + } + + // get_logs accepts multiple FQNs with exact match - one call for all hosts + auto logs = log_mgr->get_logs(host_fqns, false, min_severity, context_filter, entity_id); + if (!logs) { + HandlerContext::send_error(res, 503, ERR_SERVICE_UNAVAILABLE, logs.error()); + return; + } + + json result; + result["items"] = std::move(*logs); + HandlerContext::send_json(res, result); + return; + } + + const bool prefix_match = (entity.type == EntityType::COMPONENT || entity.type == EntityType::AREA); // Optional query parameters const std::string min_severity = req.get_param_value("severity"); diff --git a/src/ros2_medkit_gateway/src/http/rest_server.cpp b/src/ros2_medkit_gateway/src/http/rest_server.cpp index 825d6f76d..b8b6c8313 100644 --- a/src/ros2_medkit_gateway/src/http/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/http/rest_server.cpp @@ -860,6 +860,41 @@ void RESTServer::setup_routes() { log_handlers_->handle_put_logs_configuration(req, res); }); + // GET /areas/{id}/logs - query log entries for an area (prefix match on namespace) + srv->Get((api_path("/areas") + R"(/([^/]+)/logs$)"), [this](const httplib::Request & req, httplib::Response & res) { + log_handlers_->handle_get_logs(req, res); + }); + + // GET /areas/{id}/logs/configuration + srv->Get((api_path("/areas") + R"(/([^/]+)/logs/configuration$)"), + [this](const httplib::Request & req, httplib::Response & res) { + log_handlers_->handle_get_logs_configuration(req, res); + }); + + // PUT /areas/{id}/logs/configuration + srv->Put((api_path("/areas") + R"(/([^/]+)/logs/configuration$)"), + [this](const httplib::Request & req, httplib::Response & res) { + log_handlers_->handle_put_logs_configuration(req, res); + }); + + // GET /functions/{id}/logs - query log entries for a function (aggregated from hosted apps) + srv->Get((api_path("/functions") + R"(/([^/]+)/logs$)"), + [this](const httplib::Request & req, httplib::Response & res) { + log_handlers_->handle_get_logs(req, res); + }); + + // GET /functions/{id}/logs/configuration + srv->Get((api_path("/functions") + R"(/([^/]+)/logs/configuration$)"), + [this](const httplib::Request & req, httplib::Response & res) { + log_handlers_->handle_get_logs_configuration(req, res); + }); + + // PUT /functions/{id}/logs/configuration + srv->Put((api_path("/functions") + R"(/([^/]+)/logs/configuration$)"), + [this](const httplib::Request & req, httplib::Response & res) { + log_handlers_->handle_put_logs_configuration(req, res); + }); + // === Bulk Data Routes (REQ_INTEROP_071-073) === // List bulk-data categories srv->Get((api_path("/apps") + R"(/([^/]+)/bulk-data$)"), diff --git a/src/ros2_medkit_gateway/src/models/entity_capabilities.cpp b/src/ros2_medkit_gateway/src/models/entity_capabilities.cpp index 8e6bfd24c..9691048fc 100644 --- a/src/ros2_medkit_gateway/src/models/entity_capabilities.cpp +++ b/src/ros2_medkit_gateway/src/models/entity_capabilities.cpp @@ -34,9 +34,16 @@ EntityCapabilities EntityCapabilities::for_type(SovdEntityType type) { break; case SovdEntityType::AREA: - // AREA does NOT support resource collections per SOVD spec - // Only navigation/relationship resources - caps.collections_ = {}; + // ros2_medkit extension: areas support resource collections via aggregation + // (SOVD spec defines collections only for apps/components) + caps.collections_ = { + ResourceCollection::DATA, ResourceCollection::OPERATIONS, ResourceCollection::CONFIGURATIONS, + ResourceCollection::FAULTS, ResourceCollection::LOGS, ResourceCollection::BULK_DATA, + }; + caps.aggregated_collections_ = { + ResourceCollection::DATA, ResourceCollection::OPERATIONS, ResourceCollection::CONFIGURATIONS, + ResourceCollection::FAULTS, ResourceCollection::LOGS, + }; caps.resources_ = {"docs", "contains", "subareas", "related-components"}; break; @@ -66,15 +73,15 @@ EntityCapabilities EntityCapabilities::for_type(SovdEntityType type) { break; case SovdEntityType::FUNCTION: - // FUNCTION only supports data and operations (aggregated from Apps) + // ros2_medkit extension: functions support additional collections via aggregation + // (SOVD spec only defines data/operations for functions) caps.collections_ = { - ResourceCollection::DATA, - ResourceCollection::OPERATIONS, + ResourceCollection::DATA, ResourceCollection::OPERATIONS, ResourceCollection::CONFIGURATIONS, + ResourceCollection::FAULTS, ResourceCollection::LOGS, ResourceCollection::BULK_DATA, }; - // Mark these as aggregated caps.aggregated_collections_ = { - ResourceCollection::DATA, - ResourceCollection::OPERATIONS, + ResourceCollection::DATA, ResourceCollection::OPERATIONS, ResourceCollection::CONFIGURATIONS, + ResourceCollection::FAULTS, ResourceCollection::LOGS, }; caps.resources_ = {"docs", "hosts", "depends-on"}; break; diff --git a/src/ros2_medkit_gateway/test/test_entity_resource_model.cpp b/src/ros2_medkit_gateway/test/test_entity_resource_model.cpp index 2c731bf64..b61b0c778 100644 --- a/src/ros2_medkit_gateway/test/test_entity_resource_model.cpp +++ b/src/ros2_medkit_gateway/test/test_entity_resource_model.cpp @@ -135,12 +135,20 @@ TEST(EntityCapabilities, ServerSupportsAllCollections) { EXPECT_TRUE(caps.supports_collection(ResourceCollection::OPERATIONS)); } -TEST(EntityCapabilities, AreaDoesNotSupportCollections) { +TEST(EntityCapabilities, AreaSupportsCollectionsViaAggregation) { auto caps = EntityCapabilities::for_type(SovdEntityType::AREA); - EXPECT_FALSE(caps.supports_collection(ResourceCollection::CONFIGURATIONS)); - EXPECT_FALSE(caps.supports_collection(ResourceCollection::DATA)); - EXPECT_FALSE(caps.supports_collection(ResourceCollection::FAULTS)); - EXPECT_FALSE(caps.supports_collection(ResourceCollection::OPERATIONS)); + // ros2_medkit extension: areas support resource collections via aggregation + EXPECT_TRUE(caps.supports_collection(ResourceCollection::CONFIGURATIONS)); + EXPECT_TRUE(caps.supports_collection(ResourceCollection::DATA)); + EXPECT_TRUE(caps.supports_collection(ResourceCollection::FAULTS)); + EXPECT_TRUE(caps.supports_collection(ResourceCollection::OPERATIONS)); + EXPECT_TRUE(caps.supports_collection(ResourceCollection::LOGS)); + EXPECT_TRUE(caps.supports_collection(ResourceCollection::BULK_DATA)); + EXPECT_TRUE(caps.is_aggregated(ResourceCollection::DATA)); + EXPECT_TRUE(caps.is_aggregated(ResourceCollection::OPERATIONS)); + EXPECT_TRUE(caps.is_aggregated(ResourceCollection::CONFIGURATIONS)); + EXPECT_TRUE(caps.is_aggregated(ResourceCollection::FAULTS)); + EXPECT_TRUE(caps.is_aggregated(ResourceCollection::LOGS)); } TEST(EntityCapabilities, AreaSupportsContains) { @@ -165,13 +173,20 @@ TEST(EntityCapabilities, AppSupportsIsLocatedOn) { EXPECT_FALSE(caps.supports_resource("hosts")); // Apps don't host anything } -TEST(EntityCapabilities, FunctionAggregatesOperations) { +TEST(EntityCapabilities, FunctionAggregatesCollections) { auto caps = EntityCapabilities::for_type(SovdEntityType::FUNCTION); + // ros2_medkit extension: functions support additional collections via aggregation EXPECT_TRUE(caps.supports_collection(ResourceCollection::DATA)); EXPECT_TRUE(caps.supports_collection(ResourceCollection::OPERATIONS)); + EXPECT_TRUE(caps.supports_collection(ResourceCollection::CONFIGURATIONS)); + EXPECT_TRUE(caps.supports_collection(ResourceCollection::FAULTS)); + EXPECT_TRUE(caps.supports_collection(ResourceCollection::LOGS)); + EXPECT_TRUE(caps.supports_collection(ResourceCollection::BULK_DATA)); EXPECT_TRUE(caps.is_aggregated(ResourceCollection::DATA)); EXPECT_TRUE(caps.is_aggregated(ResourceCollection::OPERATIONS)); - EXPECT_FALSE(caps.supports_collection(ResourceCollection::CONFIGURATIONS)); + EXPECT_TRUE(caps.is_aggregated(ResourceCollection::CONFIGURATIONS)); + EXPECT_TRUE(caps.is_aggregated(ResourceCollection::FAULTS)); + EXPECT_TRUE(caps.is_aggregated(ResourceCollection::LOGS)); } TEST(EntityCapabilities, UnknownTypeHasNoCapabilities) { @@ -580,7 +595,7 @@ TEST_F(AggregationServiceTest, SupportsOperationsCheckCorrect) { EXPECT_TRUE(AggregationService::supports_operations(SovdEntityType::COMPONENT)); EXPECT_TRUE(AggregationService::supports_operations(SovdEntityType::APP)); EXPECT_TRUE(AggregationService::supports_operations(SovdEntityType::FUNCTION)); - EXPECT_FALSE(AggregationService::supports_operations(SovdEntityType::AREA)); + EXPECT_TRUE(AggregationService::supports_operations(SovdEntityType::AREA)); } TEST_F(AggregationServiceTest, ShouldAggregateCheckCorrect) { diff --git a/src/ros2_medkit_integration_tests/test/features/test_hateoas.test.py b/src/ros2_medkit_integration_tests/test/features/test_hateoas.test.py index 26e72f7a2..3a61c2f42 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_hateoas.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_hateoas.test.py @@ -173,6 +173,48 @@ def test_app_detail_has_capability_uris(self): self.assertEqual(data['operations'], f'{base}/operations') self.assertEqual(data['configurations'], f'{base}/configurations') + def test_area_detail_has_capability_uris(self): + """GET /areas/{id} returns capability URIs including logs and bulk-data. + + ros2_medkit extension: areas support resource collections beyond + SOVD spec (which only defines them for apps/components). + + @verifies REQ_INTEROP_003 + """ + areas = self.get_json('/areas')['items'] + self.assertGreater(len(areas), 0) + + area_id = areas[0]['id'] + data = self.get_json(f'/areas/{area_id}') + + base = f'/api/v1/areas/{area_id}' + self.assertIn('data', data, 'Area should have data URI') + self.assertEqual(data['data'], f'{base}/data') + self.assertIn('logs', data, 'Area should have logs URI') + self.assertEqual(data['logs'], f'{base}/logs') + self.assertIn('bulk-data', data, 'Area should have bulk-data URI') + self.assertEqual(data['bulk-data'], f'{base}/bulk-data') + + def test_function_detail_has_capability_uris(self): + """GET /functions/{id} returns capability URIs including logs and bulk-data. + + @verifies REQ_INTEROP_003 + """ + functions = self.get_json('/functions')['items'] + if not functions: + self.skipTest('No functions available') + + func_id = functions[0]['id'] + data = self.get_json(f'/functions/{func_id}') + + base = f'/api/v1/functions/{func_id}' + self.assertIn('data', data, 'Function should have data URI') + self.assertEqual(data['data'], f'{base}/data') + self.assertIn('logs', data, 'Function should have logs URI') + self.assertEqual(data['logs'], f'{base}/logs') + self.assertIn('bulk-data', data, 'Function should have bulk-data URI') + self.assertEqual(data['bulk-data'], f'{base}/bulk-data') + # ------------------------------------------------------------------ # Subareas and subcomponents (test_75-76) # ------------------------------------------------------------------ diff --git a/src/ros2_medkit_integration_tests/test/features/test_logging_api.test.py b/src/ros2_medkit_integration_tests/test/features/test_logging_api.test.py index 23f8b15d8..d8f5d8d72 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_logging_api.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_logging_api.test.py @@ -290,6 +290,42 @@ def test_component_put_logs_configuration_returns_204(self): data = self.get_json(f'/components/{comp_id}/logs/configuration') self.assertEqual(data['severity_filter'], 'info') + # ------------------------------------------------------------------ + # GET /areas/{id}/logs (prefix match on area namespace) + # ------------------------------------------------------------------ + + def test_area_get_logs_returns_200(self): + """GET /areas/{id}/logs returns 200 with items array. + + Areas use namespace prefix matching - all nodes under the area + namespace are included. This is a ros2_medkit extension (SOVD + defines resource collections only for apps/components). + + # @verifies REQ_INTEROP_061 + """ + areas = self.get_json('/areas')['items'] + if not areas: + self.skipTest('No areas available in runtime discovery mode') + area_id = areas[0]['id'] + + data = self.get_json(f'/areas/{area_id}/logs') + self.assertIn('items', data) + self.assertIsInstance(data['items'], list) + + def test_area_get_logs_configuration_returns_200(self): + """GET /areas/{id}/logs/configuration returns default config. + + # @verifies REQ_INTEROP_063 + """ + areas = self.get_json('/areas')['items'] + if not areas: + self.skipTest('No areas available in runtime discovery mode') + area_id = areas[0]['id'] + + data = self.get_json(f'/areas/{area_id}/logs/configuration') + self.assertIn('severity_filter', data) + self.assertIn('max_entries', data) + # ------------------------------------------------------------------ # GET /apps/{id}/logs?context= (context filter) # ------------------------------------------------------------------ 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 167bd9ff2..7b7427cbf 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 @@ -385,11 +385,68 @@ def test_25_no_topic_components_in_manifest_only(self): f'Non-manifest components found in manifest_only mode: {extra}' ) + # ========================================================================= + # Function + Area logs (ros2_medkit extension) + # ========================================================================= + + def test_26_function_logs_returns_200(self): + """GET /functions/{id}/logs returns 200 with items array. + + Function logs aggregate from all hosted apps. engine-monitoring + is hosted by engine-temp-sensor and engine-rpm-sensor. + """ + data = self.get_json('/functions/engine-monitoring/logs') + self.assertIn('items', data) + self.assertIsInstance(data['items'], list) + + def test_27_function_logs_has_entries_from_hosts(self): + """GET /functions/{id}/logs returns entries from hosted apps. + + Poll until log entries appear from at least one hosted app. + """ + data = self.poll_endpoint_until( + '/functions/engine-monitoring/logs', + condition=lambda d: d if d.get('items') else None, + timeout=15.0, + ) + self.assertGreater( + len(data['items']), 0, + 'Expected log entries aggregated from hosted apps' + ) + + def test_28_function_logs_configuration_returns_200(self): + """GET /functions/{id}/logs/configuration returns default config.""" + data = self.get_json('/functions/engine-monitoring/logs/configuration') + self.assertIn('severity_filter', data) + self.assertIn('max_entries', data) + + def test_29_area_logs_returns_200(self): + """GET /areas/{id}/logs returns 200 with items array. + + Area logs use namespace prefix matching. powertrain area should + capture logs from nodes in /powertrain/* namespace tree. + """ + data = self.get_json('/areas/powertrain/logs') + self.assertIn('items', data) + self.assertIsInstance(data['items'], list) + + def test_30_area_logs_has_entries(self): + """GET /areas/{id}/logs returns entries from nodes in area namespace.""" + data = self.poll_endpoint_until( + '/areas/powertrain/logs', + condition=lambda d: d if d.get('items') else None, + timeout=15.0, + ) + self.assertGreater( + len(data['items']), 0, + 'Expected log entries from nodes in powertrain namespace' + ) + # ========================================================================= # Error Cases # ========================================================================= - def test_26_invalid_area_id(self): + def test_31_invalid_area_id(self): """GET /areas/{id} with invalid ID format returns 400. Entity IDs only allow alphanumeric, underscore, and hyphen characters. @@ -400,7 +457,7 @@ def test_26_invalid_area_id(self): ) self.assertEqual(response.status_code, 400) - def test_27_invalid_component_id(self): + def test_32_invalid_component_id(self): """GET /components/{id} with path traversal returns 404.""" response = requests.get( f'{self.BASE_URL}/components/../etc/passwd', timeout=5 From 0abfa01c1c5dab74739691c59f8e2a8410f1f283 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Fri, 13 Mar 2026 07:45:47 +0100 Subject: [PATCH 12/15] docs: add SOVD compliance philosophy and fix resource collection claims Document ros2_medkit's pragmatic approach to SOVD - we extend the spec where ROS 2 use cases benefit (resource collections on areas/functions, x-medkit vendor extensions). Add resource collection support matrix. Fix incorrect claims about areas supporting same collections as components. Add changelog entries for area/function log endpoints. --- docs/api/rest.rst | 107 +++++++++++++++++++++----- src/ros2_medkit_gateway/CHANGELOG.rst | 3 + 2 files changed, 91 insertions(+), 19 deletions(-) diff --git a/docs/api/rest.rst b/docs/api/rest.rst index de43ec61e..148644653 100644 --- a/docs/api/rest.rst +++ b/docs/api/rest.rst @@ -107,8 +107,12 @@ 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``, and ``/faults``. See the corresponding sections below. + .. note:: + + **ros2_medkit extension:** Areas support resource collections beyond the SOVD spec, + which only defines them for apps and components. Areas provide ``/data``, ``/operations``, + ``/configurations``, ``/faults``, ``/logs`` (namespace prefix aggregation), and read-only + ``/bulk-data``. See :ref:`sovd-compliance` for details. Components ~~~~~~~~~~ @@ -165,8 +169,12 @@ Functions ``GET /api/v1/functions/{function_id}/hosts`` List apps that host this function. - Functions also support ``/data``, ``/operations``, ``/configurations``, and ``/faults``. - See the corresponding sections below. + .. note:: + + **ros2_medkit extension:** Functions support resource collections beyond the SOVD spec. + ``/data`` and ``/operations`` aggregate from hosted apps (per SOVD). Additionally, + ``/configurations``, ``/faults``, ``/logs`` aggregate from hosts, and read-only + ``/bulk-data`` is available. See :ref:`sovd-compliance` for details. Data Endpoints -------------- @@ -1285,34 +1293,95 @@ Topic and parameter paths containing ``/`` must be URL-encoded: * - ``/chassis/brakes/command`` - ``chassis%2Fbrakes%2Fcommand`` +.. _sovd-compliance: + SOVD Compliance ---------------- +~~~~~~~~~~~~~~~ -The gateway implements a subset of the SOVD (Service-Oriented Vehicle Diagnostics) specification: +The gateway implements a **pragmatic subset** of the SOVD (Service-Oriented Vehicle +Diagnostics) standard. We follow SOVD where it matters for interoperability - +endpoint contracts, data model, entity hierarchy - but extend it where ROS 2 +use cases benefit. -**SOVD-Compliant Endpoints:** +**SOVD-Aligned Capabilities:** - Discovery (``/areas``, ``/components``, ``/apps``, ``/functions``) -- Data access (``/data``) -- Operations (``/operations``, ``/executions``) +- Data access (``/data``) with topic sampling and JSON serialization +- Operations (``/operations``, ``/executions``) with async action support - 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) +- Bulk Data (``/bulk-data``) with custom categories and rosbag downloads - Software Updates (``/updates``) with async prepare/execute lifecycle -- Cyclic Subscriptions (``/cyclic-subscriptions``) with SSE-based periodic data delivery +- Cyclic Subscriptions (``/cyclic-subscriptions``) with SSE-based delivery -**ros2_medkit Extensions:** +**Pragmatic Extensions:** -- ``/health`` - Health check with discovery pipeline stats -- ``/version-info`` - Gateway version information +The SOVD spec defines resource collections only for apps and components. ros2_medkit +extends this to areas and functions where aggregation makes practical sense: + +.. list-table:: Resource Collection Support Matrix + :header-rows: 1 + :widths: 20 16 16 16 16 16 + + * - Resource + - Areas + - Components + - Apps + - Functions + - SOVD Spec + * - data + - aggregated + - yes + - yes + - aggregated + - apps, components + * - operations + - aggregated + - yes + - yes + - aggregated + - apps, components + * - configurations + - aggregated + - yes + - yes + - aggregated + - apps, components + * - faults + - aggregated + - yes + - yes + - aggregated + - apps, components + * - logs + - prefix match + - prefix match + - exact match + - from hosts + - apps, components + * - bulk-data + - read-only + - full CRUD + - full CRUD + - read-only + - apps, components + * - cyclic-subscriptions + - \- + - yes + - yes + - \- + - apps, components + +Other extensions beyond SOVD: + +- Vendor extension fields using ``x-medkit`` prefix (per SOVD extension mechanism) - ``DELETE /faults`` - Clear all faults globally - ``GET /faults/stream`` - SSE real-time fault notifications -- ``x-medkit`` extension fields in responses +- ``/health`` - Health check with discovery pipeline diagnostics +- ``/version-info`` - Gateway version information See Also --------- +^^^^^^^^ -- :doc:`/tutorials/authentication` - Configure authentication -- :doc:`/config/server` - Server configuration options -- `Postman Collection `_ - Interactive API testing +- :doc:`/config/discovery-options` for merge pipeline configuration diff --git a/src/ros2_medkit_gateway/CHANGELOG.rst b/src/ros2_medkit_gateway/CHANGELOG.rst index b9b7e688c..4929c4a3f 100644 --- a/src/ros2_medkit_gateway/CHANGELOG.rst +++ b/src/ros2_medkit_gateway/CHANGELOG.rst @@ -19,6 +19,9 @@ Changelog for package ros2_medkit_gateway * ``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 `_) +* Area and function log endpoints with namespace aggregation and host-based aggregation (`#258 `_) +* Entity capabilities fix: areas and functions now report correct resource collections (`#258 `_) +* SOVD compliance documentation with resource collection support matrix (`#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 `_) From 478233e3f0500826dbfc25b1dacaa391a7b66482 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Fri, 13 Mar 2026 08:12:17 +0100 Subject: [PATCH 13/15] fix: address review findings - sampler scoping, log dedup, handle_root, docs Code fixes: - Fix faults sampler to scope by entity type (AREA: namespace, FUNCTION: host FQNs, COMPONENT: app FQNs) matching REST handler behavior - Fix logs sampler to use prefix/exact matching per entity type, matching log_handlers.cpp scoping logic - Hoist duplicated severity/context parameter validation in log_handlers - Add area/function bulk-data endpoints to handle_root endpoint list Docs fixes: - Fix SOVD Compliance RST heading level (~~~ -> --- for h2) - Update Logs Endpoints section to mention areas and functions - Restore See Also cross-references (authentication, server config) - Fix em dashes to hyphens in log configuration section --- docs/api/rest.rst | 13 +- src/ros2_medkit_gateway/src/gateway_node.cpp | 197 ++++++++++++++++-- .../src/http/handlers/health_handlers.cpp | 6 + .../src/http/handlers/log_handlers.cpp | 48 ++--- 4 files changed, 205 insertions(+), 59 deletions(-) diff --git a/docs/api/rest.rst b/docs/api/rest.rst index 148644653..14e76d319 100644 --- a/docs/api/rest.rst +++ b/docs/api/rest.rst @@ -549,7 +549,8 @@ Logs Endpoints -------------- Query and configure the /rosout ring buffer for an entity. Supported entity types: -**components** and **apps**. +**areas** (namespace prefix match), **components** (namespace prefix match), **apps** (exact FQN match), +and **functions** (aggregated from hosted apps). .. note:: @@ -653,10 +654,10 @@ The ``context.function``, ``context.file``, and ``context.line`` fields are omit "max_entries": 500 } - ``severity_filter`` — minimum severity to return in query results (``debug`` | ``info`` | ``warning`` | + ``severity_filter`` - minimum severity to return in query results (``debug`` | ``info`` | ``warning`` | ``error`` | ``fatal``). Entries below this level are excluded from queries. Default: ``debug``. - ``max_entries`` — maximum number of entries returned per query. Must be between 1 and 10,000 + ``max_entries`` - maximum number of entries returned per query. Must be between 1 and 10,000 (inclusive). Default: ``100``. **Response 204:** No content. @@ -1296,7 +1297,7 @@ Topic and parameter paths containing ``/`` must be URL-encoded: .. _sovd-compliance: SOVD Compliance -~~~~~~~~~~~~~~~ +--------------- The gateway implements a **pragmatic subset** of the SOVD (Service-Oriented Vehicle Diagnostics) standard. We follow SOVD where it matters for interoperability - @@ -1382,6 +1383,8 @@ Other extensions beyond SOVD: - ``/version-info`` - Gateway version information See Also -^^^^^^^^ +~~~~~~~~ - :doc:`/config/discovery-options` for merge pipeline configuration +- :doc:`/tutorials/authentication` - Configure authentication +- :doc:`/config/server` - Server configuration options diff --git a/src/ros2_medkit_gateway/src/gateway_node.cpp b/src/ros2_medkit_gateway/src/gateway_node.cpp index 9bc48ef21..63e5f2d7c 100644 --- a/src/ros2_medkit_gateway/src/gateway_node.cpp +++ b/src/ros2_medkit_gateway/src/gateway_node.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include "ros2_medkit_gateway/http/handlers/sse_transport_provider.hpp" @@ -572,28 +573,122 @@ GatewayNode::GatewayNode() : Node("ros2_medkit_gateway") { if (!fault_mgr) { return tl::make_unexpected(std::string("FaultManager not available")); } - // Determine source_id for fault filtering based on entity type + // Determine fault filtering based on entity type (mirrors fault_handlers.cpp logic) const auto & cache = get_thread_safe_cache(); auto entity_ref = cache.find_entity(entity_id); - std::string source_id; - if (entity_ref) { - if (entity_ref->type == SovdEntityType::APP) { - auto app = cache.get_app(entity_id); - if (app) { - source_id = app->bound_fqn.value_or(""); + if (!entity_ref) { + return tl::make_unexpected(std::string("Entity not found: " + entity_id)); + } + + if (entity_ref->type == SovdEntityType::FUNCTION) { + // FUNCTION: get all faults, filter by host app FQNs + auto result = fault_mgr->list_faults(""); + if (!result.success) { + return tl::make_unexpected(result.error_message); + } + // Collect host app FQNs + auto func = cache.get_function(entity_id); + if (!func || func->hosts.empty()) { + nlohmann::json empty_result = {{"faults", nlohmann::json::array()}, {"count", 0}}; + return empty_result; + } + std::set host_fqns; + for (const auto & app_id : func->hosts) { + auto app = cache.get_app(app_id); + if (app && app->bound_fqn.has_value() && !app->bound_fqn->empty()) { + host_fqns.insert(*app->bound_fqn); } - } else if (entity_ref->type == SovdEntityType::COMPONENT) { - auto comp = cache.get_component(entity_id); - if (comp) { - source_id = comp->namespace_path; + } + // Filter faults by host FQNs (prefix match on reporting_sources) + nlohmann::json filtered = nlohmann::json::array(); + if (result.data.contains("faults") && result.data["faults"].is_array()) { + for (const auto & fault : result.data["faults"]) { + if (!fault.contains("reporting_sources")) { + continue; + } + bool matches = false; + for (const auto & src : fault["reporting_sources"]) { + const std::string src_str = src.get(); + for (const auto & fqn : host_fqns) { + if (src_str.rfind(fqn, 0) == 0) { + matches = true; + break; + } + } + if (matches) { + break; + } + } + if (matches) { + filtered.push_back(fault); + } } } - // AREA and FUNCTION: leave source_id empty (returns all faults) - // Guard against TOCTOU: entity found but vanished before get_app/get_component - if (source_id.empty() && entity_ref->type != SovdEntityType::AREA && - entity_ref->type != SovdEntityType::FUNCTION) { - return tl::make_unexpected(std::string("Entity no longer available: " + entity_id)); + result.data["faults"] = filtered; + result.data["count"] = filtered.size(); + return result.data; + } + + if (entity_ref->type == SovdEntityType::COMPONENT) { + // COMPONENT: get all faults, filter by hosted app FQNs + auto result = fault_mgr->list_faults(""); + if (!result.success) { + return tl::make_unexpected(result.error_message); + } + auto app_ids = cache.get_apps_for_component(entity_id); + std::set app_fqns; + for (const auto & app_id : app_ids) { + auto app = cache.get_app(app_id); + if (app && app->bound_fqn.has_value() && !app->bound_fqn->empty()) { + app_fqns.insert(app->bound_fqn.value()); + } } + // Filter faults by app FQNs (prefix match on reporting_sources) + nlohmann::json filtered = nlohmann::json::array(); + if (result.data.contains("faults") && result.data["faults"].is_array()) { + for (const auto & fault : result.data["faults"]) { + if (!fault.contains("reporting_sources")) { + continue; + } + bool matches = false; + for (const auto & src : fault["reporting_sources"]) { + const std::string src_str = src.get(); + for (const auto & fqn : app_fqns) { + if (src_str.rfind(fqn, 0) == 0) { + matches = true; + break; + } + } + if (matches) { + break; + } + } + if (matches) { + filtered.push_back(fault); + } + } + } + result.data["faults"] = filtered; + result.data["count"] = filtered.size(); + return result.data; + } + + // APP: use bound_fqn with prefix matching via list_faults + // AREA: use namespace_path with prefix matching via list_faults + std::string source_id; + if (entity_ref->type == SovdEntityType::APP) { + auto app = cache.get_app(entity_id); + if (app) { + source_id = app->bound_fqn.value_or(""); + } + } else if (entity_ref->type == SovdEntityType::AREA) { + auto area = cache.get_area(entity_id); + if (area) { + source_id = area->namespace_path; + } + } + if (source_id.empty()) { + return tl::make_unexpected(std::string("Entity no longer available: " + entity_id)); } auto result = fault_mgr->list_faults(source_id); if (!result.success) { @@ -636,14 +731,72 @@ GatewayNode::GatewayNode() : Node("ros2_medkit_gateway") { if (!log_mgr) { return tl::make_unexpected(std::string("LogManager not available")); } + // Match log_handlers.cpp scoping logic: + // AREA/COMPONENT: entity fqn (namespace_path) with prefix_match=true + // FUNCTION: host app FQNs with prefix_match=false (exact) + // APP: entity fqn (bound_fqn) with prefix_match=false (exact) const auto & cache = get_thread_safe_cache(); - auto configs = cache.get_entity_configurations(entity_id); - std::vector node_fqns; - node_fqns.reserve(configs.nodes.size()); - for (const auto & node : configs.nodes) { - node_fqns.push_back(node.node_fqn); + auto entity_ref = cache.find_entity(entity_id); + if (!entity_ref) { + return tl::make_unexpected(std::string("Entity not found: " + entity_id)); + } + + if (entity_ref->type == SovdEntityType::FUNCTION) { + // Aggregate logs from all hosted apps (exact match) + auto func = cache.get_function(entity_id); + if (!func || func->hosts.empty()) { + nlohmann::json payload; + payload["items"] = nlohmann::json::array(); + return payload; + } + std::vector host_fqns; + for (const auto & app_id : func->hosts) { + auto app = cache.get_app(app_id); + if (app && app->bound_fqn.has_value() && !app->bound_fqn->empty()) { + host_fqns.push_back(*app->bound_fqn); + } + } + if (host_fqns.empty()) { + nlohmann::json payload; + payload["items"] = nlohmann::json::array(); + return payload; + } + auto result = log_mgr->get_logs(host_fqns, false, "", "", entity_id); + if (!result.has_value()) { + return tl::make_unexpected(result.error()); + } + nlohmann::json payload; + payload["items"] = std::move(*result); + return payload; + } + + // AREA and COMPONENT: use namespace_path/fqn with prefix matching + // APP: use bound_fqn with exact matching + std::string fqn; + bool prefix_match = false; + if (entity_ref->type == SovdEntityType::AREA) { + auto area = cache.get_area(entity_id); + if (area) { + fqn = area->namespace_path; + } + prefix_match = true; + } else if (entity_ref->type == SovdEntityType::COMPONENT) { + auto comp = cache.get_component(entity_id); + if (comp) { + fqn = comp->fqn; + } + prefix_match = true; + } else if (entity_ref->type == SovdEntityType::APP) { + auto app = cache.get_app(entity_id); + if (app) { + fqn = app->bound_fqn.value_or(""); + } + } + + if (fqn.empty()) { + return tl::make_unexpected(std::string("Entity no longer available: " + entity_id)); } - auto result = log_mgr->get_logs(node_fqns, false, "", "", entity_id); + auto result = log_mgr->get_logs({fqn}, prefix_match, "", "", entity_id); if (!result.has_value()) { return tl::make_unexpected(result.error()); } 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 884e48176..b9fba5004 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp @@ -196,6 +196,12 @@ 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/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/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/src/http/handlers/log_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/log_handlers.cpp index a74d88742..d62a6787e 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/log_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/log_handlers.cpp @@ -51,6 +51,22 @@ void LogHandlers::handle_get_logs(const httplib::Request & req, httplib::Respons return; } + // Validate optional query parameters (shared across all entity types) + const std::string min_severity = req.get_param_value("severity"); + const std::string context_filter = req.get_param_value("context"); + + if (!min_severity.empty() && !LogManager::is_valid_severity(min_severity)) { + HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, + "Invalid severity value: must be one of debug, info, warning, error, fatal"); + return; + } + + static constexpr size_t kMaxContextFilterLen = 256; + if (context_filter.size() > kMaxContextFilterLen) { + HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "context filter exceeds maximum length of 256"); + return; + } + // Components and areas use prefix matching (all nodes under the namespace); // Apps use exact matching (single node FQN); // Functions aggregate logs from all hosted apps. @@ -81,22 +97,6 @@ void LogHandlers::handle_get_logs(const httplib::Request & req, httplib::Respons return; } - // Optional query parameters - const std::string min_severity = req.get_param_value("severity"); - const std::string context_filter = req.get_param_value("context"); - - if (!min_severity.empty() && !LogManager::is_valid_severity(min_severity)) { - HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, - "Invalid severity value: must be one of debug, info, warning, error, fatal"); - return; - } - - static constexpr size_t kMaxContextFilterLen = 256; - if (context_filter.size() > kMaxContextFilterLen) { - HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "context filter exceeds maximum length of 256"); - return; - } - // get_logs accepts multiple FQNs with exact match - one call for all hosts auto logs = log_mgr->get_logs(host_fqns, false, min_severity, context_filter, entity_id); if (!logs) { @@ -112,22 +112,6 @@ void LogHandlers::handle_get_logs(const httplib::Request & req, httplib::Respons const bool prefix_match = (entity.type == EntityType::COMPONENT || entity.type == EntityType::AREA); - // Optional query parameters - const std::string min_severity = req.get_param_value("severity"); - const std::string context_filter = req.get_param_value("context"); - - if (!min_severity.empty() && !LogManager::is_valid_severity(min_severity)) { - HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, - "Invalid severity value: must be one of debug, info, warning, error, fatal"); - return; - } - - static constexpr size_t kMaxContextFilterLen = 256; - if (context_filter.size() > kMaxContextFilterLen) { - HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "context filter exceeds maximum length of 256"); - return; - } - auto logs = log_mgr->get_logs({entity.fqn}, prefix_match, min_severity, context_filter, entity_id); if (!logs) { HandlerContext::send_error(res, 503, ERR_SERVICE_UNAVAILABLE, logs.error()); From 8dd462ea9546bd976db10542e423e13d253d5000 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Fri, 13 Mar 2026 09:49:51 +0100 Subject: [PATCH 14/15] fix: use effective_fqn() for App FQN resolution in manifest_only mode In manifest_only discovery mode, Apps never get bound_fqn set because runtime_linker only runs in hybrid mode. This caused handlers, samplers, and configuration aggregation to silently return empty results for all App-based lookups (logs, faults, configurations). Add App::effective_fqn() that prefers bound_fqn when available, falling back to deriving the FQN from ros_binding (namespace_pattern + node_name). Replace all direct bound_fqn accesses in handler_context, log_handlers, fault_handlers, gateway_node samplers, thread_safe_entity_cache config aggregation, and plugin_context with effective_fqn() calls. Update test_bulk_data_api for areas now returning 200 (entity capabilities extended) and test_scenario_discovery_manifest timeout for log aggregation. --- .../discovery/models/app.hpp | 18 +++++++++++ src/ros2_medkit_gateway/src/gateway_node.cpp | 31 ++++++++++++------- .../src/http/handlers/fault_handlers.cpp | 7 +++-- .../src/http/handlers/handler_context.cpp | 11 ++++--- .../src/http/handlers/log_handlers.cpp | 11 +++++-- .../src/models/thread_safe_entity_cache.cpp | 22 +++++++------ .../src/plugins/plugin_context.cpp | 2 +- .../test/features/test_bulk_data_api.test.py | 18 +++-------- .../test_scenario_discovery_manifest.test.py | 2 +- 9 files changed, 77 insertions(+), 45 deletions(-) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/app.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/app.hpp index 020e89b14..863e1a881 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/app.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/app.hpp @@ -85,6 +85,24 @@ struct App { bool is_online{false}; ///< Whether the bound node is running bool external{false}; ///< True if not a ROS node + /// Get effective FQN: bound_fqn if available, otherwise derived from ros_binding. + /// Returns empty string if neither is available. Used by handlers and samplers + /// to resolve the ROS node FQN in manifest_only mode where runtime linking + /// doesn't set bound_fqn. + std::string effective_fqn() const { + if (bound_fqn.has_value() && !bound_fqn->empty()) { + return *bound_fqn; + } + if (ros_binding.has_value() && !ros_binding->node_name.empty() && ros_binding->namespace_pattern != "*") { + std::string ns = ros_binding->namespace_pattern; + if (!ns.empty() && ns.back() != '/') { + ns += '/'; + } + return ns + ros_binding->node_name; + } + return ""; + } + // === Resources (populated from bound node) === ComponentTopics topics; std::vector services; diff --git a/src/ros2_medkit_gateway/src/gateway_node.cpp b/src/ros2_medkit_gateway/src/gateway_node.cpp index 63e5f2d7c..d86c86f16 100644 --- a/src/ros2_medkit_gateway/src/gateway_node.cpp +++ b/src/ros2_medkit_gateway/src/gateway_node.cpp @@ -595,8 +595,11 @@ GatewayNode::GatewayNode() : Node("ros2_medkit_gateway") { std::set host_fqns; for (const auto & app_id : func->hosts) { auto app = cache.get_app(app_id); - if (app && app->bound_fqn.has_value() && !app->bound_fqn->empty()) { - host_fqns.insert(*app->bound_fqn); + if (app) { + auto fqn = app->effective_fqn(); + if (!fqn.empty()) { + host_fqns.insert(std::move(fqn)); + } } } // Filter faults by host FQNs (prefix match on reporting_sources) @@ -639,8 +642,11 @@ GatewayNode::GatewayNode() : Node("ros2_medkit_gateway") { std::set app_fqns; for (const auto & app_id : app_ids) { auto app = cache.get_app(app_id); - if (app && app->bound_fqn.has_value() && !app->bound_fqn->empty()) { - app_fqns.insert(app->bound_fqn.value()); + if (app) { + auto fqn = app->effective_fqn(); + if (!fqn.empty()) { + app_fqns.insert(std::move(fqn)); + } } } // Filter faults by app FQNs (prefix match on reporting_sources) @@ -673,13 +679,13 @@ GatewayNode::GatewayNode() : Node("ros2_medkit_gateway") { return result.data; } - // APP: use bound_fqn with prefix matching via list_faults + // APP: use effective_fqn with prefix matching via list_faults // AREA: use namespace_path with prefix matching via list_faults std::string source_id; if (entity_ref->type == SovdEntityType::APP) { auto app = cache.get_app(entity_id); if (app) { - source_id = app->bound_fqn.value_or(""); + source_id = app->effective_fqn(); } } else if (entity_ref->type == SovdEntityType::AREA) { auto area = cache.get_area(entity_id); @@ -734,7 +740,7 @@ GatewayNode::GatewayNode() : Node("ros2_medkit_gateway") { // Match log_handlers.cpp scoping logic: // AREA/COMPONENT: entity fqn (namespace_path) with prefix_match=true // FUNCTION: host app FQNs with prefix_match=false (exact) - // APP: entity fqn (bound_fqn) with prefix_match=false (exact) + // APP: entity fqn (effective_fqn) with prefix_match=false (exact) const auto & cache = get_thread_safe_cache(); auto entity_ref = cache.find_entity(entity_id); if (!entity_ref) { @@ -752,8 +758,11 @@ GatewayNode::GatewayNode() : Node("ros2_medkit_gateway") { std::vector host_fqns; for (const auto & app_id : func->hosts) { auto app = cache.get_app(app_id); - if (app && app->bound_fqn.has_value() && !app->bound_fqn->empty()) { - host_fqns.push_back(*app->bound_fqn); + if (app) { + auto fqn = app->effective_fqn(); + if (!fqn.empty()) { + host_fqns.push_back(std::move(fqn)); + } } } if (host_fqns.empty()) { @@ -771,7 +780,7 @@ GatewayNode::GatewayNode() : Node("ros2_medkit_gateway") { } // AREA and COMPONENT: use namespace_path/fqn with prefix matching - // APP: use bound_fqn with exact matching + // APP: use effective_fqn with exact matching std::string fqn; bool prefix_match = false; if (entity_ref->type == SovdEntityType::AREA) { @@ -789,7 +798,7 @@ GatewayNode::GatewayNode() : Node("ros2_medkit_gateway") { } else if (entity_ref->type == SovdEntityType::APP) { auto app = cache.get_app(entity_id); if (app) { - fqn = app->bound_fqn.value_or(""); + fqn = app->effective_fqn(); } } 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 d3e612bf0..f8e84b7a1 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp @@ -372,8 +372,11 @@ void FaultHandlers::handle_list_faults(const httplib::Request & req, httplib::Re std::set app_fqns; for (const auto & app_id : app_ids) { auto app = cache.get_app(app_id); - if (app && app->bound_fqn.has_value() && !app->bound_fqn->empty()) { - app_fqns.insert(app->bound_fqn.value()); + if (app) { + auto fqn = app->effective_fqn(); + if (!fqn.empty()) { + app_fqns.insert(std::move(fqn)); + } } } 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 d95997da2..84890d5d8 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/handler_context.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/handler_context.cpp @@ -94,8 +94,9 @@ EntityInfo HandlerContext::get_entity_info(const std::string & entity_id, SovdEn case SovdEntityType::APP: if (auto app = cache.get_app(entity_id)) { info.type = EntityType::APP; - info.namespace_path = app->bound_fqn.value_or(""); - info.fqn = app->bound_fqn.value_or(""); + auto fqn = app->effective_fqn(); + info.namespace_path = fqn; + info.fqn = fqn; info.id_field = "app_id"; info.error_name = "App"; return info; @@ -148,9 +149,9 @@ EntityInfo HandlerContext::get_entity_info(const std::string & entity_id, SovdEn // Search apps (O(1) lookup) if (auto app = cache.get_app(entity_id)) { info.type = EntityType::APP; - // Apps use bound_fqn as namespace_path for fault filtering - info.namespace_path = app->bound_fqn.value_or(""); - info.fqn = app->bound_fqn.value_or(""); + auto fqn = app->effective_fqn(); + info.namespace_path = fqn; + info.fqn = fqn; info.id_field = "app_id"; info.error_name = "App"; return info; diff --git a/src/ros2_medkit_gateway/src/http/handlers/log_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/log_handlers.cpp index d62a6787e..ab85bf88b 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/log_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/log_handlers.cpp @@ -81,12 +81,17 @@ void LogHandlers::handle_get_logs(const httplib::Request & req, httplib::Respons return; } - // Collect FQNs of hosted apps + // Collect FQNs of hosted apps via effective_fqn() which falls back + // to ros_binding in manifest_only mode where bound_fqn is not set. std::vector host_fqns; for (const auto & app_id : func->hosts) { auto app = cache.get_app(app_id); - if (app && app->bound_fqn.has_value() && !app->bound_fqn->empty()) { - host_fqns.push_back(*app->bound_fqn); + if (!app) { + continue; + } + auto fqn = app->effective_fqn(); + if (!fqn.empty()) { + host_fqns.push_back(std::move(fqn)); } } diff --git a/src/ros2_medkit_gateway/src/models/thread_safe_entity_cache.cpp b/src/ros2_medkit_gateway/src/models/thread_safe_entity_cache.cpp index c544072b8..18d531b23 100644 --- a/src/ros2_medkit_gateway/src/models/thread_safe_entity_cache.cpp +++ b/src/ros2_medkit_gateway/src/models/thread_safe_entity_cache.cpp @@ -418,10 +418,11 @@ AggregatedConfigurations ThreadSafeEntityCache::get_app_configurations(const std const auto & app = apps_[it->second]; - // App must have a bound FQN to have parameters - if (app.bound_fqn.has_value() && !app.bound_fqn->empty()) { + // App must have a resolvable FQN to have parameters + auto eff_fqn = app.effective_fqn(); + if (!eff_fqn.empty()) { NodeConfigInfo info; - info.node_fqn = *app.bound_fqn; + info.node_fqn = std::move(eff_fqn); info.app_id = app_id; info.entity_id = app_id; result.nodes.push_back(info); @@ -451,9 +452,10 @@ AggregatedConfigurations ThreadSafeEntityCache::get_component_configurations(con continue; } const auto & app = apps_[app_idx]; - if (app.bound_fqn.has_value() && !app.bound_fqn->empty()) { + auto eff_fqn = app.effective_fqn(); + if (!eff_fqn.empty()) { NodeConfigInfo info; - info.node_fqn = *app.bound_fqn; + info.node_fqn = std::move(eff_fqn); info.app_id = app.id; info.entity_id = component_id; result.nodes.push_back(info); @@ -499,9 +501,10 @@ AggregatedConfigurations ThreadSafeEntityCache::get_area_configurations(const st continue; } const auto & app = apps_[app_idx]; - if (app.bound_fqn.has_value() && !app.bound_fqn->empty()) { + auto eff_fqn = app.effective_fqn(); + if (!eff_fqn.empty()) { NodeConfigInfo info; - info.node_fqn = *app.bound_fqn; + info.node_fqn = std::move(eff_fqn); info.app_id = app.id; info.entity_id = area_id; result.nodes.push_back(info); @@ -538,9 +541,10 @@ AggregatedConfigurations ThreadSafeEntityCache::get_function_configurations(cons continue; } const auto & app = apps_[app_idx]; - if (app.bound_fqn.has_value() && !app.bound_fqn->empty()) { + auto eff_fqn = app.effective_fqn(); + if (!eff_fqn.empty()) { NodeConfigInfo info; - info.node_fqn = *app.bound_fqn; + info.node_fqn = std::move(eff_fqn); info.app_id = app.id; info.entity_id = function_id; result.nodes.push_back(info); diff --git a/src/ros2_medkit_gateway/src/plugins/plugin_context.cpp b/src/ros2_medkit_gateway/src/plugins/plugin_context.cpp index b38cf383b..0e904df0a 100644 --- a/src/ros2_medkit_gateway/src/plugins/plugin_context.cpp +++ b/src/ros2_medkit_gateway/src/plugins/plugin_context.cpp @@ -54,7 +54,7 @@ class GatewayPluginContext : public PluginContext { return PluginEntityInfo{SovdEntityType::COMPONENT, id, comp->namespace_path, comp->fqn}; } if (auto app = cache.get_app(id)) { - return PluginEntityInfo{SovdEntityType::APP, id, {}, app->bound_fqn.value_or("")}; + return PluginEntityInfo{SovdEntityType::APP, id, {}, app->effective_fqn()}; } if (auto area = cache.get_area(id)) { return PluginEntityInfo{SovdEntityType::AREA, id, area->namespace_path, {}}; diff --git a/src/ros2_medkit_integration_tests/test/features/test_bulk_data_api.test.py b/src/ros2_medkit_integration_tests/test/features/test_bulk_data_api.test.py index bd9edd990..064a29342 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_bulk_data_api.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_bulk_data_api.test.py @@ -66,17 +66,18 @@ def test_bulk_data_list_categories_success(self): self.assertIn('rosbags', data['items']) def test_bulk_data_list_categories_all_entity_types(self): - """Bulk-data endpoint works for supported entity types and rejects unsupported ones. + """Bulk-data endpoint works for all entity types that support it. - Per SOVD Table 8, areas do NOT support resource collections (including bulk-data). - Components and apps do support bulk-data. + As a ros2_medkit extension, all entity types (apps, components, areas, + functions) support bulk-data. Areas and functions provide read-only + aggregated access via their hosted/child entities. @verifies REQ_INTEROP_071 """ - # Entity types that support bulk-data (SOVD Table 8) supported_endpoints = [ '/apps/lidar_sensor/bulk-data', '/components/perception/bulk-data', + '/areas/perception/bulk-data', ] for endpoint in supported_endpoints: @@ -90,15 +91,6 @@ def test_bulk_data_list_categories_all_entity_types(self): self.assertIn('items', data) self.assertIsInstance(data['items'], list) - # Areas do NOT support resource collections per SOVD spec - response = requests.get( - f'{self.BASE_URL}/areas/perception/bulk-data', timeout=10 - ) - self.assertEqual( - response.status_code, 400, - f'Expected 400 for areas bulk-data, got {response.status_code}' - ) - def test_bulk_data_list_categories_entity_not_found(self): """Bulk-data returns 404 for nonexistent entity. 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 7b7427cbf..87b43c510 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 @@ -407,7 +407,7 @@ def test_27_function_logs_has_entries_from_hosts(self): data = self.poll_endpoint_until( '/functions/engine-monitoring/logs', condition=lambda d: d if d.get('items') else None, - timeout=15.0, + timeout=30.0, ) self.assertGreater( len(data['items']), 0, From c2ec423471adefe17d59d9357c1e2c79daaa3ca9 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Fri, 13 Mar 2026 20:51:48 +0100 Subject: [PATCH 15/15] fix: address PR #258 review - effective_fqn, capabilities, fault filtering - Fix effective_fqn() to prepend "/" when namespace_pattern is empty, ensuring valid ROS 2 FQNs for fault filtering and bulk-data scoping - Reject glob patterns (containing "*") in effective_fqn() to prevent garbage FQNs from namespace patterns like "**" or "prefix*" - Add BULK_DATA and CYCLIC_SUBSCRIPTIONS to CapabilityBuilder enum and include them in caps vectors for all entity types in discovery handlers - Extract filter_faults_by_fqns() helper to eliminate duplication between FUNCTION and COMPONENT fault filtering blocks in gateway_node.cpp - Add EXPECT_FALSE(is_aggregated(BULK_DATA)) assertions for AREA/FUNCTION - Add effective_fqn() unit tests covering empty namespace, wildcards, globs - Add comments explaining unconditional bulk-data/cyclic endpoints in handle_root (depend on fault_manager, not optional plugins) - Update bulk-data handler with get_source_filters() for function aggregation - Fix integration test docstrings for bulk-data entity type coverage --- .../discovery/models/app.hpp | 17 ++- .../http/handlers/bulkdata_handlers.hpp | 12 ++ .../http/handlers/capability_builder.hpp | 26 ++-- src/ros2_medkit_gateway/src/gateway_node.cpp | 84 +++++------ .../src/http/handlers/bulkdata_handlers.cpp | 130 ++++++++++++------ .../src/http/handlers/capability_builder.cpp | 4 + .../src/http/handlers/discovery_handlers.cpp | 13 +- .../src/http/handlers/health_handlers.cpp | 4 +- .../test/test_discovery_models.cpp | 43 ++++++ .../test/test_entity_resource_model.cpp | 4 + .../test/features/test_bulk_data_api.test.py | 8 +- .../test/features/test_hateoas.test.py | 20 ++- .../test/features/test_logging_api.test.py | 7 +- 13 files changed, 244 insertions(+), 128 deletions(-) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/app.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/app.hpp index 863e1a881..58007b9c3 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/app.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/app.hpp @@ -89,14 +89,23 @@ struct App { /// Returns empty string if neither is available. Used by handlers and samplers /// to resolve the ROS node FQN in manifest_only mode where runtime linking /// doesn't set bound_fqn. + /// @note namespace_pattern must be empty or an exact namespace path (e.g. "/perception"). + /// Glob patterns like "**" or "prefix*" are not supported and will produce empty FQN. std::string effective_fqn() const { if (bound_fqn.has_value() && !bound_fqn->empty()) { return *bound_fqn; } - if (ros_binding.has_value() && !ros_binding->node_name.empty() && ros_binding->namespace_pattern != "*") { - std::string ns = ros_binding->namespace_pattern; - if (!ns.empty() && ns.back() != '/') { - ns += '/'; + if (ros_binding.has_value() && !ros_binding->node_name.empty()) { + const auto & ns = ros_binding->namespace_pattern; + // Wildcard or glob patterns cannot produce valid FQNs + if (ns.find('*') != std::string::npos) { + return ""; + } + if (ns.empty()) { + return "/" + ros_binding->node_name; + } + if (ns.back() != '/') { + return ns + "/" + ros_binding->node_name; } return ns + ros_binding->node_name; } diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/bulkdata_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/bulkdata_handlers.hpp index 05944bf8e..ad2f5641e 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/bulkdata_handlers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/bulkdata_handlers.hpp @@ -114,6 +114,18 @@ class BulkDataHandlers { private: HandlerContext & ctx_; + /** + * @brief Get source filters for rosbag queries based on entity type. + * + * For apps/components/areas: returns the entity's FQN or namespace path. + * For functions: aggregates FQNs from all hosting apps (read-only + * aggregated view - upload/delete are blocked at the route level). + * + * @param entity Entity information + * @return Vector of source filter strings (empty if no valid filters) + */ + std::vector get_source_filters(const EntityInfo & entity) const; + /** * @brief Stream file contents to HTTP response. * @param res HTTP response to write to diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/capability_builder.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/capability_builder.hpp index 602e6d17f..86dbefd06 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/capability_builder.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/capability_builder.hpp @@ -41,18 +41,20 @@ class CapabilityBuilder { * @brief Available capability types for SOVD entities. */ enum class Capability { - DATA, ///< Entity has data endpoints - OPERATIONS, ///< Entity has operations (services/actions) - CONFIGURATIONS, ///< Entity has configurations (parameters) - FAULTS, ///< Entity has fault management - SUBAREAS, ///< Entity has child areas (areas only) - SUBCOMPONENTS, ///< Entity has child components (components only) - RELATED_COMPONENTS, ///< Entity has related components (areas only) - CONTAINS, ///< Entity contains other entities (areas->components) - RELATED_APPS, ///< Entity has related apps (components only) - HOSTS, ///< Entity has host apps (functions/components) - DEPENDS_ON, ///< Entity has dependencies (components only) - LOGS ///< Entity has application log entries (components and apps) + DATA, ///< Entity has data endpoints + OPERATIONS, ///< Entity has operations (services/actions) + CONFIGURATIONS, ///< Entity has configurations (parameters) + FAULTS, ///< Entity has fault management + SUBAREAS, ///< Entity has child areas (areas only) + SUBCOMPONENTS, ///< Entity has child components (components only) + RELATED_COMPONENTS, ///< Entity has related components (areas only) + CONTAINS, ///< Entity contains other entities (areas->components) + RELATED_APPS, ///< Entity has related apps (components only) + HOSTS, ///< Entity has host apps (functions/components) + DEPENDS_ON, ///< Entity has dependencies (components only) + LOGS, ///< Entity has application log entries (components and apps) + BULK_DATA, ///< Entity has bulk data endpoints (rosbags) + CYCLIC_SUBSCRIPTIONS ///< Entity has cyclic subscription endpoints }; /** diff --git a/src/ros2_medkit_gateway/src/gateway_node.cpp b/src/ros2_medkit_gateway/src/gateway_node.cpp index d86c86f16..1068b22c7 100644 --- a/src/ros2_medkit_gateway/src/gateway_node.cpp +++ b/src/ros2_medkit_gateway/src/gateway_node.cpp @@ -74,6 +74,37 @@ nlohmann::json extract_plugin_config(const std::vector & over return config; } +/// Filter faults by FQN prefix match on reporting_sources. +/// Used for FUNCTION and COMPONENT entities that aggregate faults from hosted apps. +nlohmann::json filter_faults_by_fqns(const nlohmann::json & fault_data, const std::set & fqns) { + nlohmann::json filtered = nlohmann::json::array(); + if (!fault_data.contains("faults") || !fault_data["faults"].is_array()) { + return filtered; + } + for (const auto & fault : fault_data["faults"]) { + if (!fault.contains("reporting_sources")) { + continue; + } + bool matches = false; + for (const auto & src : fault["reporting_sources"]) { + const std::string src_str = src.get(); + for (const auto & fqn : fqns) { + if (src_str.rfind(fqn, 0) == 0) { + matches = true; + break; + } + } + if (matches) { + break; + } + } + if (matches) { + filtered.push_back(fault); + } + } + return filtered; +} + } // namespace GatewayNode::GatewayNode() : Node("ros2_medkit_gateway") { @@ -586,7 +617,6 @@ GatewayNode::GatewayNode() : Node("ros2_medkit_gateway") { if (!result.success) { return tl::make_unexpected(result.error_message); } - // Collect host app FQNs auto func = cache.get_function(entity_id); if (!func || func->hosts.empty()) { nlohmann::json empty_result = {{"faults", nlohmann::json::array()}, {"count", 0}}; @@ -602,31 +632,7 @@ GatewayNode::GatewayNode() : Node("ros2_medkit_gateway") { } } } - // Filter faults by host FQNs (prefix match on reporting_sources) - nlohmann::json filtered = nlohmann::json::array(); - if (result.data.contains("faults") && result.data["faults"].is_array()) { - for (const auto & fault : result.data["faults"]) { - if (!fault.contains("reporting_sources")) { - continue; - } - bool matches = false; - for (const auto & src : fault["reporting_sources"]) { - const std::string src_str = src.get(); - for (const auto & fqn : host_fqns) { - if (src_str.rfind(fqn, 0) == 0) { - matches = true; - break; - } - } - if (matches) { - break; - } - } - if (matches) { - filtered.push_back(fault); - } - } - } + auto filtered = filter_faults_by_fqns(result.data, host_fqns); result.data["faults"] = filtered; result.data["count"] = filtered.size(); return result.data; @@ -649,31 +655,7 @@ GatewayNode::GatewayNode() : Node("ros2_medkit_gateway") { } } } - // Filter faults by app FQNs (prefix match on reporting_sources) - nlohmann::json filtered = nlohmann::json::array(); - if (result.data.contains("faults") && result.data["faults"].is_array()) { - for (const auto & fault : result.data["faults"]) { - if (!fault.contains("reporting_sources")) { - continue; - } - bool matches = false; - for (const auto & src : fault["reporting_sources"]) { - const std::string src_str = src.get(); - for (const auto & fqn : app_fqns) { - if (src_str.rfind(fqn, 0) == 0) { - matches = true; - break; - } - } - if (matches) { - break; - } - } - if (matches) { - filtered.push_back(fault); - } - } - } + auto filtered = filter_faults_by_fqns(result.data, app_fqns); result.data["faults"] = filtered; result.data["count"] = filtered.size(); return result.data; diff --git a/src/ros2_medkit_gateway/src/http/handlers/bulkdata_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/bulkdata_handlers.cpp index 1719a9ad1..93f73a058 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/bulkdata_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/bulkdata_handlers.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include "ros2_medkit_gateway/bulk_data_store.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" @@ -96,57 +97,64 @@ void BulkDataHandlers::handle_list_descriptors(const httplib::Request & req, htt if (category == "rosbags") { // === Rosbags: served via FaultManager === - // Get FaultManager from node auto fault_mgr = ctx_.node()->get_fault_manager(); - // Get all faults for this entity (filter by FQN/namespace path) - // Use the entity's FQN or namespace_path as the source_id filter - std::string source_filter = entity.fqn.empty() ? entity.namespace_path : entity.fqn; - auto faults_result = fault_mgr->list_faults(source_filter); + // Get source filters for this entity (single for most, multiple for functions). + // Functions aggregate rosbags from all hosting apps. + auto source_filters = get_source_filters(entity); - // Build a map of fault_code -> fault_json for quick lookup + // Collect faults across all source filters for timestamp enrichment std::unordered_map fault_map; - if (faults_result.success && faults_result.data.contains("faults")) { - for (const auto & fault_json : faults_result.data["faults"]) { - if (fault_json.contains("fault_code")) { - std::string fc = fault_json["fault_code"].get(); - fault_map[fc] = fault_json; + for (const auto & source_filter : source_filters) { + auto faults_result = fault_mgr->list_faults(source_filter); + if (faults_result.success && faults_result.data.contains("faults")) { + for (const auto & fault_json : faults_result.data["faults"]) { + if (fault_json.contains("fault_code")) { + std::string fc = fault_json["fault_code"].get(); + fault_map[fc] = fault_json; + } } } } - // Use batch rosbag retrieval (single service call) instead of N+1 individual calls - auto rosbags_result = fault_mgr->list_rosbags(source_filter); + // Collect rosbags across all source filters + std::vector all_rosbags; + for (const auto & source_filter : source_filters) { + auto rosbags_result = fault_mgr->list_rosbags(source_filter); + if (rosbags_result.success && rosbags_result.data.contains("rosbags")) { + for (const auto & rosbag : rosbags_result.data["rosbags"]) { + all_rosbags.push_back(rosbag); + } + } + } nlohmann::json items = nlohmann::json::array(); - if (rosbags_result.success && rosbags_result.data.contains("rosbags")) { - for (const auto & rosbag : rosbags_result.data["rosbags"]) { - std::string fault_code = rosbag.value("fault_code", ""); - std::string format = rosbag.value("format", "mcap"); - uint64_t size_bytes = rosbag.value("size_bytes", 0); - double duration_sec = rosbag.value("duration_sec", 0.0); - - // Use fault_code as bulk_data_id - std::string bulk_data_id = fault_code; - - // Get timestamp from fault if available - int64_t created_at_ns = 0; - auto it = fault_map.find(fault_code); - if (it != fault_map.end()) { - double first_occurred = it->second.value("first_occurred", 0.0); - created_at_ns = static_cast(first_occurred * 1'000'000'000); - } - - nlohmann::json descriptor = { - {"id", bulk_data_id}, - {"name", fault_code + " recording " + format_timestamp_ns(created_at_ns)}, - {"mimetype", get_rosbag_mimetype(format)}, - {"size", size_bytes}, - {"creation_date", format_timestamp_ns(created_at_ns)}, - {"x-medkit", {{"fault_code", fault_code}, {"duration_sec", duration_sec}, {"format", format}}}}; - items.push_back(descriptor); + for (const auto & rosbag : all_rosbags) { + std::string fault_code = rosbag.value("fault_code", ""); + std::string format = rosbag.value("format", "mcap"); + uint64_t size_bytes = rosbag.value("size_bytes", 0); + double duration_sec = rosbag.value("duration_sec", 0.0); + + // Use fault_code as bulk_data_id + std::string bulk_data_id = fault_code; + + // Get timestamp from fault if available + int64_t created_at_ns = 0; + auto it = fault_map.find(fault_code); + if (it != fault_map.end()) { + double first_occurred = it->second.value("first_occurred", 0.0); + created_at_ns = static_cast(first_occurred * 1'000'000'000); } + + nlohmann::json descriptor = { + {"id", bulk_data_id}, + {"name", fault_code + " recording " + format_timestamp_ns(created_at_ns)}, + {"mimetype", get_rosbag_mimetype(format)}, + {"size", size_bytes}, + {"creation_date", format_timestamp_ns(created_at_ns)}, + {"x-medkit", {{"fault_code", fault_code}, {"duration_sec", duration_sec}, {"format", format}}}}; + items.push_back(descriptor); } nlohmann::json response = {{"items", items}}; @@ -409,12 +417,19 @@ void BulkDataHandlers::handle_download(const httplib::Request & req, httplib::Re return; } - // Security check: verify rosbag belongs to this entity - // Use targeted get_fault lookup instead of loading the entire fault list - std::string source_filter = entity.fqn.empty() ? entity.namespace_path : entity.fqn; - auto fault_result = fault_mgr->get_fault(fault_code, source_filter); + // Security check: verify rosbag belongs to this entity. + // For functions, check all hosting apps (aggregated view). + auto source_filters = get_source_filters(entity); + bool fault_verified = false; + for (const auto & sf : source_filters) { + auto fault_result = fault_mgr->get_fault(fault_code, sf); + if (fault_result.success) { + fault_verified = true; + break; + } + } - if (!fault_result.success) { + if (!fault_verified) { HandlerContext::send_error(res, 404, ERR_RESOURCE_NOT_FOUND, "Bulk-data not found for this entity", {{"entity_id", entity_info->entity_id}}); return; @@ -513,5 +528,32 @@ std::string BulkDataHandlers::get_rosbag_mimetype(const std::string & format) { return "application/octet-stream"; } +std::vector BulkDataHandlers::get_source_filters(const EntityInfo & entity) const { + if (entity.type == EntityType::FUNCTION) { + // Functions aggregate rosbags from all hosting apps + const auto & cache = ctx_.node()->get_thread_safe_cache(); + auto host_app_ids = cache.get_apps_for_function(entity.id); + std::vector filters; + filters.reserve(host_app_ids.size()); + for (const auto & app_id : host_app_ids) { + auto app = cache.get_app(app_id); + if (app) { + auto fqn = app->effective_fqn(); + if (!fqn.empty()) { + filters.push_back(fqn); + } + } + } + return filters; + } + + // For other entity types, use FQN or namespace_path + std::string filter = entity.fqn.empty() ? entity.namespace_path : entity.fqn; + if (filter.empty()) { + return {}; + } + return {filter}; +} + } // namespace handlers } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/http/handlers/capability_builder.cpp b/src/ros2_medkit_gateway/src/http/handlers/capability_builder.cpp index c9e108361..e50930843 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/capability_builder.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/capability_builder.cpp @@ -43,6 +43,10 @@ std::string CapabilityBuilder::capability_to_name(Capability cap) { return "depends-on"; case Capability::LOGS: return "logs"; + case Capability::BULK_DATA: + return "bulk-data"; + case Capability::CYCLIC_SUBSCRIPTIONS: + return "cyclic-subscriptions"; default: return "unknown"; } 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 d252a2735..69950a3c4 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp @@ -154,7 +154,7 @@ void DiscoveryHandlers::handle_get_area(const httplib::Request & req, httplib::R using Cap = CapabilityBuilder::Capability; std::vector caps = {Cap::SUBAREAS, Cap::CONTAINS, Cap::DATA, Cap::OPERATIONS, - Cap::CONFIGURATIONS, Cap::FAULTS, Cap::LOGS}; + Cap::CONFIGURATIONS, Cap::FAULTS, Cap::LOGS, Cap::BULK_DATA}; response["capabilities"] = CapabilityBuilder::build_capabilities("areas", area.id, caps); append_plugin_capabilities(response["capabilities"], "areas", area.id, SovdEntityType::AREA, ctx_.node()); @@ -494,8 +494,9 @@ void DiscoveryHandlers::handle_get_component(const httplib::Request & req, httpl } using Cap = CapabilityBuilder::Capability; - std::vector caps = {Cap::DATA, Cap::OPERATIONS, Cap::CONFIGURATIONS, Cap::FAULTS, - Cap::LOGS, Cap::SUBCOMPONENTS, Cap::HOSTS}; + std::vector caps = {Cap::DATA, Cap::OPERATIONS, Cap::CONFIGURATIONS, + Cap::FAULTS, Cap::LOGS, Cap::SUBCOMPONENTS, + Cap::HOSTS, Cap::BULK_DATA, Cap::CYCLIC_SUBSCRIPTIONS}; if (!comp.depends_on.empty()) { caps.push_back(Cap::DEPENDS_ON); } @@ -823,7 +824,8 @@ void DiscoveryHandlers::handle_get_app(const httplib::Request & req, httplib::Re } using Cap = CapabilityBuilder::Capability; - std::vector caps = {Cap::DATA, Cap::OPERATIONS, Cap::CONFIGURATIONS, Cap::FAULTS, Cap::LOGS}; + std::vector caps = {Cap::DATA, Cap::OPERATIONS, Cap::CONFIGURATIONS, Cap::FAULTS, + Cap::LOGS, Cap::BULK_DATA, Cap::CYCLIC_SUBSCRIPTIONS}; response["capabilities"] = CapabilityBuilder::build_capabilities("apps", app.id, caps); append_plugin_capabilities(response["capabilities"], "apps", app.id, SovdEntityType::APP, ctx_.node()); @@ -1026,7 +1028,8 @@ void DiscoveryHandlers::handle_get_function(const httplib::Request & req, httpli response["bulk-data"] = base_uri + "/bulk-data"; using Cap = CapabilityBuilder::Capability; - std::vector caps = {Cap::HOSTS, Cap::DATA, Cap::OPERATIONS, Cap::CONFIGURATIONS, Cap::FAULTS, Cap::LOGS}; + std::vector caps = {Cap::HOSTS, Cap::DATA, Cap::OPERATIONS, Cap::CONFIGURATIONS, + Cap::FAULTS, Cap::LOGS, Cap::BULK_DATA}; response["capabilities"] = CapabilityBuilder::build_capabilities("functions", func.id, caps); append_plugin_capabilities(response["capabilities"], "functions", func.id, SovdEntityType::FUNCTION, ctx_.node()); 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 b9fba5004..ba0fdc85f 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp @@ -185,7 +185,7 @@ void HealthHandlers::handle_root(const httplib::Request & req, httplib::Response "GET /api/v1/functions/{function_id}/logs", "GET /api/v1/functions/{function_id}/logs/configuration", "PUT /api/v1/functions/{function_id}/logs/configuration", - // Bulk Data + // Bulk Data - always available (depends on fault_manager, not an optional plugin) "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}", @@ -202,7 +202,7 @@ void HealthHandlers::handle_root(const httplib::Request & req, httplib::Response "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 + // Cyclic Subscriptions - always available (core gateway feature, not plugin-dependent) "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}", diff --git a/src/ros2_medkit_gateway/test/test_discovery_models.cpp b/src/ros2_medkit_gateway/test/test_discovery_models.cpp index 189342e56..88e5701a4 100644 --- a/src/ros2_medkit_gateway/test/test_discovery_models.cpp +++ b/src/ros2_medkit_gateway/test/test_discovery_models.cpp @@ -304,6 +304,49 @@ TEST_F(AppModelTest, ToCapabilities_OmitsDataWithoutTopics) { EXPECT_TRUE(j.contains("x-medkit")); } +// ============================================================================= +// App effective_fqn() Tests +// ============================================================================= + +TEST_F(AppModelTest, EffectiveFqn_ReturnsBoundFqn) { + EXPECT_EQ(app_.effective_fqn(), "/nav2/controller_server"); +} + +TEST_F(AppModelTest, EffectiveFqn_FallsBackToRosBinding) { + app_.bound_fqn = std::nullopt; + EXPECT_EQ(app_.effective_fqn(), "/nav2/nav2_controller"); +} + +TEST_F(AppModelTest, EffectiveFqn_EmptyNamespaceHasLeadingSlash) { + app_.bound_fqn = std::nullopt; + app_.ros_binding = App::RosBinding{"my_node", "", ""}; + EXPECT_EQ(app_.effective_fqn(), "/my_node"); +} + +TEST_F(AppModelTest, EffectiveFqn_WildcardReturnsEmpty) { + app_.bound_fqn = std::nullopt; + app_.ros_binding = App::RosBinding{"my_node", "*", ""}; + EXPECT_EQ(app_.effective_fqn(), ""); +} + +TEST_F(AppModelTest, EffectiveFqn_GlobPatternReturnsEmpty) { + app_.bound_fqn = std::nullopt; + app_.ros_binding = App::RosBinding{"my_node", "/nav**", ""}; + EXPECT_EQ(app_.effective_fqn(), ""); +} + +TEST_F(AppModelTest, EffectiveFqn_PrefixGlobReturnsEmpty) { + app_.bound_fqn = std::nullopt; + app_.ros_binding = App::RosBinding{"my_node", "prefix*", ""}; + EXPECT_EQ(app_.effective_fqn(), ""); +} + +TEST_F(AppModelTest, EffectiveFqn_NoBindingReturnsEmpty) { + app_.bound_fqn = std::nullopt; + app_.ros_binding = std::nullopt; + EXPECT_EQ(app_.effective_fqn(), ""); +} + // ============================================================================= // Function Model Tests // ============================================================================= diff --git a/src/ros2_medkit_gateway/test/test_entity_resource_model.cpp b/src/ros2_medkit_gateway/test/test_entity_resource_model.cpp index b61b0c778..a62fbae44 100644 --- a/src/ros2_medkit_gateway/test/test_entity_resource_model.cpp +++ b/src/ros2_medkit_gateway/test/test_entity_resource_model.cpp @@ -149,6 +149,8 @@ TEST(EntityCapabilities, AreaSupportsCollectionsViaAggregation) { EXPECT_TRUE(caps.is_aggregated(ResourceCollection::CONFIGURATIONS)); EXPECT_TRUE(caps.is_aggregated(ResourceCollection::FAULTS)); EXPECT_TRUE(caps.is_aggregated(ResourceCollection::LOGS)); + // Bulk-data uses host-scoped filtering, not aggregation + EXPECT_FALSE(caps.is_aggregated(ResourceCollection::BULK_DATA)); } TEST(EntityCapabilities, AreaSupportsContains) { @@ -187,6 +189,8 @@ TEST(EntityCapabilities, FunctionAggregatesCollections) { EXPECT_TRUE(caps.is_aggregated(ResourceCollection::CONFIGURATIONS)); EXPECT_TRUE(caps.is_aggregated(ResourceCollection::FAULTS)); EXPECT_TRUE(caps.is_aggregated(ResourceCollection::LOGS)); + // Bulk-data uses host-scoped filtering, not aggregation + EXPECT_FALSE(caps.is_aggregated(ResourceCollection::BULK_DATA)); } TEST(EntityCapabilities, UnknownTypeHasNoCapabilities) { diff --git a/src/ros2_medkit_integration_tests/test/features/test_bulk_data_api.test.py b/src/ros2_medkit_integration_tests/test/features/test_bulk_data_api.test.py index 064a29342..1f4465c58 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_bulk_data_api.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_bulk_data_api.test.py @@ -66,11 +66,11 @@ def test_bulk_data_list_categories_success(self): self.assertIn('rosbags', data['items']) def test_bulk_data_list_categories_all_entity_types(self): - """Bulk-data endpoint works for all entity types that support it. + """Bulk-data endpoint works for apps, components, and areas. - As a ros2_medkit extension, all entity types (apps, components, areas, - functions) support bulk-data. Areas and functions provide read-only - aggregated access via their hosted/child entities. + As a ros2_medkit extension, these entity types support bulk-data. + Areas provide read-only aggregated access via their child entities. + Functions also support bulk-data (tested separately with manifest). @verifies REQ_INTEROP_071 """ diff --git a/src/ros2_medkit_integration_tests/test/features/test_hateoas.test.py b/src/ros2_medkit_integration_tests/test/features/test_hateoas.test.py index 3a61c2f42..ac2238fc1 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_hateoas.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_hateoas.test.py @@ -21,8 +21,10 @@ """ +import os import unittest +from ament_index_python.packages import get_package_share_directory import launch_testing import launch_testing.actions import requests @@ -38,9 +40,19 @@ def generate_test_description(): + pkg_share = get_package_share_directory('ros2_medkit_gateway') + manifest_path = os.path.join( + pkg_share, 'config', 'examples', 'demo_nodes_manifest.yaml' + ) + return create_test_launch( demo_nodes=SENSOR_NODES + ACTUATOR_NODES + SERVICE_NODES, fault_manager=False, + gateway_params={ + 'discovery.mode': 'hybrid', + 'discovery.manifest_path': manifest_path, + 'discovery.manifest_strict_validation': False, + }, ) @@ -198,11 +210,15 @@ def test_area_detail_has_capability_uris(self): def test_function_detail_has_capability_uris(self): """GET /functions/{id} returns capability URIs including logs and bulk-data. + Requires hybrid discovery mode with a manifest that defines functions. + @verifies REQ_INTEROP_003 """ functions = self.get_json('/functions')['items'] - if not functions: - self.skipTest('No functions available') + self.assertGreater( + len(functions), 0, + 'Expected at least one function from manifest' + ) func_id = functions[0]['id'] data = self.get_json(f'/functions/{func_id}') diff --git a/src/ros2_medkit_integration_tests/test/features/test_logging_api.test.py b/src/ros2_medkit_integration_tests/test/features/test_logging_api.test.py index d8f5d8d72..b9908372c 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_logging_api.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_logging_api.test.py @@ -46,6 +46,7 @@ class TestLoggingApi(GatewayTestCase): MIN_EXPECTED_APPS = 1 REQUIRED_APPS = {'temp_sensor'} + REQUIRED_AREAS = {'powertrain'} # ------------------------------------------------------------------ # GET /apps/{id}/logs @@ -304,8 +305,7 @@ def test_area_get_logs_returns_200(self): # @verifies REQ_INTEROP_061 """ areas = self.get_json('/areas')['items'] - if not areas: - self.skipTest('No areas available in runtime discovery mode') + self.assertGreater(len(areas), 0, 'Expected at least one area') area_id = areas[0]['id'] data = self.get_json(f'/areas/{area_id}/logs') @@ -318,8 +318,7 @@ def test_area_get_logs_configuration_returns_200(self): # @verifies REQ_INTEROP_063 """ areas = self.get_json('/areas')['items'] - if not areas: - self.skipTest('No areas available in runtime discovery mode') + self.assertGreater(len(areas), 0, 'Expected at least one area') area_id = areas[0]['id'] data = self.get_json(f'/areas/{area_id}/logs/configuration')