diff --git a/docs/api/rest.rst b/docs/api/rest.rst index 793ac01d3..14e76d319 100644 --- a/docs/api/rest.rst +++ b/docs/api/rest.rst @@ -24,18 +24,54 @@ Server Capabilities .. code-block:: json { - "api_version": "1.0.0", - "gateway_version": "0.1.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"]} - ] + "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`` 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. @@ -57,7 +93,7 @@ Areas { "id": "powertrain", "name": "Powertrain", - "self": "/api/v1/areas/powertrain" + "href": "/api/v1/areas/powertrain" } ] } @@ -71,6 +107,13 @@ Areas ``GET /api/v1/areas/{area_id}/components`` List components in a specific area. + .. 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 ~~~~~~~~~~ @@ -86,9 +129,7 @@ Components { "id": "temp_sensor", "name": "temp_sensor", - "self": "/api/v1/components/temp_sensor", - "area": "powertrain", - "resource_collections": ["data", "operations", "configurations", "faults"] + "href": "/api/v1/components/temp_sensor" } ] } @@ -128,6 +169,13 @@ Functions ``GET /api/v1/functions/{function_id}/hosts`` List apps that host this function. + .. 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 -------------- @@ -477,6 +525,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)*. @@ -491,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:: @@ -595,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. @@ -1235,33 +1294,97 @@ 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 -- Bulk Data (``/bulk-data``) for binary data downloads (rosbags, logs) +- Logs (``/logs``) with severity filtering and per-entity configuration +- 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 + +**Pragmatic Extensions:** -**ros2_medkit Extensions:** +The SOVD spec defines resource collections only for apps and components. ros2_medkit +extends this to areas and functions where aggregation makes practical sense: -- ``/health`` - Health check endpoint +.. 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 +- ``/health`` - Health check with discovery pipeline diagnostics - ``/version-info`` - Gateway version information -- ``/manifest/status`` - Manifest discovery status -- SSE fault streaming - Real-time fault notifications -- ``x-medkit`` extension fields in responses See Also --------- +~~~~~~~~ +- :doc:`/config/discovery-options` for merge pipeline configuration - :doc:`/tutorials/authentication` - Configure authentication - :doc:`/config/server` - Server configuration options -- `Postman Collection `_ - Interactive API testing diff --git a/docs/config/discovery-options.rst b/docs/config/discovery-options.rst index d8cc5bcdf..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). @@ -87,19 +91,89 @@ 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) +----------------------------- + +In hybrid mode, the gateway uses a layered merge pipeline to combine entities +from multiple sources. Three layers contribute entities independently: + +- **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 + +Each layer's contribution is merged per **field-group** with configurable policies. + +Field Groups +^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :widths: 20 80 -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. + * - 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, bound_fqn + * - ``metadata`` + - source, x-medkit extensions, custom metadata fields -Gap-Fill Configuration -^^^^^^^^^^^^^^^^^^^^^^ +Merge Policies +^^^^^^^^^^^^^^ -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 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". Policy values are **case-sensitive** and must be lowercase +(``authoritative``, ``enrichment``, ``fallback``): + +.. 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 +184,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 +196,52 @@ 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": "hybrid", + "pipeline": { + "layers": ["manifest", "runtime", "plugin"], + "total_entities": 6, + "enriched_count": 5, + "conflict_count": 0, + "conflicts": [], + "id_collisions": 0, + "filtered_by_gap_fill": 0 + }, + "linking": { + "linked_count": 5, + "orphan_count": 1, + "binding_conflicts": 0 + } + } + } Configuration Example --------------------- @@ -201,15 +265,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 --------------------- 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 -------- diff --git a/docs/tutorials/manifest-discovery.rst b/docs/tutorials/manifest-discovery.rst index 1a45369e8..382b2cbc4 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 ~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -313,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: @@ -389,30 +396,31 @@ 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. 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. Hot Reloading ------------- 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/docs/tutorials/plugin-system.rst b/docs/tutorials/plugin-system.rst index 0562c690a..aa8ba7840 100644 --- a/docs/tutorials/plugin-system.rst +++ b/docs/tutorials/plugin-system.rst @@ -10,9 +10,13 @@ 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 - via the merge pipeline. In hybrid mode, each IntrospectionProvider is wrapped as a - ``PluginLayer`` and added to the pipeline with ENRICHMENT merge policy. +- **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`. 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). @@ -122,12 +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); - } - -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. @@ -223,7 +222,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 +336,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 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/CHANGELOG.rst b/src/ros2_medkit_gateway/CHANGELOG.rst index 660eff0cf..4929c4a3f 100644 --- a/src/ros2_medkit_gateway/CHANGELOG.rst +++ b/src/ros2_medkit_gateway/CHANGELOG.rst @@ -4,6 +4,24 @@ 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 `_) +* 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 `_) 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..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 @@ -85,6 +85,33 @@ 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. + /// @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()) { + 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; + } + return ""; + } + // === Resources (populated from bound node) === ComponentTopics topics; std::vector services; 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/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/gateway_node.cpp b/src/ros2_medkit_gateway/src/gateway_node.cpp index aa81bf86f..1068b22c7 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" @@ -73,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") { @@ -572,29 +604,80 @@ 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 (!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); + } + 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) { - source_id = app->bound_fqn.value_or(""); + auto fqn = app->effective_fqn(); + if (!fqn.empty()) { + host_fqns.insert(std::move(fqn)); + } } - } else if (entity_ref->type == SovdEntityType::COMPONENT) { - auto comp = cache.get_component(entity_id); - if (comp) { - source_id = comp->namespace_path; + } + auto filtered = filter_faults_by_fqns(result.data, host_fqns); + 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) { + auto fqn = app->effective_fqn(); + if (!fqn.empty()) { + app_fqns.insert(std::move(fqn)); + } } } - // 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)); + auto filtered = filter_faults_by_fqns(result.data, app_fqns); + result.data["faults"] = filtered; + result.data["count"] = filtered.size(); + return result.data; + } + + // 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->effective_fqn(); + } + } 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) { return tl::make_unexpected(result.error_message); @@ -636,14 +719,75 @@ 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 (effective_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) { + auto fqn = app->effective_fqn(); + if (!fqn.empty()) { + host_fqns.push_back(std::move(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; } - auto result = log_mgr->get_logs(node_fqns, false, "", "", entity_id); + + // AREA and COMPONENT: use namespace_path/fqn with prefix matching + // APP: use effective_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->effective_fqn(); + } + } + + if (fqn.empty()) { + return tl::make_unexpected(std::string("Entity no longer available: " + entity_id)); + } + auto result = log_mgr->get_logs({fqn}, prefix_match, "", "", entity_id); if (!result.has_value()) { return tl::make_unexpected(result.error()); } @@ -809,15 +953,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 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 2f0645ea4..69950a3c4 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, Cap::BULK_DATA}; response["capabilities"] = CapabilityBuilder::build_capabilities("areas", area.id, caps); append_plugin_capabilities(response["capabilities"], "areas", area.id, SovdEntityType::AREA, ctx_.node()); @@ -457,6 +459,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"; @@ -489,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); } @@ -805,6 +811,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; @@ -815,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()); @@ -1014,9 +1024,12 @@ 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, 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/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/health_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp index 043535836..ba0fdc85f 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,53 @@ 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", + "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 - 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}", + "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/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 - 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}", + "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 +231,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 +250,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 +297,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/src/http/handlers/log_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/log_handlers.cpp index be2aaddb2..ab85bf88b 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/log_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/log_handlers.cpp @@ -51,11 +51,7 @@ 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); - - // Optional query parameters + // 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"); @@ -71,6 +67,56 @@ void LogHandlers::handle_get_logs(const httplib::Request & req, httplib::Respons 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. + 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 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) { + continue; + } + auto fqn = app->effective_fqn(); + if (!fqn.empty()) { + host_fqns.push_back(std::move(fqn)); + } + } + + if (host_fqns.empty()) { + json result; + result["items"] = json::array(); + HandlerContext::send_json(res, result); + 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); + 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()); 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/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_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 2c731bf64..a62fbae44 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,22 @@ 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)); + // Bulk-data uses host-scoped filtering, not aggregation + EXPECT_FALSE(caps.is_aggregated(ResourceCollection::BULK_DATA)); } TEST(EntityCapabilities, AreaSupportsContains) { @@ -165,13 +175,22 @@ 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)); + // Bulk-data uses host-scoped filtering, not aggregation + EXPECT_FALSE(caps.is_aggregated(ResourceCollection::BULK_DATA)); } TEST(EntityCapabilities, UnknownTypeHasNoCapabilities) { @@ -580,7 +599,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_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..45ab65e35 100644 --- a/src/ros2_medkit_gateway/test/test_health_handlers.cpp +++ b/src/ros2_medkit_gateway/test/test_health_handlers.cpp @@ -80,37 +80,37 @@ 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("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) { +TEST_F(HealthHandlersTest, HandleVersionInfoItemsEntryHasVersionField) { 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()); } // @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["sovd_info"][0]; + auto & entry = body["items"][0]; EXPECT_TRUE(entry.contains("base_uri")); } // @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["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_bulk_data_api.test.py b/src/ros2_medkit_integration_tests/test/features/test_bulk_data_api.test.py index bd9edd990..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,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 apps, components, and areas. - 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, 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 """ - # 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/features/test_hateoas.test.py b/src/ros2_medkit_integration_tests/test/features/test_hateoas.test.py index 26e72f7a2..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, + }, ) @@ -173,6 +185,52 @@ 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. + + Requires hybrid discovery mode with a manifest that defines functions. + + @verifies REQ_INTEROP_003 + """ + functions = self.get_json('/functions')['items'] + 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}') + + 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_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_logging_api.test.py b/src/ros2_medkit_integration_tests/test/features/test_logging_api.test.py index 23f8b15d8..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 @@ -290,6 +291,40 @@ 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'] + self.assertGreater(len(areas), 0, 'Expected at least one area') + 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'] + 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') + 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/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'} 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'] ) 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) 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..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 @@ -361,11 +361,92 @@ 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}' + ) + + # ========================================================================= + # 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=30.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. @@ -376,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