From cdbae0c4a9e0265c64ec644b8fba254693d75473 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Wed, 5 Aug 2026 08:42:45 +0200 Subject: [PATCH 01/17] test(gateway): add a contract test for the generated OpenAPI document Document-wide invariants no single handler owns: unique operation identity, declared tags, resolvable refs, no malformed path keys. A violation here is a client-visible defect even when every endpoint behaves correctly. --- .../ros2_medkit_test_utils/launch_helpers.py | 40 +++++ .../features/test_openapi_contract.test.py | 140 ++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py diff --git a/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/launch_helpers.py b/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/launch_helpers.py index d1d25f7d8..dbf968892 100644 --- a/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/launch_helpers.py +++ b/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/launch_helpers.py @@ -21,6 +21,9 @@ Node names, namespaces, and parameters match ``demo_nodes.launch.py`` exactly. """ +import os + +from ament_index_python.packages import get_package_prefix from launch import LaunchDescription from launch.actions import TimerAction import launch_ros.actions @@ -381,6 +384,43 @@ def create_demo_nodes(nodes=None, *, lidar_faulty=True, coverage=True, return actions +# --------------------------------------------------------------------------- +# Gateway parameter presets +# --------------------------------------------------------------------------- + +def full_feature_gateway_params(scripts_dir): + """Gateway parameters that turn on every optional feature gate. + + The OpenAPI contract test asserts over the maximal route surface, so + scripts, updates, triggers, locking and the graph provider plugin must all + be live. Without this the gated routes are absent and assertions about them + pass vacuously. + + Parameters + ---------- + scripts_dir : str + Directory for uploaded diagnostic scripts. A non-empty value is what + enables the scripts feature. + + Returns + ------- + dict + Parameter overrides for ``create_test_launch(gateway_params=...)``. + + """ + graph_plugin = os.path.join( + get_package_prefix('ros2_medkit_graph_provider'), 'lib', + 'ros2_medkit_graph_provider', 'libros2_medkit_graph_provider_plugin.so') + return { + 'updates.enabled': True, + 'scripts.scripts_dir': scripts_dir, + 'triggers.enabled': True, + 'locking.enabled': True, + 'plugins': ['graph_provider'], + 'plugins.graph_provider.path': graph_plugin, + } + + # --------------------------------------------------------------------------- # Factory: complete test launch description # --------------------------------------------------------------------------- diff --git a/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py b/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py new file mode 100644 index 000000000..48630f4a5 --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +# Copyright 2026 bburda +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Contract tests for the generated OpenAPI document. + +Document-wide invariants that no single handler owns. Every rule here is a +rule a generated client depends on, so a violation is a client-visible defect +even when each individual endpoint behaves correctly. + +The gateway is launched with every optional feature gate on, so the maximal +route surface is present and assertions about gated routes cannot pass +vacuously. +""" + +import json +import re +import tempfile +import unittest + +import launch_testing +import launch_testing.actions + +from ros2_medkit_test_utils.constants import ALLOWED_EXIT_CODES +from ros2_medkit_test_utils.gateway_test_case import GatewayTestCase +from ros2_medkit_test_utils.launch_helpers import ( + create_test_launch, + full_feature_gateway_params, +) + +HTTP_METHODS = {'get', 'post', 'put', 'delete', 'patch', 'head', 'options'} + +_SCRIPTS_DIR = tempfile.mkdtemp(prefix='medkit-contract-scripts-') + + +def generate_test_description(): + return create_test_launch( + demo_nodes=['calibration', 'temp_sensor'], + fault_manager=True, + gateway_params=full_feature_gateway_params(_SCRIPTS_DIR), + ) + + +def refs_in(node): + """Return every #/components/... reference reachable inside a subtree.""" + return set(re.findall(r'#/components/[A-Za-z0-9_/]+', json.dumps(node))) + + +class TestOpenApiContract(GatewayTestCase): + """Document-wide invariants of GET /api/v1/docs.""" + + MIN_EXPECTED_APPS = 2 + REQUIRED_APPS = {'calibration', 'temp_sensor'} + + _spec = None + + def spec(self): + """Fetch and cache the served OpenAPI document.""" + if TestOpenApiContract._spec is None: + TestOpenApiContract._spec = self.poll_endpoint_until( + '/docs', + lambda d: d if 'openapi' in d else None, + ) + return TestOpenApiContract._spec + + def operations(self): + """Yield (path, method, operation) for every documented operation.""" + for path, item in self.spec()['paths'].items(): + for method, operation in item.items(): + if method in HTTP_METHODS: + yield path, method, operation + + def test_document_is_openapi_31(self): + """Served document is OpenAPI 3.1.0 and carries the SOVD version. + + @verifies REQ_INTEROP_002 + """ + spec = self.spec() + self.assertEqual(spec['openapi'], '3.1.0') + self.assertEqual(spec['info']['x-sovd-version'], '1.0.0') + + def test_every_operation_is_identified(self): + """Every operation has a unique operationId, a summary and a tag.""" + seen = {} + for path, method, op in self.operations(): + where = f'{method.upper()} {path}' + self.assertTrue(op.get('summary'), f'{where}: missing summary') + self.assertTrue(op.get('tags'), f'{where}: missing tag') + op_id = op.get('operationId') + self.assertTrue(op_id, f'{where}: missing operationId') + self.assertNotIn( + op_id, seen, f'{where}: operationId collides with {seen.get(op_id)}') + seen[op_id] = where + + def test_every_tag_used_is_declared(self): + """No operation carries a tag missing from the document tag list.""" + declared = {t['name'] for t in self.spec().get('tags', [])} + for path, method, op in self.operations(): + for tag in op.get('tags', []): + self.assertIn( + tag, declared, f'{method.upper()} {path}: tag "{tag}" not declared') + + def test_no_malformed_path_keys(self): + """Path keys have a leading slash and no empty segments.""" + for path in self.spec()['paths']: + self.assertTrue(path.startswith('/'), f'{path}: missing leading slash') + self.assertNotIn('//', path, f'{path}: empty path segment') + + def test_every_ref_resolves(self): + """No $ref points at a component the document does not define.""" + spec = self.spec() + dangling = [] + for ref in refs_in(spec): + section, name = ref[len('#/components/'):].split('/', 1) + if name not in spec.get('components', {}).get(section, {}): + dangling.append(ref) + self.assertEqual(dangling, [], f'dangling refs: {dangling}') + + +@launch_testing.post_shutdown_test() +class TestShutdown(unittest.TestCase): + + def test_exit_codes(self, proc_info): + """Check all processes exited cleanly (SIGTERM allowed).""" + for info in proc_info: + self.assertIn( + info.returncode, ALLOWED_EXIT_CODES, + f'{info.process_name} exited with code {info.returncode}' + ) From 8767f5e6463462318f864e1523487bcebaf8293b Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Wed, 5 Aug 2026 08:42:45 +0200 Subject: [PATCH 02/17] feat(gateway): derive the declared success status from the handler return type 45 operations declared a 2xx they cannot return, because the status was attached by hand at the registration while the handler's return type said otherwise. Created and Accepted carry the status in the type, so the registry reads it from the signature and the document cannot drift from the wire. status_payload_t unwraps to the schema, the serializer and the static assertions. A 202 labelled "No content" is now unrepresentable rather than merely corrected. --- docs/api/rest.rst | 7 + .../design/dto_contract.rst | 127 ++++++-- src/ros2_medkit_gateway/design/lifecycle.rst | 14 +- .../core/http/handlers/bulkdata_handlers.hpp | 4 +- .../core/http/handlers/lifecycle_handlers.hpp | 4 +- .../core/http/handlers/lock_handlers.hpp | 6 +- .../core/http/handlers/operation_handlers.hpp | 9 +- .../core/http/handlers/script_handlers.hpp | 4 +- .../core/http/handlers/trigger_handlers.hpp | 11 +- .../core/http/handlers/update_handlers.hpp | 13 +- .../http/alternate_status.hpp | 54 ++++ .../http/handler_result.hpp | 23 ++ .../handlers/cyclic_subscription_handlers.hpp | 12 +- .../src/core/openapi/route_registry.cpp | 77 ++++- .../src/http/handlers/bulkdata_handlers.cpp | 6 +- .../handlers/cyclic_subscription_handlers.cpp | 6 +- .../src/http/handlers/lifecycle_handlers.cpp | 6 +- .../src/http/handlers/lock_handlers.cpp | 8 +- .../src/http/handlers/operation_handlers.cpp | 8 +- .../src/http/handlers/script_handlers.cpp | 12 +- .../src/http/handlers/trigger_handlers.cpp | 8 +- .../src/http/handlers/update_handlers.cpp | 24 +- .../src/http/rest_server.cpp | 97 +++--- .../src/openapi/route_registry.hpp | 287 +++++++++++++----- .../test/test_lifecycle_handlers.cpp | 5 +- .../test/test_lock_handlers.cpp | 43 +-- .../test/test_operation_handlers.cpp | 7 +- .../test/test_script_handlers.cpp | 16 +- .../features/test_openapi_contract.test.py | 144 +++++++++ .../test_openapi_response_drift.test.py | 50 +++ 30 files changed, 835 insertions(+), 257 deletions(-) diff --git a/docs/api/rest.rst b/docs/api/rest.rst index 663a0875c..c3f52a817 100644 --- a/docs/api/rest.rst +++ b/docs/api/rest.rst @@ -2760,6 +2760,13 @@ OpenAPI spec describing the available operations at that level. **Features:** - Specs include SOVD extensions (``x-sovd-version``, ``x-sovd-data-category``) +- Each operation declares exactly one success status, derived from the handler's + C++ return type. The few operations whose handler can genuinely answer with one + of several success shapes (``POST .../operations/{operation_id}/executions``, + ``DELETE .../faults/{fault_code}``, + ``DELETE .../configurations``) carry ``x-medkit-alternates: true`` and list every + alternative under its own status code. A generated client can therefore branch on + status only where that marker is present. - Entity-level specs reflect actual capabilities from the runtime entity cache - Specs are cached per entity cache generation for performance - Plugin-registered vendor routes appear in path-scoped specs when the requested diff --git a/src/ros2_medkit_gateway/design/dto_contract.rst b/src/ros2_medkit_gateway/design/dto_contract.rst index af00242ac..e5ce2a96e 100644 --- a/src/ros2_medkit_gateway/design/dto_contract.rst +++ b/src/ros2_medkit_gateway/design/dto_contract.rst @@ -322,27 +322,62 @@ plus, on POST / PUT / PATCH overloads, an already-parsed ``TBody``: // ... return result ... }); -When a handler needs to override the success status (201 + Location, 204 + -custom header, ...) the pair-returning overload makes the framework apply -``ResponseAttachments`` after the body is written: +Success Status Lives in the Return Type +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A handler that completes with something other than 200 says so in its return +type, not at runtime. ``http::Created`` declares 201 and ``http::Accepted`` +declares 202; both are transparent wrappers whose payload is ``T``: .. code-block:: cpp - reg.post( + reg.post>( + "/{entity}/triggers", + [](http::TypedRequest, dto::TriggerCreateRequest) + -> http::Result> { + dto::Trigger t; + return http::Created{std::move(t)}; + }); + +The registry reads ``http::dto_alternate_status`` for the status and +``http::status_payload_t`` for everything else - the schema ``$ref``, +the ``has_dto_shape_v`` assertion and the body writer. An unwrapped ``TResponse`` +is its own payload, so a plain DTO still means 200 and ``http::NoContent`` still +means 204. ``http::Accepted`` is the shape for an accepted +asynchronous transition that sends no body (202, empty). + +This is what keeps the document honest: the declared status and the status on +the wire come from one type, so they cannot disagree. Writing the status at the +call site instead - ``.response(201, ...)`` beside a handler that returns 200 - +is what previously let 45 operations advertise a success status their handler +could never emit. + +``ResponseAttachments`` remains the channel for everything that is *not* the +status: extra headers on the success response, and the rare runtime status +override. The pair-returning overloads carry it alongside the (possibly +wrapped) response: + +.. code-block:: cpp + + reg.post>( "/...", [](http::TypedRequest, dto::Req) - -> http::Result> { + -> http::Result, http::ResponseAttachments>> { dto::Resp r; http::ResponseAttachments att; - att.status_override = 201; - att.headers.emplace_back("Location", "/resources/123"); - return std::make_pair(std::move(r), std::move(att)); + att.with_header("Location", "/resources/123"); + return std::make_pair(http::Created{std::move(r)}, std::move(att)); }); -The framework writes the response body via ``JsonWriter``, applies -the attachments, and renders any error branch via the route's configured -``ErrorRenderer`` (``kSovdGenericError`` by default; the ``/auth/*`` routes -opt into ``kOAuth2Error`` to emit the RFC 6749 wire shape). +When the attachments carry no ``status_override``, the framework falls back to +``dto_alternate_status`` - never to a literal 200/204 - so wrapping a +paired response is enough to move both the wire status and the declared one. + +The framework writes the response body via +``JsonWriter>``, applies the attachments, and renders +any error branch via the route's configured ``ErrorRenderer`` +(``kSovdGenericError`` by default; the ``/auth/*`` routes opt into +``kOAuth2Error`` to emit the RFC 6749 wire shape). Type-System Guarantees ~~~~~~~~~~~~~~~~~~~~~~ @@ -352,19 +387,50 @@ gate, so any non-DTO type passed as ``TResponse`` or ``TBody`` rejects at compile time with a contract-aware diagnostic. ``has_dto_shape_v`` is true when either ``is_dto_v`` (a regular field-walking DTO) or ``is_opaque_dto_v`` (a hand-written opaque DTO envelope) is true; the -``NoContent`` marker is the third accepted shape and triggers a 204 -empty-body branch in ``write_success_body``. - -The OpenAPI schema slot for every typed route is wired automatically from -``TResponse`` and ``TBody`` (and from the alternates in +``NoContent`` marker is the third accepted shape and triggers an empty-body +branch in ``write_success_body``. The gate is applied to +``status_payload_t``, so ``Created`` / ``Accepted`` are +accepted exactly when ``T`` is - the wrappers deliberately have no +``dto_fields`` / ``dto_name`` specialization of their own. + +The OpenAPI status **and** schema slot for every typed route is wired +automatically from ``TResponse`` and ``TBody`` (and from the alternates in ``post_alternates`` / ``del_alternates``). The -registry calls ``RouteEntry::response(200, "")`` / -``RouteEntry::request_body("")`` so the wire JSON and the published -schema cannot drift: the same C++ type names both. Hand-attached -``.response(...)`` / ``.request_body(...)`` calls are reserved for non-200 -status documentation (404 / 409 / ...) and for the rare body-less typed -``post`` / ``put`` overloads that parse non-JSON bodies (form-urlencoded -auth) and need an explicit OpenAPI ``request_body`` annotation. +registry calls +``RouteEntry::response>(dto_alternate_status::value, ...)`` +/ ``RouteEntry::request_body("")`` so neither the status nor the schema can +drift from the handler: the same C++ type names all three. + +Hand-attached ``.response(...)`` calls are therefore reserved for statuses the +*framework cannot see* - error statuses beyond the blanket 400/404/500 set, and +the ``request_body(...)`` annotation the rare body-less typed ``post`` / ``put`` +overloads need when they parse non-JSON bodies (form-urlencoded auth). Never +hand-attach a 2xx: it restates something the return type already decides, and +if the two disagree the route publishes both. + +To author the prose a generated client shows for a success response, use +``.success_description("Trigger created")``. It rewrites the description of the +already-derived 2xx and touches neither the status nor the schema. Without it +the framework publishes a status-appropriate default ("Created", "Accepted", +"No content", "Successful response"). + +Routes whose handler genuinely returns a ``std::variant`` - the +``post_alternates`` / ``del_alternates`` helpers - legitimately declare more +than one 2xx. Those helpers call ``RouteEntry::mark_alternates()`` themselves, +which publishes the ``x-medkit-alternates: true`` operation extension, so the +document contract test can tell a real variant from a route that declares a +status it cannot return. Nothing else may set that marker. + +Two further ``RouteEntry`` knobs shape the published response set: + +- ``errors({409, 423})`` - declare error statuses this route can emit beyond + the blanket set; each is rendered as a ``GenericError`` response ``$ref``. + Statuses below 400 are ignored and reported by ``validate_completeness()``, + because a success status belongs in the return type, not here. +- ``only_status(code, desc)`` - this route has exactly one outcome. Clears every + other response and suppresses the blanket 400/404/500 injection. The + auth 401/403 refs stay when authentication is enabled: they come from the + middleware ahead of the handler and are reachable on every route. Escape Hatches -------------- @@ -394,8 +460,9 @@ remain compile-time-checked at their boundary. - ``reg.multipart_upload(path, handler)`` - registers a ``multipart/form-data`` upload. The handler receives ``http::MultipartBody`` (already parsed by cpp-httplib) and returns - ``Result>`` so it can pin - 201 + ``Location`` on successful uploads. Used by bulk-data POST/PUT. + ``Result>``. Uploads declare + 201 through ``TResponse`` (``http::Created``) and use + the attachments only for the ``Location`` header. Used by bulk-data POST/PUT. - ``reg.static_asset(path, handler)`` - serves bytes already in memory (Swagger UI bundles, embedded HTML/JS/CSS) as ``Result`` carrying ``bytes``, ``content_type``, and @@ -411,9 +478,11 @@ remain compile-time-checked at their boundary. ``reg.del_alternates(path, handler)`` - register multi-shape responses. The active variant alternative is dispatched to its ``dto_alternate_status::value`` (default 200; specialize per type, for - example ``Accepted`` -> 202, ``NoContent`` -> 204). The published spec - lists every alternative under its own status code, and the wire status is - picked by the active alternative at call time. + example ``NoContent`` -> 204, ``Created`` -> 201, ``Accepted`` -> 202). + The published spec lists every alternative under its own status code, and the + wire status is picked by the active alternative at call time. Both helpers + call ``mark_alternates()``, so these are the only operations allowed to carry + more than one 2xx. Plugin-Owned Routes (``PluginContext::register_route()``) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/ros2_medkit_gateway/design/lifecycle.rst b/src/ros2_medkit_gateway/design/lifecycle.rst index 9a9b86efa..6a8462f5a 100644 --- a/src/ros2_medkit_gateway/design/lifecycle.rst +++ b/src/ros2_medkit_gateway/design/lifecycle.rst @@ -57,7 +57,7 @@ The lifecycle routes are registered **outside** the four-entity-type loop in // other; registration order within the loop is arbitrary. for (const auto & action : {"start", "restart", "force-restart", "shutdown", "force-shutdown"}) { - reg.put(base_lc + "/status/" + action, ...); + reg.put>(base_lc + "/status/" + action, ...); } reg.get(base_lc + "/status", ...); } @@ -138,7 +138,7 @@ It follows the same typed-DTO style as ``OperationProvider``. package "core/http/handlers/" { class LifecycleHandlers { + handle_get_status(req): Result - + handle_transition(req, transition): Result> + + handle_transition(req, transition): Result, ResponseAttachments>> - ctx_: HandlerContext - plugin_mgr_: PluginManager* } @@ -243,8 +243,8 @@ Transition Flow end provider --> handler : expected (success) - handler -> handler : build ResponseAttachments (202, Location header) - handler --> reg : pair + handler -> handler : build ResponseAttachments (Location header only) + handler --> reg : pair, ResponseAttachments> reg --> Client : 202 Accepted\nLocation: /api/v1/apps/{id}/status @enduml @@ -255,6 +255,12 @@ observe the state change. The provider is responsible for initiating the substrate-level operation (e.g., calling a ROS 2 lifecycle service or sending a signal to a process manager); it returns immediately on acceptance. +The 202 is declared by the handler's return type +(``http::Accepted``), not set at runtime, so the OpenAPI +document and the wire status come from one place and cannot disagree. The +``ResponseAttachments`` companion carries only the ``Location`` header. See +:doc:`dto_contract` for the status-wrapper contract. + SOVD Requirement Coverage -------------------------- diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/bulkdata_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/bulkdata_handlers.hpp index ef4266420..264a47936 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/bulkdata_handlers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/bulkdata_handlers.hpp @@ -65,8 +65,8 @@ class BulkDataHandlers { http::Result download(const http::TypedRequest & req); /// POST /{entity}/bulk-data/{category_id} - multipart upload, 201 + Location. - http::Result> upload(const http::TypedRequest & req, - const http::MultipartBody & body); + http::Result, http::ResponseAttachments>> + upload(const http::TypedRequest & req, const http::MultipartBody & body); /// DELETE /{entity}/bulk-data/{category_id}/{file_id} - 204 No Content. http::Result remove(const http::TypedRequest & req); diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/lifecycle_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/lifecycle_handlers.hpp index 1df15a9dd..ab1fedcac 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/lifecycle_handlers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/lifecycle_handlers.hpp @@ -54,8 +54,8 @@ class LifecycleHandlers { /// PUT /{entity}/status/{action} - request a lifecycle transition. /// Returns 202 + Location on acceptance, or 501 when no provider is registered. - http::Result> handle_transition(const http::TypedRequest & req, - std::string_view transition); + http::Result, http::ResponseAttachments>> + handle_transition(const http::TypedRequest & req, std::string_view transition); private: HandlerContext & ctx_; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/lock_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/lock_handlers.hpp index 1ad6d4f89..fe4722918 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/lock_handlers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/lock_handlers.hpp @@ -62,11 +62,11 @@ class LockHandlers { * * Request body: `AcquireLockRequest` (validated at framework level). * Requires X-Client-Id header. - * On success returns the new `Lock` body with a 201 status override and a + * On success returns the new `Lock` body as 201 Created plus a * `Location: /` header. */ - http::Result> post_lock(const http::TypedRequest & req, - dto::AcquireLockRequest body); + http::Result, http::ResponseAttachments>> post_lock(const http::TypedRequest & req, + dto::AcquireLockRequest body); /** * @brief GET /{entity_type}/{entity_id}/locks - list locks on entity. diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/operation_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/operation_handlers.hpp index db385decd..8b4782af4 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/operation_handlers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/operation_handlers.hpp @@ -83,11 +83,10 @@ class OperationHandlers { /// PUT /{entity}/operations/{op_id}/executions/{exec_id} - update execution. /// - /// Returns `OperationExecution` + attachments so the supported `stop` - /// capability can emit 202 + `Location` (the SOVD async-update convention) - /// while the success body stays 200 for any future synchronous capability - /// that might land. - http::Result> + /// Only the `stop` capability succeeds, and it is asynchronous, so the + /// return type declares 202 Accepted. The attachments companion carries the + /// `Location` header (the SOVD async-update convention). + http::Result, http::ResponseAttachments>> update_execution(const http::TypedRequest & req, const dto::ExecutionUpdateRequest & body); private: diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/script_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/script_handlers.hpp index c2df68890..89693a9f9 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/script_handlers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/script_handlers.hpp @@ -38,7 +38,7 @@ class ScriptHandlers { ScriptHandlers(HandlerContext & ctx, ScriptManager * script_manager); /// POST /{entity}/scripts - multipart upload, returns 201 + Location. - http::Result> + http::Result, http::ResponseAttachments>> upload_script(const http::TypedRequest & req, const http::MultipartBody & body); /// GET /{entity}/scripts - list scripts, typed HATEOAS envelope. @@ -51,7 +51,7 @@ class ScriptHandlers { http::Result delete_script(const http::TypedRequest & req); /// POST /{entity}/scripts/{script_id}/executions - start, returns 202 + Location. - http::Result> + http::Result, http::ResponseAttachments>> start_execution(const http::TypedRequest & req); /// GET /{entity}/scripts/{script_id}/executions/{execution_id} - get status. diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/trigger_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/trigger_handlers.hpp index 46490b7a9..54743c917 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/trigger_handlers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/trigger_handlers.hpp @@ -57,8 +57,9 @@ struct TriggerParsedResourceUri { * `Result` factory; the framework drives the chunked content * provider. The forwarding-scope primitive (commit 17) is installed by the * framework so peer-forwarding still works for entities owned by a remote - * gateway. CRUD POST uses the attachments variant so it can override the - * status to 201 without re-introducing a `httplib::Response &` parameter. + * gateway. CRUD POST returns `Created` so the 201 status lives in the + * signature and the generated document cannot declare a status the handler + * never emits. */ class TriggerHandlers { public: @@ -66,9 +67,9 @@ class TriggerHandlers { /// POST /{entity}/triggers - create trigger. /// - /// On success returns the new `Trigger` body with a 201 status override. - http::Result> post_trigger(const http::TypedRequest & req, - dto::TriggerCreateRequest body); + /// On success returns the new `Trigger` body as 201 Created. + http::Result> post_trigger(const http::TypedRequest & req, + dto::TriggerCreateRequest body); /// GET /{entity}/triggers - list all triggers for entity. http::Result> get_triggers(const http::TypedRequest & req); diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/update_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/update_handlers.hpp index b6f4fec22..318797dcc 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/update_handlers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/update_handlers.hpp @@ -47,9 +47,9 @@ class UpdateHandlers { http::Result get_update(const http::TypedRequest & req); /// POST /updates - register a new update descriptor. On success returns the - /// `UpdateRegisterResponse` body with a 201 status override and a + /// `UpdateRegisterResponse` body as 201 Created plus a /// `Location: /api/v1/updates/` header. - http::Result> + http::Result, http::ResponseAttachments>> post_update(const http::TypedRequest & req, dto::UpdateRegisterRequest body); /// DELETE /updates/{update_id} - 204 No Content on success. @@ -57,15 +57,18 @@ class UpdateHandlers { /// PUT /updates/{update_id}/prepare - 202 Accepted + `Location: .../status` /// header, kicks the background prepare task. - http::Result> put_prepare(const http::TypedRequest & req); + http::Result, http::ResponseAttachments>> + put_prepare(const http::TypedRequest & req); /// PUT /updates/{update_id}/execute - 202 Accepted + `Location: .../status` /// header, kicks the background execute task. - http::Result> put_execute(const http::TypedRequest & req); + http::Result, http::ResponseAttachments>> + put_execute(const http::TypedRequest & req); /// PUT /updates/{update_id}/automated - 202 Accepted + `Location: .../status` /// header, kicks the background prepare+execute task. - http::Result> put_automated(const http::TypedRequest & req); + http::Result, http::ResponseAttachments>> + put_automated(const http::TypedRequest & req); /// GET /updates/{update_id}/status - returns the current async-task state. http::Result get_status(const http::TypedRequest & req); diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/alternate_status.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/alternate_status.hpp index 9b68506e3..77dd90245 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/alternate_status.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/alternate_status.hpp @@ -45,5 +45,59 @@ struct dto_alternate_status { static constexpr int value = 204; }; +/// Status wrappers. These are partial specializations over a different type +/// than the `NoContent` full specialization, so `Accepted` matches +/// only the partial one and resolves to 202, not 204. +template +struct dto_alternate_status> { + static constexpr int value = 201; +}; + +template +struct dto_alternate_status> { + static constexpr int value = 202; +}; + +/// The payload a status wrapper carries. An unwrapped type is its own payload, +/// so every existing route keeps its current schema and serialization. +/// +/// The typed route helpers must ask this - never the wrapper itself - for the +/// schema `$ref`, the `has_dto_shape_v` assertion and the body writer. The +/// wrappers deliberately have no `dto_fields` / `dto_name` specialization. +template +struct status_payload { + using type = T; +}; +template +struct status_payload> { + using type = T; +}; +template +struct status_payload> { + using type = T; +}; +template +using status_payload_t = typename status_payload::type; + +/// Unwrap a status wrapper to the value the body writer should serialize. +/// Call qualified (`http::status_body(v)`): ADL finds these overloads for +/// `Created` but not for a bare DTO in namespace `dto`. +/// +/// Overload resolution picks the wrapper overloads over the generic one by +/// partial ordering of function templates - both are exact matches, and the +/// wrapper form is more specialized. +template +const T & status_body(const T & value) { + return value; +} +template +const T & status_body(const Created & wrapped) { + return wrapped.value; +} +template +const T & status_body(const Accepted & wrapped) { + return wrapped.value; +} + } // namespace http } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handler_result.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handler_result.hpp index 5296b90ad..63ace3b57 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handler_result.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handler_result.hpp @@ -67,6 +67,29 @@ static_assert(kValidatorVariantOrderingOk, "ErrorInfo must be the first va template using ValidatorResult = tl::expected>; +/// Carries a response's HTTP status in its type, so the route registry derives +/// the declared status from the handler's signature and the document cannot +/// drift from the wire. +/// +/// Needed where the payload type does not imply the status on its own: a +/// `dto::Trigger` is a 201 body when created and a 200 body when read, so the +/// status cannot live on the DTO. Wrapping is what distinguishes the two. +/// +/// The registry never introspects the wrapper - it asks +/// `dto_alternate_status` for the status and `status_payload_t` for the +/// schema, the serializer and the static assertions. +template +struct Created { + T value; +}; + +/// See Created. Declares 202 Accepted. `Accepted` is the shape for +/// an asynchronous transition that returns no body. +template +struct Accepted { + T value; +}; + /// Side-channel a handler can attach to its successful response when the /// default "200 OK + DTO body" is not enough. Examples: /// - 201 Created with a `Location` header for POST creating a resource. diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/cyclic_subscription_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/cyclic_subscription_handlers.hpp index ca6db0823..0c820050f 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/cyclic_subscription_handlers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/cyclic_subscription_handlers.hpp @@ -56,8 +56,9 @@ struct ParsedResourceUri { * The SSE event-stream route uses the `reg.sse<>` escape hatch and returns a * `Result` factory; the framework drives the chunked content * provider. The transport's `make_sse_stream` builds the `next_event` closure. - * CRUD POST uses the attachments variant so it can override the status to 201 - * without re-introducing a `httplib::Response &` parameter. + * CRUD POST returns `Created` so the 201 status lives in + * the signature and the generated document cannot declare a status the handler + * never emits. */ class CyclicSubscriptionHandlers { public: @@ -67,10 +68,9 @@ class CyclicSubscriptionHandlers { /// POST /{entity}/cyclic-subscriptions - create subscription. /// - /// On success returns the new `CyclicSubscription` body with a 201 status - /// override. - http::Result> - post_subscription(const http::TypedRequest & req, dto::CyclicSubscriptionCreateRequest body); + /// On success returns the new `CyclicSubscription` body as 201 Created. + http::Result> post_subscription(const http::TypedRequest & req, + dto::CyclicSubscriptionCreateRequest body); /// GET /{entity}/cyclic-subscriptions - list all subscriptions for entity. http::Result> get_subscriptions(const http::TypedRequest & req); diff --git a/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp b/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp index 1e15e6cd1..777f581b4 100644 --- a/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp +++ b/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp @@ -15,6 +15,7 @@ #include "route_registry.hpp" #include +#include #include #include #include @@ -135,6 +136,41 @@ RouteEntry & RouteEntry::hidden() { return *this; } +RouteEntry & RouteEntry::mark_alternates() { + alternates_ = true; + return *this; +} + +RouteEntry & RouteEntry::success_description(const std::string & desc) { + for (auto & [code, info] : responses_) { + if (code >= 200 && code < 300) { + info.desc = desc; + } + } + return *this; +} + +RouteEntry & RouteEntry::errors(std::initializer_list codes) { + for (int code : codes) { + if (code < 400) { + // Not an error status. Recording it rather than silently dropping it is + // what turns a miscall into a validate_completeness() issue. + rejected_error_codes_.push_back(code); + continue; + } + declared_errors_.push_back(code); + } + return *this; +} + +RouteEntry & RouteEntry::only_status(int code, const std::string & desc) { + responses_.clear(); + declared_errors_.clear(); + only_status_ = true; + responses_[code] = {desc, {}}; + return *this; +} + RouteEntry & RouteEntry::error_renderer(ErrorRenderer renderer) { // The shared_ptr is captured by the typed handler wrapper closure; mutating // through it is the mechanism by which `.error_renderer(...)` called AFTER @@ -462,6 +498,10 @@ nlohmann::json RouteRegistry::to_openapi_paths() const { if (route.deprecated_) { operation["deprecated"] = true; } + if (route.alternates_) { + // The handler returns a variant, so more than one 2xx code is genuine. + operation["x-medkit-alternates"] = true; + } // Parameters if (!route.parameters_.empty()) { @@ -557,9 +597,19 @@ nlohmann::json RouteRegistry::to_openapi_paths() const { } }; - add_error_ref("400"); - add_error_ref("404"); - add_error_ref("500"); + for (int code : route.declared_errors_) { + add_error_ref(std::to_string(code)); + } + + // `only_status()` states the route has exactly one outcome, so the blanket + // set would document statuses it can never emit. The auth refs below stay: + // 401/403 come from the auth middleware ahead of the handler and are + // reachable on every route regardless of what the handler can return. + if (!route.only_status_) { + add_error_ref("400"); + add_error_ref("404"); + add_error_ref("500"); + } if (auth_enabled_) { add_error_ref("401"); @@ -663,6 +713,14 @@ std::vector RouteRegistry::validate_completeness() const { issues.push_back({ValidationIssue::Severity::kError, route_id, "Missing tag"}); } + // errors() only accepts 4xx/5xx. A success or redirect status passed there + // was dropped, so report it rather than let the route quietly lose it. + for (int code : route.rejected_error_codes_) { + issues.push_back({ValidationIssue::Severity::kError, route_id, + "errors() ignored non-error status " + std::to_string(code) + + "; use response() for success and redirect statuses"}); + } + // Check response schemas for non-DELETE methods if (route.method_ != "delete") { bool has_success_response_with_schema = false; @@ -679,8 +737,15 @@ std::vector RouteRegistry::validate_completeness() const { route.summary_.find("stream") != std::string::npos || route.summary_.find("Stream") != std::string::npos; - // 204 No Content responses don't need a schema - bool has_204 = route.responses_.count(204) > 0; + // Body-less success statuses need no schema: 204 never carries a body, + // and a 202 declared without one is an accepted asynchronous transition + // (`Accepted`). Mirrors the "202 without content is OK" + // branch of the served-document completeness gate. + bool has_bodyless_success = route.responses_.count(204) > 0; + if (auto accepted = route.responses_.find(202); + accepted != route.responses_.end() && accepted->second.schema.empty()) { + has_bodyless_success = true; + } // Endpoints that only return errors (e.g., 405) don't need success schemas bool has_only_error_responses = !route.responses_.empty(); @@ -691,7 +756,7 @@ std::vector RouteRegistry::validate_completeness() const { } } - if (!has_success_response_with_schema && !is_sse && !has_204 && !has_only_error_responses) { + if (!has_success_response_with_schema && !is_sse && !has_bodyless_success && !has_only_error_responses) { issues.push_back({ValidationIssue::Severity::kError, route_id, "Missing response schema for success (2xx)"}); } } else { 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 e89268ee6..280a1f063 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/bulkdata_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/bulkdata_handlers.cpp @@ -451,7 +451,7 @@ http::Result BulkDataHandlers::download(const http::TypedR // POST /{entity}/bulk-data/{category_id} - multipart upload (201 + Location) // --------------------------------------------------------------------------- -http::Result> +http::Result, http::ResponseAttachments>> BulkDataHandlers::upload(const http::TypedRequest & req, const http::MultipartBody & body) { auto path_info = parse_path(req); if (!path_info) { @@ -563,8 +563,8 @@ BulkDataHandlers::upload(const http::TypedRequest & req, const http::MultipartBo } http::ResponseAttachments att; - att.with_status(201).with_header("Location", req.path() + "/" + stored.id); - return std::make_pair(std::move(descriptor), std::move(att)); + att.with_header("Location", req.path() + "/" + stored.id); + return std::make_pair(http::Created{std::move(descriptor)}, std::move(att)); } // --------------------------------------------------------------------------- diff --git a/src/ros2_medkit_gateway/src/http/handlers/cyclic_subscription_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/cyclic_subscription_handlers.cpp index fa67b6584..f9f48687b 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/cyclic_subscription_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/cyclic_subscription_handlers.cpp @@ -83,7 +83,7 @@ CyclicSubscriptionHandlers::CyclicSubscriptionHandlers(HandlerContext & ctx, Sub // --------------------------------------------------------------------------- // POST - create subscription // --------------------------------------------------------------------------- -http::Result> +http::Result> CyclicSubscriptionHandlers::post_subscription(const http::TypedRequest & req, dto::CyclicSubscriptionCreateRequest body) { auto id_result = read_entity_id(req); @@ -200,9 +200,7 @@ CyclicSubscriptionHandlers::post_subscription(const http::TypedRequest & req, } auto sub_dto = subscription_to_dto(*result, *event_source_result); - http::ResponseAttachments att; - att.with_status(201); - return std::make_pair(std::move(sub_dto), std::move(att)); + return http::Created{std::move(sub_dto)}; } // --------------------------------------------------------------------------- diff --git a/src/ros2_medkit_gateway/src/http/handlers/lifecycle_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/lifecycle_handlers.cpp index 18001ff90..efd23eaa2 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/lifecycle_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/lifecycle_handlers.cpp @@ -194,7 +194,7 @@ http::Result LifecycleHandlers::handle_get_status( // PUT /{entity}/status/{action} // ============================================================================= -http::Result> +http::Result, http::ResponseAttachments>> LifecycleHandlers::handle_transition(const http::TypedRequest & req, std::string_view transition) { auto id_raw = req.path_param("1"); if (!id_raw) { @@ -220,8 +220,8 @@ LifecycleHandlers::handle_transition(const http::TypedRequest & req, std::string return tl::make_unexpected(to_error_info(result.error())); } http::ResponseAttachments att; - att.with_status(202).with_header("Location", base + "/status"); - return std::make_pair(http::NoContent{}, std::move(att)); + att.with_header("Location", base + "/status"); + return std::make_pair(http::Accepted{http::NoContent{}}, std::move(att)); } catch (const std::exception & e) { RCLCPP_ERROR(HandlerContext::logger(), "Plugin LifecycleProvider threw for entity '%s': %s", entity_id.c_str(), e.what()); diff --git a/src/ros2_medkit_gateway/src/http/handlers/lock_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/lock_handlers.cpp index 89d96c017..c7b6b30f0 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/lock_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/lock_handlers.cpp @@ -168,8 +168,8 @@ std::string LockHandlers::format_expiration(std::chrono::steady_clock::time_poin // Handler implementations // ============================================================================ -http::Result> LockHandlers::post_lock(const http::TypedRequest & req, - dto::AcquireLockRequest body) { +http::Result, http::ResponseAttachments>> +LockHandlers::post_lock(const http::TypedRequest & req, dto::AcquireLockRequest body) { if (auto guard = check_locking_enabled(); !guard) { return tl::unexpected(guard.error()); } @@ -235,8 +235,8 @@ http::Result> LockHandlers::post auto lock_dto = lock_info_to_dto(*result, client_id); http::ResponseAttachments att; - att.with_status(201).with_header("Location", std::string(req.path()) + "/" + result->lock_id); - return std::make_pair(std::move(lock_dto), std::move(att)); + att.with_header("Location", std::string(req.path()) + "/" + result->lock_id); + return std::make_pair(http::Created{std::move(lock_dto)}, std::move(att)); } catch (const std::exception & e) { return tl::unexpected( diff --git a/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp index 5f535c8ba..972e67925 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp @@ -786,9 +786,9 @@ http::Result OperationHandlers::cancel_execution(const http::Ty // PUT /{entity}/operations/{op_id}/executions/{exec_id} - update execution // ============================================================================= -http::Result> +http::Result, http::ResponseAttachments>> OperationHandlers::update_execution(const http::TypedRequest & req, const dto::ExecutionUpdateRequest & body) { - using SuccessPair = std::pair; + using SuccessPair = std::pair, http::ResponseAttachments>; auto id_result = read_entity_id(req); if (!id_result) { @@ -844,8 +844,8 @@ OperationHandlers::update_execution(const http::TypedRequest & req, const dto::E exec_dto.status = "running"; // canceling is still "running" in SOVD terms http::ResponseAttachments att; - att.with_status(202).with_header("Location", location); - return SuccessPair{std::move(exec_dto), std::move(att)}; + att.with_header("Location", location); + return SuccessPair{http::Accepted{std::move(exec_dto)}, std::move(att)}; } std::string error_msg; switch (result.return_code) { diff --git a/src/ros2_medkit_gateway/src/http/handlers/script_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/script_handlers.cpp index 844c5f1e3..853cad297 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/script_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/script_handlers.cpp @@ -174,7 +174,7 @@ http::Result ScriptHandlers::list_scripts(const http::TypedRequ // POST /{entity}/scripts - multipart upload, 201 + Location // --------------------------------------------------------------------------- -http::Result> +http::Result, http::ResponseAttachments>> ScriptHandlers::upload_script(const http::TypedRequest & req, const http::MultipartBody & body) { if (!script_mgr_ || !script_mgr_->has_backend()) { return tl::unexpected(make_error(501, ERR_NOT_IMPLEMENTED, "Scripts backend not configured")); @@ -249,8 +249,8 @@ ScriptHandlers::upload_script(const http::TypedRequest & req, const http::Multip upload_resp.name = result->name; http::ResponseAttachments att; - att.with_status(201).with_header("Location", script_path); - return std::make_pair(std::move(upload_resp), std::move(att)); + att.with_header("Location", script_path); + return std::make_pair(http::Created{std::move(upload_resp)}, std::move(att)); } catch (const std::exception & e) { return tl::unexpected(make_error(500, ERR_INTERNAL_ERROR, e.what())); } @@ -353,7 +353,7 @@ http::Result ScriptHandlers::delete_script(const http::TypedReq // POST /{entity}/scripts/{script_id}/executions - 202 + Location // --------------------------------------------------------------------------- -http::Result> +http::Result, http::ResponseAttachments>> ScriptHandlers::start_execution(const http::TypedRequest & req) { if (!script_mgr_ || !script_mgr_->has_backend()) { return tl::unexpected(make_error(501, ERR_NOT_IMPLEMENTED, "Scripts backend not configured")); @@ -427,8 +427,8 @@ ScriptHandlers::start_execution(const http::TypedRequest & req) { api_path("/" + entity_type_segment + "/" + entity_id + "/scripts/" + script_id + "/executions/" + result->id); http::ResponseAttachments att; - att.with_status(202).with_header("Location", exec_path); - return std::make_pair(execution_info_to_dto(*result), std::move(att)); + att.with_header("Location", exec_path); + return std::make_pair(http::Accepted{execution_info_to_dto(*result)}, std::move(att)); } catch (const std::exception & e) { return tl::unexpected(make_error(500, ERR_INTERNAL_ERROR, e.what())); } diff --git a/src/ros2_medkit_gateway/src/http/handlers/trigger_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/trigger_handlers.cpp index d5d5d5a7a..b6dd53ded 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/trigger_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/trigger_handlers.cpp @@ -106,8 +106,8 @@ TriggerHandlers::TriggerHandlers(HandlerContext & ctx, TriggerManager & trigger_ // --------------------------------------------------------------------------- // POST - create trigger // --------------------------------------------------------------------------- -http::Result> -TriggerHandlers::post_trigger(const http::TypedRequest & req, dto::TriggerCreateRequest body) { +http::Result> TriggerHandlers::post_trigger(const http::TypedRequest & req, + dto::TriggerCreateRequest body) { auto id_result = read_entity_id(req); if (!id_result) { return tl::unexpected(id_result.error()); @@ -271,9 +271,7 @@ TriggerHandlers::post_trigger(const http::TypedRequest & req, dto::TriggerCreate auto event_source = build_event_source(*result); auto trigger_dto = trigger_info_to_dto(*result, event_source); - http::ResponseAttachments att; - att.with_status(201); - return std::make_pair(std::move(trigger_dto), std::move(att)); + return http::Created{std::move(trigger_dto)}; } // --------------------------------------------------------------------------- diff --git a/src/ros2_medkit_gateway/src/http/handlers/update_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/update_handlers.cpp index 83918b2a2..935e49b9d 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/update_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/update_handlers.cpp @@ -241,7 +241,7 @@ http::Result UpdateHandlers::get_update(const http::TypedRequ } } -http::Result> +http::Result, http::ResponseAttachments>> UpdateHandlers::post_update(const http::TypedRequest & /*req*/, dto::UpdateRegisterRequest body) { if (auto guard = check_backend()) { return tl::unexpected(*guard); @@ -269,8 +269,8 @@ UpdateHandlers::post_update(const http::TypedRequest & /*req*/, dto::UpdateRegis dto::UpdateRegisterResponse resp; resp.id = id; http::ResponseAttachments att; - att.with_status(201).with_header("Location", api_path("/updates/" + id)); - return std::make_pair(std::move(resp), std::move(att)); + att.with_header("Location", api_path("/updates/" + id)); + return std::make_pair(http::Created{std::move(resp)}, std::move(att)); } catch (const std::exception & e) { return tl::unexpected(make_internal_error("post_update", e)); } @@ -301,7 +301,7 @@ http::Result UpdateHandlers::del_update(const http::TypedReques } } -http::Result> +http::Result, http::ResponseAttachments>> UpdateHandlers::put_prepare(const http::TypedRequest & req) { if (auto guard = check_backend()) { return tl::unexpected(*guard); @@ -322,14 +322,14 @@ UpdateHandlers::put_prepare(const http::TypedRequest & req) { return tl::unexpected(map_prepare_error(result.error())); } http::ResponseAttachments att; - att.with_status(202).with_header("Location", api_path("/updates/" + id + "/status")); - return std::make_pair(http::NoContent{}, std::move(att)); + att.with_header("Location", api_path("/updates/" + id + "/status")); + return std::make_pair(http::Accepted{http::NoContent{}}, std::move(att)); } catch (const std::exception & e) { return tl::unexpected(make_internal_error("put_prepare", e)); } } -http::Result> +http::Result, http::ResponseAttachments>> UpdateHandlers::put_execute(const http::TypedRequest & req) { if (auto guard = check_backend()) { return tl::unexpected(*guard); @@ -350,14 +350,14 @@ UpdateHandlers::put_execute(const http::TypedRequest & req) { return tl::unexpected(map_execute_error(result.error())); } http::ResponseAttachments att; - att.with_status(202).with_header("Location", api_path("/updates/" + id + "/status")); - return std::make_pair(http::NoContent{}, std::move(att)); + att.with_header("Location", api_path("/updates/" + id + "/status")); + return std::make_pair(http::Accepted{http::NoContent{}}, std::move(att)); } catch (const std::exception & e) { return tl::unexpected(make_internal_error("put_execute", e)); } } -http::Result> +http::Result, http::ResponseAttachments>> UpdateHandlers::put_automated(const http::TypedRequest & req) { if (auto guard = check_backend()) { return tl::unexpected(*guard); @@ -378,8 +378,8 @@ UpdateHandlers::put_automated(const http::TypedRequest & req) { return tl::unexpected(map_automated_error(result.error())); } http::ResponseAttachments att; - att.with_status(202).with_header("Location", api_path("/updates/" + id + "/status")); - return std::make_pair(http::NoContent{}, std::move(att)); + att.with_header("Location", api_path("/updates/" + id + "/status")); + return std::make_pair(http::Accepted{http::NoContent{}}, std::move(att)); } catch (const std::exception & e) { return tl::unexpected(make_internal_error("put_automated", e)); } diff --git a/src/ros2_medkit_gateway/src/http/rest_server.cpp b/src/ros2_medkit_gateway/src/http/rest_server.cpp index 958b8b67b..def26838b 100644 --- a/src/ros2_medkit_gateway/src/http/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/http/rest_server.cpp @@ -683,18 +683,18 @@ void RESTServer::setup_routes() { .description("Returns the current status and result of a specific execution.") .operation_id(std::string("get") + capitalize(et.singular) + "Execution"); - reg.put( + reg.put>( entity_path + "/operations/{operation_id}/executions/{execution_id}", - std::function>( + std::function, http::ResponseAttachments>>( http::TypedRequest, dto::ExecutionUpdateRequest)>{ [this](http::TypedRequest req, const dto::ExecutionUpdateRequest & body) - -> http::Result> { + -> http::Result, http::ResponseAttachments>> { return operation_handlers_->update_execution(req, body); }}) .tag("Operations") .summary(std::string("Update execution for ") + et.singular) .description("Sends a control command to a running execution.") - .response(202, "Accepted (asynchronous control)", SB::ref("OperationExecution")) + .success_description("Accepted (asynchronous control)") .operation_id(std::string("update") + capitalize(et.singular) + "Execution"); reg.del(entity_path + "/operations/{operation_id}/executions/{execution_id}", @@ -899,16 +899,16 @@ void RESTServer::setup_routes() { // Upload: only for apps and components (405 for areas and functions) std::string et_type_str = et.type; if (et_type_str == "apps" || et_type_str == "components") { - reg.multipart_upload( + reg.multipart_upload>( entity_path + "/bulk-data/{category_id}", [this](http::TypedRequest req, const http::MultipartBody & body) - -> http::Result> { + -> http::Result, http::ResponseAttachments>> { return bulkdata_handlers_->upload(req, body); }) .tag("Bulk Data") .summary(std::string("Upload bulk-data for ") + et.singular) .description(std::string("Uploads a file to a bulk-data category for this ") + et.singular + ".") - .response(201, "File uploaded", SB::ref("BulkDataDescriptor")) + .success_description("File uploaded") .operation_id(std::string("upload") + capitalize(et.singular) + "BulkData"); reg.del(entity_path + "/bulk-data/{category_id}/{file_id}", @@ -989,10 +989,10 @@ void RESTServer::setup_routes() { .description(std::string("Server-Sent Events stream for trigger notifications on this ") + et.singular + ".") .operation_id(std::string("stream") + capitalize(et.singular) + "TriggerEvents"); - reg.post( + reg.post>( entity_path + "/triggers", - [this, make_not_available_error](http::TypedRequest req, dto::TriggerCreateRequest body) - -> http::Result> { + [this, make_not_available_error]( + http::TypedRequest req, dto::TriggerCreateRequest body) -> http::Result> { if (!trigger_handlers_) { return tl::unexpected(make_not_available_error()); } @@ -1001,7 +1001,7 @@ void RESTServer::setup_routes() { .tag("Triggers") .summary(std::string("Create trigger for ") + et.singular) .description(std::string("Creates a new event trigger for this ") + et.singular + ".") - .response(201, "Trigger created", SB::ref("Trigger")) + .success_description("Trigger created") .operation_id(std::string("create") + capitalize(et.singular) + "Trigger"); reg.get>( @@ -1079,16 +1079,16 @@ void RESTServer::setup_routes() { .description(std::string("Server-Sent Events stream for subscription data on this ") + et.singular + ".") .operation_id(std::string("stream") + capitalize(et.singular) + "SubscriptionEvents"); - reg.post( + reg.post>( entity_path + "/cyclic-subscriptions", - [this](http::TypedRequest req, dto::CyclicSubscriptionCreateRequest body) - -> http::Result> { + [this](http::TypedRequest req, + dto::CyclicSubscriptionCreateRequest body) -> http::Result> { return cyclic_sub_handlers_->post_subscription(req, std::move(body)); }) .tag("Subscriptions") .summary(std::string("Create cyclic subscription for ") + et.singular) .description(std::string("Creates a new cyclic data subscription for this ") + et.singular + ".") - .response(201, "Subscription created", SB::ref("CyclicSubscription")) + .success_description("Subscription created") .operation_id(std::string("create") + capitalize(et.singular) + "Subscription"); reg.get>( @@ -1141,17 +1141,17 @@ void RESTServer::setup_routes() { // emit 201 + Location without re-introducing httplib::Response. static const nlohmann::json client_id_schema = {{"type", "string"}, {"minLength", 1}, {"maxLength", 256}}; - reg.post( + reg.post>( entity_path + "/locks", - [this](http::TypedRequest req, - dto::AcquireLockRequest body) -> http::Result> { + [this](http::TypedRequest req, dto::AcquireLockRequest body) + -> http::Result, http::ResponseAttachments>> { return lock_handlers_->post_lock(req, std::move(body)); }) .tag("Locking") .summary(std::string("Acquire lock on ") + et.singular) .description(std::string("Acquires an exclusive lock on this ") + et.singular + ".") .header_param("X-Client-Id", "Unique client identifier for lock ownership", true, client_id_schema) - .response(201, "Lock acquired", SB::ref("Lock")) + .success_description("Lock acquired") .operation_id(std::string("acquire") + capitalize(et.singular) + "Lock"); reg.get>(entity_path + "/locks", @@ -1210,16 +1210,16 @@ void RESTServer::setup_routes() { // calls stay only where the schema differs (multipart upload + free-form // start-execution body). if (script_handlers_ && (et_type_str == "apps" || et_type_str == "components")) { - reg.multipart_upload( + reg.multipart_upload>( entity_path + "/scripts", [this](http::TypedRequest req, const http::MultipartBody & body) - -> http::Result> { + -> http::Result, http::ResponseAttachments>> { return script_handlers_->upload_script(req, body); }) .tag("Scripts") .summary(std::string("Upload diagnostic script for ") + et.singular) .description(std::string("Uploads a diagnostic script for this ") + et.singular + ".") - .response(201, "Script uploaded", SB::ref("ScriptUploadResponse")) + .success_description("Script uploaded") .operation_id(std::string("upload") + capitalize(et.singular) + "Script"); reg.get(entity_path + "/scripts", @@ -1249,16 +1249,17 @@ void RESTServer::setup_routes() { .description(std::string("Deletes a diagnostic script from this ") + et.singular + ".") .operation_id(std::string("delete") + capitalize(et.singular) + "Script"); - reg.post(entity_path + "/scripts/{script_id}/executions", - [this](http::TypedRequest req) - -> http::Result> { - return script_handlers_->start_execution(req); - }) + reg.post>( + entity_path + "/scripts/{script_id}/executions", + [this](http::TypedRequest req) + -> http::Result, http::ResponseAttachments>> { + return script_handlers_->start_execution(req); + }) .tag("Scripts") .summary(std::string("Start script execution for ") + et.singular) .description(std::string("Starts execution of a diagnostic script on this ") + et.singular + ".") .request_body("Execution parameters", SB::generic_object_schema()) - .response(202, "Execution started", SB::ref("ScriptExecution")) + .success_description("Execution started") .operation_id(std::string("start") + capitalize(et.singular) + "ScriptExecution"); reg.get(entity_path + "/scripts/{script_id}/executions/{execution_id}", @@ -1592,10 +1593,10 @@ void RESTServer::setup_routes() { .operation_id("listUpdates") .query(); - reg.post( + reg.post>( "/updates", [this](http::TypedRequest req, dto::UpdateRegisterRequest body) - -> http::Result> { + -> http::Result, http::ResponseAttachments>> { if (!update_handlers_) { return tl::unexpected(kUpdate501); } @@ -1604,7 +1605,7 @@ void RESTServer::setup_routes() { .tag("Updates") .summary("Register a software update") .description("Registers a new software update descriptor.") - .response(201, "Update registered", SB::ref("UpdateRegisterResponse")) + .success_description("Update registered") .operation_id("registerUpdate"); reg.get("/updates/{update_id}/status", @@ -1619,9 +1620,10 @@ void RESTServer::setup_routes() { .description("Returns the current status and progress of an update.") .operation_id("getUpdateStatus"); - reg.put( + reg.put>( "/updates/{update_id}/prepare", - [this](http::TypedRequest req) -> http::Result> { + [this](http::TypedRequest req) + -> http::Result, http::ResponseAttachments>> { if (!update_handlers_) { return tl::unexpected(kUpdate501); } @@ -1630,12 +1632,13 @@ void RESTServer::setup_routes() { .tag("Updates") .summary("Prepare update for execution") .description("Prepares an update for execution (downloads, validates).") - .response(202, "Update preparation started") + .success_description("Update preparation started") .operation_id("prepareUpdate"); - reg.put( + reg.put>( "/updates/{update_id}/execute", - [this](http::TypedRequest req) -> http::Result> { + [this](http::TypedRequest req) + -> http::Result, http::ResponseAttachments>> { if (!update_handlers_) { return tl::unexpected(kUpdate501); } @@ -1644,12 +1647,13 @@ void RESTServer::setup_routes() { .tag("Updates") .summary("Execute update") .description("Starts executing a prepared update.") - .response(202, "Update execution started") + .success_description("Update execution started") .operation_id("executeUpdate"); - reg.put( + reg.put>( "/updates/{update_id}/automated", - [this](http::TypedRequest req) -> http::Result> { + [this](http::TypedRequest req) + -> http::Result, http::ResponseAttachments>> { if (!update_handlers_) { return tl::unexpected(kUpdate501); } @@ -1658,7 +1662,7 @@ void RESTServer::setup_routes() { .tag("Updates") .summary("Run automated update") .description("Runs a fully automated update (prepare + execute).") - .response(202, "Automated update started") + .success_description("Automated update started") .operation_id("automateUpdate"); reg.get("/updates/{update_id}", @@ -1748,14 +1752,15 @@ void RESTServer::setup_routes() { cap_next = false; } } - reg.put(base_lc + "/status/" + action, - [this, action_str](http::TypedRequest req) - -> http::Result> { - return lifecycle_handlers_->handle_transition(req, action_str); - }) + reg.put>( + base_lc + "/status/" + action, + [this, action_str](http::TypedRequest req) + -> http::Result, http::ResponseAttachments>> { + return lifecycle_handlers_->handle_transition(req, action_str); + }) .tag("Lifecycle") .summary(std::string("Request lifecycle transition '") + action + "'") - .response(202, "Lifecycle transition accepted") + .success_description("Lifecycle transition accepted") .operation_id(std::string("put").append(entity_cap).append("Status").append(action_cap)); } diff --git a/src/ros2_medkit_gateway/src/openapi/route_registry.hpp b/src/ros2_medkit_gateway/src/openapi/route_registry.hpp index 1c5a015b2..ebceb7a68 100644 --- a/src/ros2_medkit_gateway/src/openapi/route_registry.hpp +++ b/src/ros2_medkit_gateway/src/openapi/route_registry.hpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -96,6 +97,33 @@ class RouteEntry { RouteEntry & deprecated(); RouteEntry & operation_id(const std::string & id); + /// Mark this route as returning one of several alternative success bodies, + /// emitted as `x-medkit-alternates: true` on the operation. Set by the + /// `post_alternates` / `del_alternates` helpers, so a route carries the + /// marker exactly when its handler returns a `std::variant` - the document + /// contract test uses it to tell a genuine multi-2xx operation from a route + /// that declares a status it can never return. + RouteEntry & mark_alternates(); + + /// Author the prose published for this route's success response(s), leaving + /// the status and the schema derived from the handler's return type. Use it + /// instead of a hand-attached `response(201, "Trigger created", ref(...))`: + /// restating the status at the call site is what let the document declare a + /// status the handler could not return. + /// + /// Applies to every declared 2xx, which for a derived route is the single + /// one `TResponse` produced. + RouteEntry & success_description(const std::string & desc); + + /// Declare additional error statuses this route can emit. Statuses below 400 + /// are ignored and reported by validate_completeness() - use response() for + /// success and redirect statuses. + RouteEntry & errors(std::initializer_list codes); + + /// This route can only ever return `code`. Clears every other response and + /// suppresses the blanket 400/404/500 injection. + RouteEntry & only_status(int code, const std::string & desc); + /// Hide this route from the OpenAPI spec output. /// The route is still registered with cpp-httplib and serves HTTP requests, /// but it won't appear in the generated spec or client code. @@ -122,6 +150,10 @@ class RouteEntry { HandlerFn handler_; bool deprecated_{false}; bool hidden_{false}; + /// Set by mark_alternates(); emitted as `x-medkit-alternates: true`. + bool alternates_{false}; + /// Set by only_status(); suppresses the blanket 400/404/500 injection. + bool only_status_{false}; std::string operation_id_; /// Heap-allocated so the typed wrapper closure can hold a stable handle to @@ -134,6 +166,14 @@ class RouteEntry { }; std::map responses_; + /// Error statuses declared via errors(), rendered as GenericError $refs + /// alongside the blanket 400/404/500 set. + std::vector declared_errors_; + /// Non-error statuses passed to errors() and therefore ignored. Kept so + /// validate_completeness() can report the miscall instead of it passing + /// silently. + std::vector rejected_error_codes_; + struct RequestBodyInfo { std::string desc; nlohmann::json schema; @@ -513,23 +553,50 @@ inline tl::expected parse_request_body(const httplib::Request return tl::make_unexpected(std::move(info)); } +/// Default prose for a derived success status. Generated clients surface this +/// text, so it must read correctly on its own: the status now comes from the +/// handler's return type, and a route that returns `Accepted` must +/// not be published as "No content" just because it has no body. Call sites +/// that want something specific ("Trigger created") say so via +/// `RouteEntry::success_description`, which never restates the status. +inline const char * default_success_description(int status) { + switch (status) { + case 201: + return "Created"; + case 202: + return "Accepted"; + case 204: + return "No content"; + default: + return "Successful response"; + } +} + } // namespace detail template void RouteRegistry::write_success_body(httplib::Response & res, const TResponse & value, int status) { - if constexpr (std::is_same_v) { - res.status = (status == 0) ? 204 : status; - // 204 No Content must have no body. + // TResponse may be a status wrapper (http::Created / http::Accepted). + // The wire shape comes from the payload; the default status comes from the + // wrapper. Callers that pass status == 0 rely on that default, so it must + // not be a literal here or a wrapped response silently downgrades to 200. + using Payload = http::status_payload_t; + const int default_status = http::dto_alternate_status::value; + if constexpr (std::is_same_v) { + res.status = (status == 0) ? default_status : status; + // No body: 204, and 202 for an accepted asynchronous transition. res.body.clear(); - } else if constexpr (std::is_same_v) { + } else if constexpr (std::is_same_v) { // Raw JSON escape hatch (docs_endpoint). - http::detail::write_json_body(http::detail::FrameworkOrPluginAccess{}, res, value, status == 0 ? 200 : status); + http::detail::write_json_body(http::detail::FrameworkOrPluginAccess{}, res, http::status_body(value), + status == 0 ? default_status : status); } else { - static_assert(dto::has_dto_shape_v, + static_assert(dto::has_dto_shape_v, "RouteRegistry typed response must be a DTO (regular or opaque), NoContent, " - "or nlohmann::json (escape hatch)"); - auto body = dto::JsonWriter::write(value); - http::detail::write_json_body(http::detail::FrameworkOrPluginAccess{}, res, body, status == 0 ? 200 : status); + "or nlohmann::json (escape hatch), optionally wrapped in Created<>/Accepted<>"); + auto body = dto::JsonWriter::write(http::status_body(value)); + http::detail::write_json_body(http::detail::FrameworkOrPluginAccess{}, res, body, + status == 0 ? default_status : status); } } @@ -564,7 +631,10 @@ HandlerFn RouteRegistry::wrap_body_less_with_attachments( auto outcome = handler(typed_req); if (outcome.has_value()) { const auto & att = outcome.value().second; - int status = att.status_override.value_or(std::is_same_v ? 204 : 200); + // Default status comes from the return type (Created -> 201, + // Accepted -> 202, NoContent -> 204, plain DTO -> 200), never from a + // literal, so a wrapped response cannot silently downgrade to 200. + int status = att.status_override.value_or(http::dto_alternate_status::value); write_success_body(res, outcome.value().first, status); apply_attachments(res, att); return; @@ -615,7 +685,10 @@ HandlerFn RouteRegistry::wrap_with_body_attachments( auto outcome = handler(typed_req, std::move(body.value())); if (outcome.has_value()) { const auto & att = outcome.value().second; - int status = att.status_override.value_or(std::is_same_v ? 204 : 200); + // Default status comes from the return type (Created -> 201, + // Accepted -> 202, NoContent -> 204, plain DTO -> 200), never from a + // literal, so a wrapped response cannot silently downgrade to 200. + int status = att.status_override.value_or(http::dto_alternate_status::value); write_success_body(res, outcome.value().first, status); apply_attachments(res, att); return; @@ -717,14 +790,18 @@ RouteRegistry::wrap_del_alternates(std::function RouteEntry & RouteRegistry::get(const std::string & openapi_path, std::function(http::TypedRequest)> handler) { - static_assert(dto::has_dto_shape_v || std::is_same_v, + static_assert(dto::has_dto_shape_v> || + std::is_same_v, http::NoContent>, "typed get: T must be a DTO (or NoContent)"); auto & entry = add_route("get", openapi_path, /*placeholder*/ HandlerFn{}); entry.handler_ = wrap_body_less(std::move(handler), entry.error_renderer_); - if constexpr (!std::is_same_v) { - entry.template response(200, ""); + if constexpr (!std::is_same_v, http::NoContent>) { + entry.template response>( + http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } else { - entry.response(204, "No content"); + entry.response(http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } return entry; } @@ -733,14 +810,18 @@ template RouteEntry & RouteRegistry::get( const std::string & openapi_path, std::function>(http::TypedRequest)> handler) { - static_assert(dto::has_dto_shape_v || std::is_same_v, + static_assert(dto::has_dto_shape_v> || + std::is_same_v, http::NoContent>, "typed get: T must be a DTO (or NoContent)"); auto & entry = add_route("get", openapi_path, HandlerFn{}); entry.handler_ = wrap_body_less_with_attachments(std::move(handler), entry.error_renderer_); - if constexpr (!std::is_same_v) { - entry.template response(200, ""); + if constexpr (!std::is_same_v, http::NoContent>) { + entry.template response>( + http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } else { - entry.response(204, "No content"); + entry.response(http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } return entry; } @@ -749,15 +830,19 @@ template RouteEntry & RouteRegistry::post(const std::string & openapi_path, std::function(http::TypedRequest, TBody)> handler) { static_assert(dto::is_dto_v, "typed post: TB must be a DTO"); - static_assert(dto::has_dto_shape_v || std::is_same_v, + static_assert(dto::has_dto_shape_v> || + std::is_same_v, http::NoContent>, "typed post: T must be a DTO (or NoContent)"); auto & entry = add_route("post", openapi_path, HandlerFn{}); entry.handler_ = wrap_with_body(std::move(handler), entry.error_renderer_); entry.template request_body(""); - if constexpr (!std::is_same_v) { - entry.template response(200, ""); + if constexpr (!std::is_same_v, http::NoContent>) { + entry.template response>( + http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } else { - entry.response(204, "No content"); + entry.response(http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } return entry; } @@ -767,15 +852,19 @@ RouteEntry & RouteRegistry::post( const std::string & openapi_path, std::function>(http::TypedRequest, TBody)> handler) { static_assert(dto::is_dto_v, "typed post: TB must be a DTO"); - static_assert(dto::has_dto_shape_v || std::is_same_v, + static_assert(dto::has_dto_shape_v> || + std::is_same_v, http::NoContent>, "typed post: T must be a DTO (or NoContent)"); auto & entry = add_route("post", openapi_path, HandlerFn{}); entry.handler_ = wrap_with_body_attachments(std::move(handler), entry.error_renderer_); entry.template request_body(""); - if constexpr (!std::is_same_v) { - entry.template response(200, ""); + if constexpr (!std::is_same_v, http::NoContent>) { + entry.template response>( + http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } else { - entry.response(204, "No content"); + entry.response(http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } return entry; } @@ -783,7 +872,8 @@ RouteEntry & RouteRegistry::post( template RouteEntry & RouteRegistry::post(const std::string & openapi_path, std::function(http::TypedRequest)> handler) { - static_assert(dto::has_dto_shape_v || std::is_same_v, + static_assert(dto::has_dto_shape_v> || + std::is_same_v, http::NoContent>, "typed post: T must be a DTO (or NoContent)"); auto & entry = add_route("post", openapi_path, HandlerFn{}); entry.handler_ = wrap_body_less(std::move(handler), entry.error_renderer_); @@ -791,10 +881,13 @@ RouteEntry & RouteRegistry::post(const std::string & openapi_path, // routes that parse the body manually (e.g. form-urlencoded auth endpoints). // Callers attach an explicit `.request_body(...)` to populate the OpenAPI // spec. - if constexpr (!std::is_same_v) { - entry.template response(200, ""); + if constexpr (!std::is_same_v, http::NoContent>) { + entry.template response>( + http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } else { - entry.response(204, "No content"); + entry.response(http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } return entry; } @@ -803,14 +896,18 @@ template RouteEntry & RouteRegistry::post( const std::string & openapi_path, std::function>(http::TypedRequest)> handler) { - static_assert(dto::has_dto_shape_v || std::is_same_v, + static_assert(dto::has_dto_shape_v> || + std::is_same_v, http::NoContent>, "typed post: T must be a DTO (or NoContent)"); auto & entry = add_route("post", openapi_path, HandlerFn{}); entry.handler_ = wrap_body_less_with_attachments(std::move(handler), entry.error_renderer_); - if constexpr (!std::is_same_v) { - entry.template response(200, ""); + if constexpr (!std::is_same_v, http::NoContent>) { + entry.template response>( + http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } else { - entry.response(204, "No content"); + entry.response(http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } return entry; } @@ -819,15 +916,19 @@ template RouteEntry & RouteRegistry::put(const std::string & openapi_path, std::function(http::TypedRequest, TBody)> handler) { static_assert(dto::is_dto_v, "typed put: TB must be a DTO"); - static_assert(dto::has_dto_shape_v || std::is_same_v, + static_assert(dto::has_dto_shape_v> || + std::is_same_v, http::NoContent>, "typed put: T must be a DTO (or NoContent)"); auto & entry = add_route("put", openapi_path, HandlerFn{}); entry.handler_ = wrap_with_body(std::move(handler), entry.error_renderer_); entry.template request_body(""); - if constexpr (!std::is_same_v) { - entry.template response(200, ""); + if constexpr (!std::is_same_v, http::NoContent>) { + entry.template response>( + http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } else { - entry.response(204, "No content"); + entry.response(http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } return entry; } @@ -837,15 +938,19 @@ RouteEntry & RouteRegistry::put( const std::string & openapi_path, std::function>(http::TypedRequest, TBody)> handler) { static_assert(dto::is_dto_v, "typed put: TB must be a DTO"); - static_assert(dto::has_dto_shape_v || std::is_same_v, + static_assert(dto::has_dto_shape_v> || + std::is_same_v, http::NoContent>, "typed put: T must be a DTO (or NoContent)"); auto & entry = add_route("put", openapi_path, HandlerFn{}); entry.handler_ = wrap_with_body_attachments(std::move(handler), entry.error_renderer_); entry.template request_body(""); - if constexpr (!std::is_same_v) { - entry.template response(200, ""); + if constexpr (!std::is_same_v, http::NoContent>) { + entry.template response>( + http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } else { - entry.response(204, "No content"); + entry.response(http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } return entry; } @@ -853,16 +958,20 @@ RouteEntry & RouteRegistry::put( template RouteEntry & RouteRegistry::put(const std::string & openapi_path, std::function(http::TypedRequest)> handler) { - static_assert(dto::has_dto_shape_v || std::is_same_v, + static_assert(dto::has_dto_shape_v> || + std::is_same_v, http::NoContent>, "typed put: T must be a DTO (or NoContent)"); auto & entry = add_route("put", openapi_path, HandlerFn{}); entry.handler_ = wrap_body_less(std::move(handler), entry.error_renderer_); // No automatic request_body schema: body-less typed PUT is reserved for // routes that take no payload at all (e.g. /updates/{id}/prepare). - if constexpr (!std::is_same_v) { - entry.template response(200, ""); + if constexpr (!std::is_same_v, http::NoContent>) { + entry.template response>( + http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } else { - entry.response(204, "No content"); + entry.response(http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } return entry; } @@ -871,14 +980,18 @@ template RouteEntry & RouteRegistry::put( const std::string & openapi_path, std::function>(http::TypedRequest)> handler) { - static_assert(dto::has_dto_shape_v || std::is_same_v, + static_assert(dto::has_dto_shape_v> || + std::is_same_v, http::NoContent>, "typed put: T must be a DTO (or NoContent)"); auto & entry = add_route("put", openapi_path, HandlerFn{}); entry.handler_ = wrap_body_less_with_attachments(std::move(handler), entry.error_renderer_); - if constexpr (!std::is_same_v) { - entry.template response(200, ""); + if constexpr (!std::is_same_v, http::NoContent>) { + entry.template response>( + http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } else { - entry.response(204, "No content"); + entry.response(http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } return entry; } @@ -887,15 +1000,19 @@ template RouteEntry & RouteRegistry::patch(const std::string & openapi_path, std::function(http::TypedRequest, TBody)> handler) { static_assert(dto::is_dto_v, "typed patch: TB must be a DTO"); - static_assert(dto::has_dto_shape_v || std::is_same_v, + static_assert(dto::has_dto_shape_v> || + std::is_same_v, http::NoContent>, "typed patch: T must be a DTO (or NoContent)"); auto & entry = add_route("patch", openapi_path, HandlerFn{}); entry.handler_ = wrap_with_body(std::move(handler), entry.error_renderer_); entry.template request_body(""); - if constexpr (!std::is_same_v) { - entry.template response(200, ""); + if constexpr (!std::is_same_v, http::NoContent>) { + entry.template response>( + http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } else { - entry.response(204, "No content"); + entry.response(http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } return entry; } @@ -905,15 +1022,19 @@ RouteEntry & RouteRegistry::patch( const std::string & openapi_path, std::function>(http::TypedRequest, TBody)> handler) { static_assert(dto::is_dto_v, "typed patch: TB must be a DTO"); - static_assert(dto::has_dto_shape_v || std::is_same_v, + static_assert(dto::has_dto_shape_v> || + std::is_same_v, http::NoContent>, "typed patch: T must be a DTO (or NoContent)"); auto & entry = add_route("patch", openapi_path, HandlerFn{}); entry.handler_ = wrap_with_body_attachments(std::move(handler), entry.error_renderer_); entry.template request_body(""); - if constexpr (!std::is_same_v) { - entry.template response(200, ""); + if constexpr (!std::is_same_v, http::NoContent>) { + entry.template response>( + http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } else { - entry.response(204, "No content"); + entry.response(http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } return entry; } @@ -921,14 +1042,18 @@ RouteEntry & RouteRegistry::patch( template RouteEntry & RouteRegistry::del(const std::string & openapi_path, std::function(http::TypedRequest)> handler) { - static_assert(dto::has_dto_shape_v || std::is_same_v, + static_assert(dto::has_dto_shape_v> || + std::is_same_v, http::NoContent>, "typed del: T must be a DTO (or NoContent)"); auto & entry = add_route("delete", openapi_path, HandlerFn{}); entry.handler_ = wrap_body_less(std::move(handler), entry.error_renderer_); - if constexpr (!std::is_same_v) { - entry.template response(200, ""); + if constexpr (!std::is_same_v, http::NoContent>) { + entry.template response>( + http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } else { - entry.response(204, "No content"); + entry.response(http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } return entry; } @@ -937,14 +1062,18 @@ template RouteEntry & RouteRegistry::del( const std::string & openapi_path, std::function>(http::TypedRequest)> handler) { - static_assert(dto::has_dto_shape_v || std::is_same_v, + static_assert(dto::has_dto_shape_v> || + std::is_same_v, http::NoContent>, "typed del: T must be a DTO (or NoContent)"); auto & entry = add_route("delete", openapi_path, HandlerFn{}); entry.handler_ = wrap_body_less_with_attachments(std::move(handler), entry.error_renderer_); - if constexpr (!std::is_same_v) { - entry.template response(200, ""); + if constexpr (!std::is_same_v, http::NoContent>) { + entry.template response>( + http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } else { - entry.response(204, "No content"); + entry.response(http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } return entry; } @@ -956,11 +1085,11 @@ template inline void add_alternate_response(RouteEntry & entry) { constexpr int status = http::dto_alternate_status::value; if constexpr (std::is_same_v) { - entry.response(status, "No content"); + entry.response(status, default_success_description(status)); } else { static_assert(dto::has_dto_shape_v, "alternate variant member must be a DTO (regular or opaque) or NoContent"); - entry.template response(status, ""); + entry.template response(status, default_success_description(status)); } } @@ -975,6 +1104,7 @@ RouteRegistry::post_alternates(const std::string & openapi_path, entry.handler_ = wrap_post_alternates(std::move(handler), entry.error_renderer_); entry.template request_body(""); (detail::add_alternate_response(entry), ...); + entry.mark_alternates(); return entry; } @@ -988,6 +1118,7 @@ RouteEntry & RouteRegistry::post_alternates( entry.handler_ = wrap_post_alternates_with_attachments(std::move(handler), entry.error_renderer_); entry.template request_body(""); (detail::add_alternate_response(entry), ...); + entry.mark_alternates(); return entry; } @@ -998,6 +1129,7 @@ RouteRegistry::del_alternates(const std::string & openapi_path, auto & entry = add_route("delete", openapi_path, HandlerFn{}); entry.handler_ = wrap_del_alternates(std::move(handler), entry.error_renderer_); (detail::add_alternate_response(entry), ...); + entry.mark_alternates(); return entry; } @@ -1011,7 +1143,8 @@ RouteEntry & RouteRegistry::multipart_upload( std::function>(http::TypedRequest, http::MultipartBody)> handler) { - static_assert(dto::has_dto_shape_v || std::is_same_v, + static_assert(dto::has_dto_shape_v> || + std::is_same_v, http::NoContent>, "multipart_upload: T must be a DTO (or NoContent)"); auto renderer = std::make_shared(ErrorRenderer::kSovdGenericError); HandlerFn fn = [handler = std::move(handler), renderer](const httplib::Request & req, httplib::Response & res) { @@ -1036,7 +1169,10 @@ RouteEntry & RouteRegistry::multipart_upload( auto outcome = handler(typed_req, std::move(body)); if (outcome.has_value()) { const auto & att = outcome.value().second; - int status = att.status_override.value_or(std::is_same_v ? 204 : 200); + // Default status comes from the return type (Created -> 201, + // Accepted -> 202, NoContent -> 204, plain DTO -> 200), never from a + // literal, so a wrapped response cannot silently downgrade to 200. + int status = att.status_override.value_or(http::dto_alternate_status::value); write_success_body(res, outcome.value().first, status); apply_attachments(res, att); return; @@ -1047,10 +1183,13 @@ RouteEntry & RouteRegistry::multipart_upload( entry.error_renderer_ = renderer; entry.request_body("Multipart upload", nlohmann::json{{"type", "object"}, {"additionalProperties", true}}, "multipart/form-data"); - if constexpr (!std::is_same_v) { - entry.template response(200, ""); + if constexpr (!std::is_same_v, http::NoContent>) { + entry.template response>( + http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } else { - entry.response(204, "No content"); + entry.response(http::dto_alternate_status::value, + detail::default_success_description(http::dto_alternate_status::value)); } return entry; } diff --git a/src/ros2_medkit_gateway/test/test_lifecycle_handlers.cpp b/src/ros2_medkit_gateway/test/test_lifecycle_handlers.cpp index 7c53e2bea..9cbea1b35 100644 --- a/src/ros2_medkit_gateway/test/test_lifecycle_handlers.cpp +++ b/src/ros2_medkit_gateway/test/test_lifecycle_handlers.cpp @@ -558,8 +558,9 @@ TEST_F(LifecycleHandlersWithProviderTest, TransitionAcceptedReturns202WithLocati auto result = handlers_->handle_transition(req, "restart"); ASSERT_TRUE(result.has_value()); const auto & att = result->second; - ASSERT_TRUE(att.status_override.has_value()); - EXPECT_EQ(*att.status_override, 202); + // 202 is declared by the Accepted<> return type, not by a runtime override. + EXPECT_EQ(http::dto_alternate_statusfirst)>::value, 202); + EXPECT_FALSE(att.status_override.has_value()); bool found_location = false; for (const auto & [name, value] : att.headers) { if (name == "Location") { diff --git a/src/ros2_medkit_gateway/test/test_lock_handlers.cpp b/src/ros2_medkit_gateway/test/test_lock_handlers.cpp index d9ebc670b..7e582a60f 100644 --- a/src/ros2_medkit_gateway/test/test_lock_handlers.cpp +++ b/src/ros2_medkit_gateway/test/test_lock_handlers.cpp @@ -33,6 +33,7 @@ #include "ros2_medkit_gateway/core/managers/lock_manager.hpp" #include "ros2_medkit_gateway/dto/locks.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" +#include "ros2_medkit_gateway/http/alternate_status.hpp" #include "ros2_medkit_gateway/http/typed_router.hpp" using json = nlohmann::json; @@ -50,6 +51,7 @@ using ros2_medkit_gateway::handlers::LockHandlers; using ros2_medkit_gateway::http::TypedRequest; namespace dto = ros2_medkit_gateway::dto; +namespace http = ros2_medkit_gateway::http; namespace { @@ -290,12 +292,14 @@ TEST_F(LockHandlersTest, AcquireLockOnComponentReturns201) { auto result = handlers_->post_lock(typed_req, body); ASSERT_TRUE(result.has_value()); - EXPECT_EQ(result->second.status_override.value_or(0), 201); + // 201 is declared by the Created<> return type, not by a runtime override. + EXPECT_EQ(http::dto_alternate_statusfirst)>::value, 201); + EXPECT_FALSE(result->second.status_override.has_value()); // Location header set to / ASSERT_FALSE(result->second.headers.empty()); EXPECT_EQ(result->second.headers[0].first, "Location"); - const auto & lock = result->first; + const auto & lock = result->first.value; EXPECT_FALSE(lock.id.empty()); EXPECT_TRUE(lock.owned); EXPECT_FALSE(lock.lock_expiration.empty()); @@ -319,9 +323,11 @@ TEST_F(LockHandlersTest, AcquireLockOnAppReturns201) { auto result = handlers_->post_lock(typed_req, body); ASSERT_TRUE(result.has_value()); - EXPECT_EQ(result->second.status_override.value_or(0), 201); + // 201 is declared by the Created<> return type, not by a runtime override. + EXPECT_EQ(http::dto_alternate_statusfirst)>::value, 201); + EXPECT_FALSE(result->second.status_override.has_value()); - const auto & lock = result->first; + const auto & lock = result->first.value; EXPECT_FALSE(lock.id.empty()); EXPECT_TRUE(lock.owned); ASSERT_TRUE(lock.scopes.has_value()); @@ -557,7 +563,7 @@ TEST_F(LockHandlersTest, GetLockReturns200) { acquire_body.lock_expiration = 300; auto acquire_res = handlers_->post_lock(acquire_typed, acquire_body); ASSERT_TRUE(acquire_res.has_value()); - const std::string lock_id = acquire_res->first.id; + const std::string lock_id = acquire_res->first.value.id; // Get the lock httplib::Request req; @@ -597,7 +603,7 @@ TEST_F(LockHandlersTest, ExtendLockReturns204) { acquire_body.lock_expiration = 300; auto acquire_res = handlers_->post_lock(acquire_typed, acquire_body); ASSERT_TRUE(acquire_res.has_value()); - const std::string lock_id = acquire_res->first.id; + const std::string lock_id = acquire_res->first.value.id; // Extend httplib::Request req; @@ -621,7 +627,7 @@ TEST_F(LockHandlersTest, ExtendLockNotOwnerReturns403) { acquire_body.lock_expiration = 300; auto acquire_res = handlers_->post_lock(acquire_typed, acquire_body); ASSERT_TRUE(acquire_res.has_value()); - const std::string lock_id = acquire_res->first.id; + const std::string lock_id = acquire_res->first.value.id; // Try to extend as client_b - should fail with 403 httplib::Request req; @@ -647,7 +653,7 @@ TEST_F(LockHandlersTest, ExtendLockWithoutClientIdReturns400) { acquire_body.lock_expiration = 300; auto acquire_res = handlers_->post_lock(acquire_typed, acquire_body); ASSERT_TRUE(acquire_res.has_value()); - const std::string lock_id = acquire_res->first.id; + const std::string lock_id = acquire_res->first.value.id; // No X-Client-Id header httplib::Request req; @@ -676,7 +682,7 @@ TEST_F(LockHandlersTest, ReleaseLockReturns204) { acquire_body.lock_expiration = 300; auto acquire_res = handlers_->post_lock(acquire_typed, acquire_body); ASSERT_TRUE(acquire_res.has_value()); - const std::string lock_id = acquire_res->first.id; + const std::string lock_id = acquire_res->first.value.id; // Release httplib::Request req; @@ -702,7 +708,7 @@ TEST_F(LockHandlersTest, ReleaseLockNotOwnerReturns403) { acquire_body.lock_expiration = 300; auto acquire_res = handlers_->post_lock(acquire_typed, acquire_body); ASSERT_TRUE(acquire_res.has_value()); - const std::string lock_id = acquire_res->first.id; + const std::string lock_id = acquire_res->first.value.id; // Try to release as client_b - should fail with 403 httplib::Request req; @@ -755,8 +761,10 @@ TEST_F(LockHandlersTest, AcquireLockOnAppPathReturns201) { auto result = handlers_->post_lock(typed_req, body); ASSERT_TRUE(result.has_value()); - EXPECT_EQ(result->second.status_override.value_or(0), 201); - const auto & lock = result->first; + // 201 is declared by the Created<> return type, not by a runtime override. + EXPECT_EQ(http::dto_alternate_statusfirst)>::value, 201); + EXPECT_FALSE(result->second.status_override.has_value()); + const auto & lock = result->first.value; EXPECT_TRUE(lock.owned); ASSERT_TRUE(lock.scopes.has_value()); EXPECT_EQ(lock.scopes->size(), 2u); @@ -793,7 +801,7 @@ TEST_F(LockHandlersTest, GetLockOnAppPathReturns200) { acquire_body.lock_expiration = 300; auto acquire_res = handlers_->post_lock(acquire_typed, acquire_body); ASSERT_TRUE(acquire_res.has_value()); - const std::string lock_id = acquire_res->first.id; + const std::string lock_id = acquire_res->first.value.id; // Get httplib::Request req; @@ -815,7 +823,7 @@ TEST_F(LockHandlersTest, ExtendLockOnAppPathReturns204) { acquire_body.lock_expiration = 300; auto acquire_res = handlers_->post_lock(acquire_typed, acquire_body); ASSERT_TRUE(acquire_res.has_value()); - const std::string lock_id = acquire_res->first.id; + const std::string lock_id = acquire_res->first.value.id; // Extend httplib::Request req; @@ -839,7 +847,7 @@ TEST_F(LockHandlersTest, ReleaseLockOnAppPathReturns204) { acquire_body.lock_expiration = 300; auto acquire_res = handlers_->post_lock(acquire_typed, acquire_body); ASSERT_TRUE(acquire_res.has_value()); - const std::string lock_id = acquire_res->first.id; + const std::string lock_id = acquire_res->first.value.id; // Release httplib::Request req; @@ -877,8 +885,9 @@ TEST_F(LockHandlersTest, AcquireLockWithBreakReplacesExisting) { auto res2 = handlers_->post_lock(typed_req2, body2); ASSERT_TRUE(res2.has_value()); - EXPECT_EQ(res2->second.status_override.value_or(0), 201); - EXPECT_TRUE(res2->first.owned); + EXPECT_EQ(http::dto_alternate_statusfirst)>::value, 201); + EXPECT_FALSE(res2->second.status_override.has_value()); + EXPECT_TRUE(res2->first.value.owned); } int main(int argc, char ** argv) { diff --git a/src/ros2_medkit_gateway/test/test_operation_handlers.cpp b/src/ros2_medkit_gateway/test/test_operation_handlers.cpp index 91d5241a3..a41d68b07 100644 --- a/src/ros2_medkit_gateway/test/test_operation_handlers.cpp +++ b/src/ros2_medkit_gateway/test/test_operation_handlers.cpp @@ -571,10 +571,11 @@ TEST_F(OperationHandlersFixtureTest, UpdateExecutionStopReturnsAcceptedAndLocati auto goal_info = get_tracked_goal_or_fail(execution_id); if (result.has_value()) { - const auto & exec = result.value().first; + const auto & exec = result.value().first.value; const auto & att = result.value().second; - ASSERT_TRUE(att.status_override.has_value()); - EXPECT_EQ(*att.status_override, 202); + // 202 is declared by the Accepted<> return type, not by a runtime override. + EXPECT_EQ(http::dto_alternate_status::value, 202); + EXPECT_FALSE(att.status_override.has_value()); bool has_location = false; for (const auto & [k, v] : att.headers) { if (k == "Location") { diff --git a/src/ros2_medkit_gateway/test/test_script_handlers.cpp b/src/ros2_medkit_gateway/test/test_script_handlers.cpp index b86120d05..e6ce244be 100644 --- a/src/ros2_medkit_gateway/test/test_script_handlers.cpp +++ b/src/ros2_medkit_gateway/test/test_script_handlers.cpp @@ -408,7 +408,7 @@ class ScriptHandlersErrorMappingTest : public ::testing::Test { } /// Helper: call start_execution with entity "ecu" and trigger the mock error. - http::Result> + http::Result, http::ResponseAttachments>> call_start_execution_with_error(ScriptBackendError err, httplib::Request & req_storage) { mock_provider_->succeed = false; mock_provider_->error_code = err; @@ -515,8 +515,11 @@ TEST_F(ScriptHandlersErrorMappingTest, UploadReturns201WithLocation) { auto result = handlers_->upload_script(typed, body); ASSERT_TRUE(result.has_value()); - const auto & [upload_resp, att] = result.value(); - EXPECT_EQ(att.status_override.value_or(0), 201); + const auto & [created, att] = result.value(); + const auto & upload_resp = created.value; + // 201 is declared by the Created<> return type, not by a runtime override. + EXPECT_EQ(http::dto_alternate_status::value, 201); + EXPECT_FALSE(att.status_override.has_value()); // Location header is appended to ResponseAttachments::headers. bool found_location = false; @@ -565,8 +568,11 @@ TEST_F(ScriptHandlersErrorMappingTest, StartExecutionReturns202WithLocation) { auto result = handlers_->start_execution(typed); ASSERT_TRUE(result.has_value()); - const auto & [exec_dto, att] = result.value(); - EXPECT_EQ(att.status_override.value_or(0), 202); + const auto & [accepted, att] = result.value(); + const auto & exec_dto = accepted.value; + // 202 is declared by the Accepted<> return type, not by a runtime override. + EXPECT_EQ(http::dto_alternate_status::value, 202); + EXPECT_FALSE(att.status_override.has_value()); bool found_location = false; for (const auto & [name, value] : att.headers) { diff --git a/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py b/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py index 48630f4a5..906b38289 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py @@ -31,6 +31,7 @@ import launch_testing import launch_testing.actions +import requests from ros2_medkit_test_utils.constants import ALLOWED_EXIT_CODES from ros2_medkit_test_utils.gateway_test_case import GatewayTestCase @@ -43,6 +44,8 @@ _SCRIPTS_DIR = tempfile.mkdtemp(prefix='medkit-contract-scripts-') +PYTHON_SCRIPT = '#!/usr/bin/env python3\nimport json\nprint(json.dumps({"result": "ok"}))\n' + def generate_test_description(): return create_test_launch( @@ -117,6 +120,147 @@ def test_no_malformed_path_keys(self): self.assertTrue(path.startswith('/'), f'{path}: missing leading slash') self.assertNotIn('//', path, f'{path}: empty path segment') + def declared_success_status(self, path, method): + """Return the single 2xx status the document declares for an operation.""" + op = self.spec()['paths'][path][method] + codes = sorted(c for c in op.get('responses', {}) if c.startswith('2')) + self.assertEqual( + len(codes), 1, + f'{method.upper()} {path}: expected exactly one declared 2xx, got {codes}') + return int(codes[0]) + + def test_trigger_create_answers_with_the_declared_status(self): + """POST /apps/{app_id}/triggers answers with its declared 2xx.""" + declared = self.declared_success_status('/apps/{app_id}/triggers', 'post') + resp = requests.post( + f'{self.BASE_URL}/apps/temp_sensor/triggers', + json={ + 'resource': '/api/v1/apps/temp_sensor/faults', + 'trigger_condition': {'condition_type': 'OnChange'}, + 'multishot': True, + }, + timeout=10, + ) + self.assertEqual(resp.status_code, declared, resp.text) + self.addCleanup( + requests.delete, + f'{self.BASE_URL}/apps/temp_sensor/triggers/{resp.json()["id"]}', + timeout=10, + ) + + def test_lock_acquire_answers_with_the_declared_status(self): + """POST /apps/{app_id}/locks answers with its declared 2xx.""" + declared = self.declared_success_status('/apps/{app_id}/locks', 'post') + resp = requests.post( + f'{self.BASE_URL}/apps/calibration/locks', + json={'lock_expiration': 60}, + headers={'X-Client-Id': 'contract_client'}, + timeout=10, + ) + self.assertEqual(resp.status_code, declared, resp.text) + self.addCleanup( + requests.delete, + f'{self.BASE_URL}/apps/calibration/locks/{resp.json()["id"]}', + headers={'X-Client-Id': 'contract_client'}, + timeout=10, + ) + + def test_subscription_create_answers_with_the_declared_status(self): + """POST /apps/{app_id}/cyclic-subscriptions answers with its declared 2xx.""" + declared = self.declared_success_status( + '/apps/{app_id}/cyclic-subscriptions', 'post') + resp = requests.post( + f'{self.BASE_URL}/apps/temp_sensor/cyclic-subscriptions', + json={ + 'resource': '/api/v1/apps/temp_sensor/faults', + 'interval': 'normal', + 'duration': 60, + }, + timeout=10, + ) + self.assertEqual(resp.status_code, declared, resp.text) + self.addCleanup( + requests.delete, + f'{self.BASE_URL}/apps/temp_sensor/cyclic-subscriptions/' + f'{resp.json()["id"]}', + timeout=10, + ) + + def test_script_routes_answer_with_the_declared_status(self): + """Script upload (201) and execution start (202) match the document. + + The execution POST is the only converted route in this fixture whose + declared success status is 202, so it is what proves an Accepted + return type reaches the wire as 202 rather than 200. + """ + upload_declared = self.declared_success_status('/apps/{app_id}/scripts', 'post') + upload = requests.post( + f'{self.BASE_URL}/apps/temp_sensor/scripts', + files={'file': ('contract.py', PYTHON_SCRIPT, 'application/octet-stream')}, + timeout=10, + ) + self.assertEqual(upload.status_code, upload_declared, upload.text) + script_id = upload.json()['id'] + self.addCleanup( + requests.delete, + f'{self.BASE_URL}/apps/temp_sensor/scripts/{script_id}', + timeout=10, + ) + + exec_declared = self.declared_success_status( + '/apps/{app_id}/scripts/{script_id}/executions', 'post') + self.assertEqual(exec_declared, 202) + start = requests.post( + f'{self.BASE_URL}/apps/temp_sensor/scripts/{script_id}/executions', + json={'execution_type': 'now'}, + timeout=10, + ) + self.assertEqual(start.status_code, exec_declared, start.text) + + def test_every_success_response_is_described(self): + """No declared 2xx ships without prose a client can show.""" + undescribed = [] + for path, method, op in self.operations(): + for code, resp in op.get('responses', {}).items(): + if code.startswith('2') and not resp.get('description'): + undescribed.append(f'{op.get("operationId")}: {code}') + self.assertEqual(undescribed, [], f'no description: {undescribed}') + + def test_no_success_response_is_described_as_no_content(self): + """Only 204 may be called "No content". + + The description is auto-filled from the declared status, so a 201 or + 202 labelled "No content" means the fill lost track of the status - the + same drift between status and document this suite exists to catch, one + layer down in the prose. + """ + mislabelled = [] + checked = 0 + for path, method, op in self.operations(): + for code, resp in op.get('responses', {}).items(): + if not code.startswith('2') or code == '204': + continue + checked += 1 + if 'no content' in (resp.get('description') or '').lower(): + mislabelled.append( + f'{op.get("operationId")}: {code} = {resp["description"]!r}') + # The body-less 202 routes (lifecycle transitions, update prepare / + # execute / automated) are the ones that used to be labelled "No + # content"; if the fixture stops exposing them this test would pass + # while guarding nothing. + self.assertGreater(checked, 0, 'No non-204 success responses to check') + self.assertEqual(mislabelled, [], f'mislabelled: {mislabelled}') + + def test_no_operation_declares_a_status_it_cannot_return(self): + """Multiple 2xx codes only where the handler returns a variant.""" + offenders = [] + for path, method, op in self.operations(): + codes = {c for c in op.get('responses', {}) if c.startswith('2')} + if len(codes) < 2 or op.get('x-medkit-alternates'): + continue + offenders.append(f'{op.get("operationId")}: {sorted(codes)}') + self.assertEqual(offenders, [], f'phantom success: {offenders}') + def test_every_ref_resolves(self): """No $ref points at a component the document does not define.""" spec = self.spec() diff --git a/src/ros2_medkit_integration_tests/test/features/test_openapi_response_drift.test.py b/src/ros2_medkit_integration_tests/test/features/test_openapi_response_drift.test.py index 3ec04098f..267df1d19 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_openapi_response_drift.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_openapi_response_drift.test.py @@ -349,6 +349,56 @@ def test_get_responses_match_declared_schema(self): + '\n'.join(violations), ) + def test_update_routes_answer_with_the_declared_status(self): + """POST /updates and PUT /updates/{id}/prepare match the document. + + The drift loop above only exercises GET against 200, so the derived + success status of a write route has no other wire coverage. The + prepare PUT is the body-less 202 shape (``Accepted``) the + lifecycle transitions share; no lifecycle provider ships with the + gateway, so this is where that shape is proven end to end. + + @verifies REQ_INTEROP_002 + """ + spec = self._fetch_spec() + + def declared(path, method): + op = spec['paths'][path][method] + codes = sorted(c for c in op.get('responses', {}) if c.startswith('2')) + self.assertEqual( + len(codes), 1, + f'{method.upper()} {path}: expected exactly one declared 2xx, ' + f'got {codes}') + return int(codes[0]) + + pkg_id = 'declared-status-pkg' + requests.delete(f'{self.BASE_URL}/updates/{pkg_id}', timeout=5) + self.addCleanup( + requests.delete, f'{self.BASE_URL}/updates/{pkg_id}', timeout=5 + ) + + register_declared = declared('/updates', 'post') + self.assertEqual(register_declared, 201) + register = requests.post( + f'{self.BASE_URL}/updates', + json={ + 'id': pkg_id, + 'update_name': 'Declared status package', + 'automated': False, + 'origins': ['proximity'], + }, + timeout=5, + ) + self.assertEqual(register.status_code, register_declared, register.text) + + prepare_declared = declared('/updates/{update_id}/prepare', 'put') + self.assertEqual(prepare_declared, 202) + prepare = requests.put( + f'{self.BASE_URL}/updates/{pkg_id}/prepare', timeout=5 + ) + self.assertEqual(prepare.status_code, prepare_declared, prepare.text) + self.assertEqual(prepare.text, '', 'Accepted must send no body') + def test_update_status_payload_uses_nested_x_medkit(self): """Specific guard for issue #385: /updates/{id}/status payload. From 640ec9ca73a8402fff972e6c737a99d6390c933a Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Wed, 5 Aug 2026 08:43:02 +0200 Subject: [PATCH 03/17] feat(gateway): make feature gates declare the status they return 28 routes could answer 501 and none of them said so, so a client met a refusal the document never mentioned. gated_on() ties the guard and the declaration to one expression: a route that can be gated off publishes its 501, and a gate with an empty predicate now fails closed instead of segfaulting. --- .../design/dto_contract.rst | 27 +++ .../src/core/openapi/route_registry.cpp | 84 ++++++++- .../src/http/rest_server.cpp | 132 +++++++------ .../src/openapi/route_registry.hpp | 174 +++++++++++++----- .../test/test_route_registry.cpp | 116 ++++++++++++ .../test/features/test_health.test.py | 8 + .../test/features/test_triggers_data.test.py | 148 ++++++++++++++- .../test/features/test_updates.test.py | 37 ++++ 8 files changed, 603 insertions(+), 123 deletions(-) diff --git a/src/ros2_medkit_gateway/design/dto_contract.rst b/src/ros2_medkit_gateway/design/dto_contract.rst index e5ce2a96e..4211a7f68 100644 --- a/src/ros2_medkit_gateway/design/dto_contract.rst +++ b/src/ros2_medkit_gateway/design/dto_contract.rst @@ -431,6 +431,33 @@ Two further ``RouteEntry`` knobs shape the published response set: other response and suppresses the blanket 400/404/500 injection. The auth 401/403 refs stay when authentication is enabled: they come from the middleware ahead of the handler and are reachable on every route. + A ``code >= 400`` is published with the ``GenericError`` schema attached, + because that is what the handler puts on the wire; publishing it bare would + describe a bodyless response a generated client then receives JSON into. + ``only_status`` is not sticky with respect to ``errors()``: a call placed + *after* it re-declares those statuses, so state the single outcome last. It + *is* safe with respect to ``gated_on()`` in either order - a live gate is a + second reachable outcome, so ``only_status`` re-declares the gate's status + rather than dropping it. +- ``gated_on(available, unavailable)`` - the route's backing feature can be + absent. ``available`` is re-evaluated per request (a manager can appear after + registration), and when it is false the framework answers with + ``unavailable`` rendered through this route's ``ErrorRenderer``. The call + also declares ``unavailable.http_status`` via ``errors()``, which is the + point: a gate written as an inline ``if (!handlers_) return + tl::unexpected(...)`` inside the handler lambda is invisible to the document + generator, so the published operation omitted the 501 it answers with in + practice. + + The guard runs *inside* the typed wrapper, at the same place the inline + ``if`` used to sit - after the request body has been parsed. A malformed + payload sent to a gated-off route therefore still answers 400, not the gate's + status. + + Feature gates the registration cannot see - a handler that answers 501 + because its own backend is unconfigured, e.g. ``LockHandlers`` without a lock + manager - are declared with plain ``errors({501})`` until a handler-level + seam exists. Escape Hatches -------------- diff --git a/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp b/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp index 777f581b4..a9168fa9a 100644 --- a/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp +++ b/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp @@ -15,8 +15,10 @@ #include "route_registry.hpp" #include +#include #include #include +#include #include #include #include @@ -167,10 +169,43 @@ RouteEntry & RouteEntry::only_status(int code, const std::string & desc) { responses_.clear(); declared_errors_.clear(); only_status_ = true; - responses_[code] = {desc, {}}; + // An error status carries a GenericError body on the wire, so publishing it + // with no `content` would describe a bodyless response a client then receives + // JSON into. Attaching the schema here (rather than routing `code` through + // declared_errors_, which would empty responses_ and make + // validate_completeness inject a phantom 200) keeps `desc` AND keeps the + // "only error responses" branch of both completeness gates satisfied. + nlohmann::json schema; + if (code >= 400) { + schema = nlohmann::json{{"$ref", "#/components/schemas/GenericError"}}; + } + responses_[code] = {desc, schema}; + // A live gate is a second outcome this route can produce, so "exactly one + // status" is not true of a gated route. Re-declare the gate's status instead + // of letting builder order decide whether the document mentions it: without + // this, `.gated_on(...).only_status(...)` silently drops the very status the + // gate exists to return. + if (gate_ && gate_->has_value()) { + errors({(*gate_)->unavailable.http_status}); + } return *this; } +RouteEntry & RouteEntry::gated_on(std::function available, ErrorInfo unavailable) { + // Read the status before the move: declaring it is the half of this call the + // document depends on, and a moved-from ErrorInfo has none. + const int status = unavailable.http_status; + // The shared handle is captured by the typed wrapper closure, so assigning + // through it is what makes a `.gated_on(...)` applied AFTER `reg.get<...>()` + // reach the already-built handler (same mechanism as `.error_renderer(...)`). + *gate_ = RouteGate{std::move(available), std::move(unavailable)}; + // A gate is the only thing that can produce this status on this route, so the + // route declares it here rather than leaving the document silent about it. + // Routing it through errors() also means a sub-400 status is rejected and + // reported by validate_completeness() instead of being published. + return errors({status}); +} + RouteEntry & RouteEntry::error_renderer(ErrorRenderer renderer) { // The shared_ptr is captured by the typed handler wrapper closure; mutating // through it is the mechanism by which `.error_renderer(...)` called AFTER @@ -254,6 +289,23 @@ void RouteRegistry::write_typed_error(httplib::Response & res, const ErrorInfo & } } +bool RouteRegistry::gate_blocked(httplib::Response & res, const GateHandle & gate, + const std::shared_ptr & renderer) { + if (!gate || !gate->has_value()) { + return false; + } + const RouteGate & g = **gate; + // Fail closed. A gate whose predicate is empty is a half-built gate, and the + // handlers behind a gate dereference their manager unconditionally - the gate + // is the only thing keeping that safe. Answering the documented status beats + // running the handler and segfaulting. + if (g.available && g.available()) { + return false; + } + write_typed_error(res, g.unavailable, renderer); + return true; +} + // ----------------------------------------------------------------------------- // Escape-hatch routes (SSE / binary / static asset / docs) // ----------------------------------------------------------------------------- @@ -261,8 +313,9 @@ void RouteRegistry::write_typed_error(httplib::Response & res, const ErrorInfo & RouteEntry & RouteRegistry::sse(const std::string & openapi_path, std::function(http::TypedRequest)> stream_factory) { auto renderer = std::make_shared(ErrorRenderer::kSovdGenericError); - HandlerFn fn = [factory = std::move(stream_factory), renderer](const httplib::Request & req, - httplib::Response & res) { + auto gate = std::make_shared>(); + HandlerFn fn = [factory = std::move(stream_factory), renderer, gate](const httplib::Request & req, + httplib::Response & res) { // Install the forwarding scope so SSE factories that call // validate_entity_for_route can stream a proxied wire response for entities // owned by a remote peer. Without it the validator's Forwarded branch has @@ -270,6 +323,9 @@ RouteEntry & RouteRegistry::sse(const std::string & openapi_path, // provider starts streaming - peer-forwarding is a synchronous decision // made up-front, never mid-stream. http::detail::ForwardResponseScope forward_scope(&res); + if (gate_blocked(res, gate, renderer)) { + return; + } http::TypedRequest typed_req(req); auto outcome = factory(typed_req); if (!outcome.has_value()) { @@ -299,6 +355,7 @@ RouteEntry & RouteRegistry::sse(const std::string & openapi_path, }; auto & entry = add_route("get", openapi_path, std::move(fn)); entry.error_renderer_ = renderer; + entry.gate_ = gate; // SSE has no JSON schema; mark it explicitly so validate_completeness skips // the success-schema check via its SSE-name heuristic. entry.response(200, "Server-Sent Events stream"); @@ -309,10 +366,14 @@ RouteEntry & RouteRegistry::binary_download(const std::string & openapi_path, std::function(http::TypedRequest)> handler) { auto renderer = std::make_shared(ErrorRenderer::kSovdGenericError); - HandlerFn fn = [handler = std::move(handler), renderer](const httplib::Request & req, httplib::Response & res) { + auto gate = std::make_shared>(); + HandlerFn fn = [handler = std::move(handler), renderer, gate](const httplib::Request & req, httplib::Response & res) { // Forwarding scope: entity-scoped binary downloads (bulk-data, scripts) on a // remote peer must proxy through validate_entity_for_route (see sse / wrap_body_less). http::detail::ForwardResponseScope forward_scope(&res); + if (gate_blocked(res, gate, renderer)) { + return; + } http::TypedRequest typed_req(req); auto outcome = handler(typed_req); if (!outcome.has_value()) { @@ -340,6 +401,7 @@ RouteRegistry::binary_download(const std::string & openapi_path, }; auto & entry = add_route("get", openapi_path, std::move(fn)); entry.error_renderer_ = renderer; + entry.gate_ = gate; entry.response(200, "Binary download", nlohmann::json{{"type", "string"}, {"format", "binary"}}); return entry; } @@ -347,10 +409,14 @@ RouteRegistry::binary_download(const std::string & openapi_path, RouteEntry & RouteRegistry::static_asset(const std::string & openapi_path, std::function(http::TypedRequest)> handler) { auto renderer = std::make_shared(ErrorRenderer::kSovdGenericError); - HandlerFn fn = [handler = std::move(handler), renderer](const httplib::Request & req, httplib::Response & res) { + auto gate = std::make_shared>(); + HandlerFn fn = [handler = std::move(handler), renderer, gate](const httplib::Request & req, httplib::Response & res) { // Forwarding scope kept uniform across wrappers (static assets are not // entity-scoped, so this never forwards; see the comment at the top of this file). http::detail::ForwardResponseScope forward_scope(&res); + if (gate_blocked(res, gate, renderer)) { + return; + } http::TypedRequest typed_req(req); auto outcome = handler(typed_req); if (!outcome.has_value()) { @@ -367,6 +433,7 @@ RouteEntry & RouteRegistry::static_asset(const std::string & openapi_path, }; auto & entry = add_route("get", openapi_path, std::move(fn)); entry.error_renderer_ = renderer; + entry.gate_ = gate; entry.hidden(); // Static assets are not part of the documented JSON API. return entry; } @@ -374,9 +441,13 @@ RouteEntry & RouteRegistry::static_asset(const std::string & openapi_path, RouteEntry & RouteRegistry::docs_endpoint(const std::string & openapi_path, std::function(http::TypedRequest)> handler) { auto renderer = std::make_shared(ErrorRenderer::kSovdGenericError); - HandlerFn fn = [handler = std::move(handler), renderer](const httplib::Request & req, httplib::Response & res) { + auto gate = std::make_shared>(); + HandlerFn fn = [handler = std::move(handler), renderer, gate](const httplib::Request & req, httplib::Response & res) { // Forwarding scope kept uniform across wrappers (see the comment at the top of this file). http::detail::ForwardResponseScope forward_scope(&res); + if (gate_blocked(res, gate, renderer)) { + return; + } http::TypedRequest typed_req(req); auto outcome = handler(typed_req); if (!outcome.has_value()) { @@ -387,6 +458,7 @@ RouteEntry & RouteRegistry::docs_endpoint(const std::string & openapi_path, }; auto & entry = add_route("get", openapi_path, std::move(fn)); entry.error_renderer_ = renderer; + entry.gate_ = gate; entry.response(200, "OpenAPI specification document", nlohmann::json{{"type", "object"}, {"additionalProperties", true}}); entry.hidden(); // The docs spec endpoint describes itself externally. diff --git a/src/ros2_medkit_gateway/src/http/rest_server.cpp b/src/ros2_medkit_gateway/src/http/rest_server.cpp index def26838b..41693eaa0 100644 --- a/src/ros2_medkit_gateway/src/http/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/http/rest_server.cpp @@ -582,7 +582,9 @@ void RESTServer::setup_routes() { .request_body("Data value to write", SB::ref("DataWriteRequest")) .operation_id(std::string("put") + capitalize(et.singular) + "DataItem"); - // Data-categories (returns 501 - not yet implemented) + // Data-categories. Unconditionally 501: `only_status` drops both the + // fabricated 200 (the DataValue return type is a placeholder the handler + // never produces) and the blanket 400/404/500 the route cannot emit either. reg.get(entity_path + "/data-categories", [this](http::TypedRequest req) -> http::Result { return data_handlers_->data_categories(req); @@ -590,9 +592,10 @@ void RESTServer::setup_routes() { .tag("Data") .summary(std::string("List data categories for ") + et.singular) .description(std::string("Lists available data categories for this ") + et.singular + ".") + .only_status(501, "Data categories are not implemented for ROS 2") .operation_id(std::string("list") + capitalize(et.singular) + "DataCategories"); - // Data-groups (returns 501 - not yet implemented) + // Data-groups. Unconditionally 501 (see data-categories above). reg.get(entity_path + "/data-groups", [this](http::TypedRequest req) -> http::Result { return data_handlers_->data_groups(req); @@ -600,6 +603,7 @@ void RESTServer::setup_routes() { .tag("Data") .summary(std::string("List data groups for ") + et.singular) .description(std::string("Lists available data groups for this ") + et.singular + ".") + .only_status(501, "Data groups are not implemented for ROS 2") .operation_id(std::string("list") + capitalize(et.singular) + "DataGroups"); // Data collection (all topics). Returns the opaque `DataListResult` envelope @@ -963,97 +967,84 @@ void RESTServer::setup_routes() { // attachments variant to emit 201 without re-introducing httplib::Response. // The SSE event-stream uses the `reg.sse<>` escape hatch. // - // Triggers can be optional: if the manager is absent, the typed handler - // wrappers below return a 501 ErrorInfo so the wire shape matches the - // legacy "Triggers not available" SOVD GenericError exactly. + // Triggers can be optional: if the manager is absent, `.gated_on(...)` + // short-circuits the route with the "Triggers not available" SOVD + // GenericError. Expressing the gate on the registration instead of inside + // each lambda is what puts the 501 into the generated document. { - auto make_not_available_error = []() { - ErrorInfo err; - err.code = ERR_NOT_IMPLEMENTED; - err.message = "Triggers not available"; - err.http_status = 501; - return err; + auto triggers_available = [this] { + return trigger_handlers_ != nullptr; }; + ErrorInfo triggers_unavailable; + triggers_unavailable.code = ERR_NOT_IMPLEMENTED; + triggers_unavailable.message = "Triggers not available"; + triggers_unavailable.http_status = 501; // SSE events stream - registered before CRUD routes so the more specific // path takes precedence in cpp-httplib's first-match routing. reg.sse(entity_path + "/triggers/{trigger_id}/events", - [this, make_not_available_error](http::TypedRequest req) -> http::Result { - if (!trigger_handlers_) { - return tl::unexpected(make_not_available_error()); - } + [this](http::TypedRequest req) -> http::Result { return trigger_handlers_->sse_trigger_events(req); }) .tag("Triggers") .summary(std::string("SSE events stream for trigger on ") + et.singular) .description(std::string("Server-Sent Events stream for trigger notifications on this ") + et.singular + ".") + .gated_on(triggers_available, triggers_unavailable) .operation_id(std::string("stream") + capitalize(et.singular) + "TriggerEvents"); reg.post>( entity_path + "/triggers", - [this, make_not_available_error]( - http::TypedRequest req, dto::TriggerCreateRequest body) -> http::Result> { - if (!trigger_handlers_) { - return tl::unexpected(make_not_available_error()); - } + [this](http::TypedRequest req, + dto::TriggerCreateRequest body) -> http::Result> { return trigger_handlers_->post_trigger(req, std::move(body)); }) .tag("Triggers") .summary(std::string("Create trigger for ") + et.singular) .description(std::string("Creates a new event trigger for this ") + et.singular + ".") .success_description("Trigger created") + .gated_on(triggers_available, triggers_unavailable) .operation_id(std::string("create") + capitalize(et.singular) + "Trigger"); reg.get>( entity_path + "/triggers", - [this, make_not_available_error](http::TypedRequest req) -> http::Result> { - if (!trigger_handlers_) { - return tl::unexpected(make_not_available_error()); - } + [this](http::TypedRequest req) -> http::Result> { return trigger_handlers_->get_triggers(req); }) .tag("Triggers") .summary(std::string("List triggers for ") + et.singular) .description(std::string("Lists all triggers configured for this ") + et.singular + ".") + .gated_on(triggers_available, triggers_unavailable) .operation_id(std::string("list") + capitalize(et.singular) + "Triggers"); reg.get(entity_path + "/triggers/{trigger_id}", - [this, make_not_available_error](http::TypedRequest req) -> http::Result { - if (!trigger_handlers_) { - return tl::unexpected(make_not_available_error()); - } + [this](http::TypedRequest req) -> http::Result { return trigger_handlers_->get_trigger(req); }) .tag("Triggers") .summary(std::string("Get trigger for ") + et.singular) .description(std::string("Returns details of a specific trigger on this ") + et.singular + ".") + .gated_on(triggers_available, triggers_unavailable) .operation_id(std::string("get") + capitalize(et.singular) + "Trigger"); reg.put( entity_path + "/triggers/{trigger_id}", - [this, make_not_available_error](http::TypedRequest req, - dto::TriggerUpdateRequest body) -> http::Result { - if (!trigger_handlers_) { - return tl::unexpected(make_not_available_error()); - } + [this](http::TypedRequest req, dto::TriggerUpdateRequest body) -> http::Result { return trigger_handlers_->put_trigger(req, body); }) .tag("Triggers") .summary(std::string("Update trigger for ") + et.singular) .description(std::string("Updates a trigger configuration on this ") + et.singular + ".") + .gated_on(triggers_available, triggers_unavailable) .operation_id(std::string("update") + capitalize(et.singular) + "Trigger"); - reg.del( - entity_path + "/triggers/{trigger_id}", - [this, make_not_available_error](http::TypedRequest req) -> http::Result { - if (!trigger_handlers_) { - return tl::unexpected(make_not_available_error()); - } - return trigger_handlers_->del_trigger(req); - }) + reg.del(entity_path + "/triggers/{trigger_id}", + [this](http::TypedRequest req) -> http::Result { + return trigger_handlers_->del_trigger(req); + }) .tag("Triggers") .summary(std::string("Delete trigger for ") + et.singular) .description(std::string("Deletes a trigger from this ") + et.singular + ".") + .gated_on(triggers_available, triggers_unavailable) .operation_id(std::string("delete") + capitalize(et.singular) + "Trigger"); } @@ -1077,6 +1068,9 @@ void RESTServer::setup_routes() { .tag("Subscriptions") .summary(std::string("SSE events stream for cyclic subscription on ") + et.singular) .description(std::string("Server-Sent Events stream for subscription data on this ") + et.singular + ".") + // Non-HTTP transports (MQTT, WebSocket, Zenoh) cannot produce an HTTP + // stream: SubscriptionTransportProvider::make_sse_stream answers 501. + .errors({501}) .operation_id(std::string("stream") + capitalize(et.singular) + "SubscriptionEvents"); reg.post>( @@ -1152,6 +1146,7 @@ void RESTServer::setup_routes() { .description(std::string("Acquires an exclusive lock on this ") + et.singular + ".") .header_param("X-Client-Id", "Unique client identifier for lock ownership", true, client_id_schema) .success_description("Lock acquired") + .errors({501}) // Locking disabled: LockHandlers::check_locking_enabled .operation_id(std::string("acquire") + capitalize(et.singular) + "Lock"); reg.get>(entity_path + "/locks", @@ -1163,6 +1158,7 @@ void RESTServer::setup_routes() { .description(std::string("Lists all active locks on this ") + et.singular + ".") .header_param("X-Client-Id", "When provided, the 'owned' field indicates whether this client owns the lock", false, client_id_schema) + .errors({501}) // Locking disabled: LockHandlers::check_locking_enabled .operation_id(std::string("list") + capitalize(et.singular) + "Locks"); reg.get(entity_path + "/locks/{lock_id}", @@ -1174,6 +1170,7 @@ void RESTServer::setup_routes() { .description(std::string("Returns details of a specific lock on this ") + et.singular + ".") .header_param("X-Client-Id", "When provided, the 'owned' field indicates whether this client owns the lock", false, client_id_schema) + .errors({501}) // Locking disabled: LockHandlers::check_locking_enabled .operation_id(std::string("get") + capitalize(et.singular) + "Lock"); reg.put( @@ -1185,6 +1182,7 @@ void RESTServer::setup_routes() { .summary(std::string("Extend lock on ") + et.singular) .description(std::string("Extends the expiration of a lock on this ") + et.singular + ".") .header_param("X-Client-Id", "Unique client identifier for lock ownership", true, client_id_schema) + .errors({501}) // Locking disabled: LockHandlers::check_locking_enabled .operation_id(std::string("extend") + capitalize(et.singular) + "Lock"); reg.del(entity_path + "/locks/{lock_id}", @@ -1195,6 +1193,7 @@ void RESTServer::setup_routes() { .summary(std::string("Release lock on ") + et.singular) .description(std::string("Releases a lock on this ") + et.singular + ".") .header_param("X-Client-Id", "Unique client identifier for lock ownership", true, client_id_schema) + .errors({501}) // Locking disabled: LockHandlers::check_locking_enabled .operation_id(std::string("release") + capitalize(et.singular) + "Lock"); } @@ -1220,6 +1219,7 @@ void RESTServer::setup_routes() { .summary(std::string("Upload diagnostic script for ") + et.singular) .description(std::string("Uploads a diagnostic script for this ") + et.singular + ".") .success_description("Script uploaded") + .errors({501}) // No scripts backend configured .operation_id(std::string("upload") + capitalize(et.singular) + "Script"); reg.get(entity_path + "/scripts", @@ -1229,6 +1229,7 @@ void RESTServer::setup_routes() { .tag("Scripts") .summary(std::string("List scripts for ") + et.singular) .description(std::string("Lists all diagnostic scripts for this ") + et.singular + ".") + .errors({501}) // No scripts backend configured .operation_id(std::string("list") + capitalize(et.singular) + "Scripts"); reg.get(entity_path + "/scripts/{script_id}", @@ -1238,6 +1239,7 @@ void RESTServer::setup_routes() { .tag("Scripts") .summary(std::string("Get script metadata for ") + et.singular) .description(std::string("Returns metadata of a specific script for this ") + et.singular + ".") + .errors({501}) // No scripts backend configured .operation_id(std::string("get") + capitalize(et.singular) + "Script"); reg.del(entity_path + "/scripts/{script_id}", @@ -1247,6 +1249,7 @@ void RESTServer::setup_routes() { .tag("Scripts") .summary(std::string("Delete script for ") + et.singular) .description(std::string("Deletes a diagnostic script from this ") + et.singular + ".") + .errors({501}) // No scripts backend configured .operation_id(std::string("delete") + capitalize(et.singular) + "Script"); reg.post>( @@ -1260,6 +1263,7 @@ void RESTServer::setup_routes() { .description(std::string("Starts execution of a diagnostic script on this ") + et.singular + ".") .request_body("Execution parameters", SB::generic_object_schema()) .success_description("Execution started") + .errors({501}) // No scripts backend configured .operation_id(std::string("start") + capitalize(et.singular) + "ScriptExecution"); reg.get(entity_path + "/scripts/{script_id}/executions/{execution_id}", @@ -1269,6 +1273,7 @@ void RESTServer::setup_routes() { .tag("Scripts") .summary(std::string("Get execution status for ") + et.singular) .description("Returns the current status of a script execution.") + .errors({501}) // No scripts backend configured .operation_id(std::string("get") + capitalize(et.singular) + "ScriptExecution"); reg.put( @@ -1280,6 +1285,7 @@ void RESTServer::setup_routes() { .tag("Scripts") .summary(std::string("Terminate script execution for ") + et.singular) .description("Sends a control command (e.g., terminate) to a running script execution.") + .errors({501}) // No scripts backend configured .operation_id(std::string("control") + capitalize(et.singular) + "ScriptExecution"); reg.del(entity_path + "/scripts/{script_id}/executions/{execution_id}", @@ -1289,6 +1295,7 @@ void RESTServer::setup_routes() { .tag("Scripts") .summary(std::string("Remove completed execution for ") + et.singular) .description("Removes a completed script execution record.") + .errors({501}) // No scripts backend configured .operation_id(std::string("remove") + capitalize(et.singular) + "ScriptExecution"); } @@ -1560,9 +1567,9 @@ void RESTServer::setup_routes() { // === Software Updates === // // PR-403 commit 22: 8 update routes migrated to typed RouteRegistry API. - // The handler instance may be null when no backend plugin is loaded; each - // typed lambda short-circuits with a 501 ErrorInfo in that case so the - // routes remain in the OpenAPI spec. + // The handler instance may be null when no backend plugin is loaded; + // `.gated_on(...)` short-circuits every route with a 501 in that case, so the + // routes stay in the OpenAPI spec AND the spec declares the 501. // // - GET /updates -> Result // - POST /updates -> attachments (201 + Location) @@ -1579,17 +1586,18 @@ void RESTServer::setup_routes() { err.http_status = 501; return err; }(); + auto updates_available = [this] { + return update_handlers_ != nullptr; + }; reg.get("/updates", [this](http::TypedRequest req) -> http::Result { - if (!update_handlers_) { - return tl::unexpected(kUpdate501); - } return update_handlers_->get_updates(req); }) .tag("Updates") .summary("List software updates") .description("Lists all registered software updates.") + .gated_on(updates_available, kUpdate501) .operation_id("listUpdates") .query(); @@ -1597,96 +1605,82 @@ void RESTServer::setup_routes() { "/updates", [this](http::TypedRequest req, dto::UpdateRegisterRequest body) -> http::Result, http::ResponseAttachments>> { - if (!update_handlers_) { - return tl::unexpected(kUpdate501); - } return update_handlers_->post_update(req, std::move(body)); }) .tag("Updates") .summary("Register a software update") .description("Registers a new software update descriptor.") .success_description("Update registered") + .gated_on(updates_available, kUpdate501) .operation_id("registerUpdate"); reg.get("/updates/{update_id}/status", [this](http::TypedRequest req) -> http::Result { - if (!update_handlers_) { - return tl::unexpected(kUpdate501); - } return update_handlers_->get_status(req); }) .tag("Updates") .summary("Get update status") .description("Returns the current status and progress of an update.") + .gated_on(updates_available, kUpdate501) .operation_id("getUpdateStatus"); reg.put>( "/updates/{update_id}/prepare", [this](http::TypedRequest req) -> http::Result, http::ResponseAttachments>> { - if (!update_handlers_) { - return tl::unexpected(kUpdate501); - } return update_handlers_->put_prepare(req); }) .tag("Updates") .summary("Prepare update for execution") .description("Prepares an update for execution (downloads, validates).") .success_description("Update preparation started") + .gated_on(updates_available, kUpdate501) .operation_id("prepareUpdate"); reg.put>( "/updates/{update_id}/execute", [this](http::TypedRequest req) -> http::Result, http::ResponseAttachments>> { - if (!update_handlers_) { - return tl::unexpected(kUpdate501); - } return update_handlers_->put_execute(req); }) .tag("Updates") .summary("Execute update") .description("Starts executing a prepared update.") .success_description("Update execution started") + .gated_on(updates_available, kUpdate501) .operation_id("executeUpdate"); reg.put>( "/updates/{update_id}/automated", [this](http::TypedRequest req) -> http::Result, http::ResponseAttachments>> { - if (!update_handlers_) { - return tl::unexpected(kUpdate501); - } return update_handlers_->put_automated(req); }) .tag("Updates") .summary("Run automated update") .description("Runs a fully automated update (prepare + execute).") .success_description("Automated update started") + .gated_on(updates_available, kUpdate501) .operation_id("automateUpdate"); reg.get("/updates/{update_id}", [this](http::TypedRequest req) -> http::Result { - if (!update_handlers_) { - return tl::unexpected(kUpdate501); - } return update_handlers_->get_update(req); }) .tag("Updates") .summary("Get update details") .description("Returns details of a specific update.") + .gated_on(updates_available, kUpdate501) .operation_id("getUpdate"); reg.del("/updates/{update_id}", [this](http::TypedRequest req) -> http::Result { - if (!update_handlers_) { - return tl::unexpected(kUpdate501); - } return update_handlers_->del_update(req); }) .tag("Updates") .summary("Delete update") .description("Removes an update registration.") + .gated_on(updates_available, kUpdate501) .operation_id("deleteUpdate"); // === Authentication === @@ -1761,6 +1755,7 @@ void RESTServer::setup_routes() { .tag("Lifecycle") .summary(std::string("Request lifecycle transition '") + action + "'") .success_description("Lifecycle transition accepted") + .errors({501}) // No LifecycleProvider, or the provider reports the transition unsupported .operation_id(std::string("put").append(entity_cap).append("Status").append(action_cap)); } @@ -1770,6 +1765,7 @@ void RESTServer::setup_routes() { }) .tag("Lifecycle") .summary(std::string("Get ") + et_lc.second + " lifecycle status") + .errors({501}) // LifecycleProvider reports the entity unsupported .operation_id(std::string("get") + entity_cap + "Status"); } diff --git a/src/ros2_medkit_gateway/src/openapi/route_registry.hpp b/src/ros2_medkit_gateway/src/openapi/route_registry.hpp index ebceb7a68..713a5f9cc 100644 --- a/src/ros2_medkit_gateway/src/openapi/route_registry.hpp +++ b/src/ros2_medkit_gateway/src/openapi/route_registry.hpp @@ -54,6 +54,28 @@ enum class ErrorRenderer { kOAuth2Error, ///< RFC 6749 §5.2 `{"error","error_description"}` }; +/// Availability guard for a route whose backing feature can be absent. +/// +/// The framework evaluates `available` once per request from *inside* the +/// typed wrapper - after the request body has been parsed - so a malformed +/// body sent to a gated-off route still answers 400 rather than the gate's +/// status. `unavailable` is rendered through the route's `ErrorRenderer`, so +/// the wire shape is identical to the same `ErrorInfo` returned by the handler. +struct RouteGate { + /// Re-evaluated per request: the manager backing a feature can appear after + /// the route is registered, so a value snapshotted at registration would be + /// permanently wrong. + std::function available; + /// Returned when `available()` is false. + ErrorInfo unavailable; +}; + +/// Shared handle to a route's gate. An empty optional means "no gate". Held by +/// shared_ptr for the same reason as `ErrorRenderer`: the typed wrapper closure +/// captures the handle when the route is registered and must observe a +/// `.gated_on(...)` applied to the returned `RouteEntry` afterwards. +using GateHandle = std::shared_ptr>; + /// Fluent builder for a single route entry. class RouteEntry { public: @@ -124,6 +146,20 @@ class RouteEntry { /// suppresses the blanket 400/404/500 injection. RouteEntry & only_status(int code, const std::string & desc); + /// Guard this route with an availability predicate **and** declare the status + /// the guard returns, in one call. A feature gate written as an inline + /// `if (!handlers_) return tl::unexpected(...)` inside the handler lambda is + /// invisible to the document generator; expressed here it is not. + /// + /// `available` is re-evaluated per request (see `RouteGate`). `unavailable` + /// is rendered through this route's `ErrorRenderer` from inside the typed + /// wrapper, after body parsing, so a malformed body still answers 400. + /// + /// The status is declared via `errors()`, which means a sub-400 + /// `unavailable.http_status` is rejected and surfaced by + /// `validate_completeness()` rather than silently published. + RouteEntry & gated_on(std::function available, ErrorInfo unavailable); + /// Hide this route from the OpenAPI spec output. /// The route is still registered with cpp-httplib and serves HTTP requests, /// but it won't appear in the generated spec or client code. @@ -160,6 +196,11 @@ class RouteEntry { /// the renderer choice and observe later `.error_renderer(...)` updates. std::shared_ptr error_renderer_{std::make_shared(ErrorRenderer::kSovdGenericError)}; + /// Heap-allocated for the same reason as `error_renderer_`: the typed wrapper + /// closure captures this handle at registration time, and `.gated_on(...)` is + /// called on the `RouteEntry` the registration returned - i.e. afterwards. + GateHandle gate_{std::make_shared>()}; + struct ResponseInfo { std::string desc; nlohmann::json schema; @@ -462,35 +503,43 @@ class RouteRegistry { static void write_typed_error(httplib::Response & res, const ErrorInfo & err, const std::shared_ptr & renderer_ptr); + /// Evaluate a route's gate. When the route is gated off this renders the + /// gate's `ErrorInfo` through `renderer` and returns true, meaning the + /// handler must not run. Every typed wrapper calls it at the point the + /// hand-written `if (!handlers_)` guard used to sit: after the request body + /// has been parsed, so a malformed body still answers 400. + static bool gate_blocked(httplib::Response & res, const GateHandle & gate, + const std::shared_ptr & renderer); + /// Build the body-less typed HandlerFn (GET/DELETE/SSE-factory-style). template static HandlerFn wrap_body_less(std::function(http::TypedRequest)> handler, - std::shared_ptr renderer); + std::shared_ptr renderer, GateHandle gate); /// Build the body-less typed HandlerFn whose return type is /// `Result>`. template static HandlerFn wrap_body_less_with_attachments( std::function>(http::TypedRequest)> handler, - std::shared_ptr renderer); + std::shared_ptr renderer, GateHandle gate); /// Build the body-bearing typed HandlerFn. template static HandlerFn wrap_with_body(std::function(http::TypedRequest, TBody)> handler, - std::shared_ptr renderer); + std::shared_ptr renderer, GateHandle gate); /// Build the body-bearing typed HandlerFn whose return type carries /// ResponseAttachments. template static HandlerFn wrap_with_body_attachments( std::function>(http::TypedRequest, TBody)> handler, - std::shared_ptr renderer); + std::shared_ptr renderer, GateHandle gate); /// Build the alternates-returning HandlerFn (POST flavour). template static HandlerFn wrap_post_alternates(std::function>(http::TypedRequest, TBody)> handler, - std::shared_ptr renderer); + std::shared_ptr renderer, GateHandle gate); /// Build the alternates+attachments HandlerFn (POST flavour). The active /// alternative drives the default status via `dto_alternate_status`; the @@ -501,12 +550,12 @@ class RouteRegistry { std::function, http::ResponseAttachments>>(http::TypedRequest, TBody)> handler, - std::shared_ptr renderer); + std::shared_ptr renderer, GateHandle gate); /// Build the alternates-returning HandlerFn (DELETE flavour). template static HandlerFn wrap_del_alternates(std::function>(http::TypedRequest)> handler, - std::shared_ptr renderer); + std::shared_ptr renderer, GateHandle gate); }; // ============================================================================= @@ -602,14 +651,17 @@ void RouteRegistry::write_success_body(httplib::Response & res, const TResponse template HandlerFn RouteRegistry::wrap_body_less(std::function(http::TypedRequest)> handler, - std::shared_ptr renderer) { - return [handler = std::move(handler), renderer = std::move(renderer)](const httplib::Request & req, - httplib::Response & res) { + std::shared_ptr renderer, GateHandle gate) { + return [handler = std::move(handler), renderer = std::move(renderer), + gate = std::move(gate)](const httplib::Request & req, httplib::Response & res) { // The forwarding scope makes the typed `validate_entity_for_route` // overload able to stream the proxied response body to `res` when an // entity is owned by a remote peer. Handlers never see the response, so // the framework installs the channel around the handler invocation. http::detail::ForwardResponseScope forward_scope(&res); + if (gate_blocked(res, gate, renderer)) { + return; + } http::TypedRequest typed_req(req); auto outcome = handler(typed_req); if (outcome.has_value()) { @@ -623,10 +675,13 @@ HandlerFn RouteRegistry::wrap_body_less(std::function(ht template HandlerFn RouteRegistry::wrap_body_less_with_attachments( std::function>(http::TypedRequest)> handler, - std::shared_ptr renderer) { - return [handler = std::move(handler), renderer = std::move(renderer)](const httplib::Request & req, - httplib::Response & res) { + std::shared_ptr renderer, GateHandle gate) { + return [handler = std::move(handler), renderer = std::move(renderer), + gate = std::move(gate)](const httplib::Request & req, httplib::Response & res) { http::detail::ForwardResponseScope forward_scope(&res); + if (gate_blocked(res, gate, renderer)) { + return; + } http::TypedRequest typed_req(req); auto outcome = handler(typed_req); if (outcome.has_value()) { @@ -645,9 +700,9 @@ HandlerFn RouteRegistry::wrap_body_less_with_attachments( template HandlerFn RouteRegistry::wrap_with_body(std::function(http::TypedRequest, TBody)> handler, - std::shared_ptr renderer) { - return [handler = std::move(handler), renderer = std::move(renderer)](const httplib::Request & req, - httplib::Response & res) { + std::shared_ptr renderer, GateHandle gate) { + return [handler = std::move(handler), renderer = std::move(renderer), + gate = std::move(gate)](const httplib::Request & req, httplib::Response & res) { // Forwarding scope: lets the typed validate_entity_for_route stream a // proxied response when the entity is owned by a remote peer (see // wrap_body_less). Without it, remote-entity writes return Forwarded with @@ -658,6 +713,12 @@ HandlerFn RouteRegistry::wrap_with_body(std::function(ht write_typed_error(res, body.error(), renderer); return; } + // Deliberately after the body parse: a malformed payload sent to a route + // whose feature is off is still a malformed payload, and answering 501 + // there would hide the client's own bug. + if (gate_blocked(res, gate, renderer)) { + return; + } http::TypedRequest typed_req(req); auto outcome = handler(typed_req, std::move(body.value())); if (outcome.has_value()) { @@ -671,9 +732,9 @@ HandlerFn RouteRegistry::wrap_with_body(std::function(ht template HandlerFn RouteRegistry::wrap_with_body_attachments( std::function>(http::TypedRequest, TBody)> handler, - std::shared_ptr renderer) { - return [handler = std::move(handler), renderer = std::move(renderer)](const httplib::Request & req, - httplib::Response & res) { + std::shared_ptr renderer, GateHandle gate) { + return [handler = std::move(handler), renderer = std::move(renderer), + gate = std::move(gate)](const httplib::Request & req, httplib::Response & res) { // Forwarding scope for remote-peer entities (see wrap_body_less / wrap_with_body). http::detail::ForwardResponseScope forward_scope(&res); auto body = detail::parse_request_body(req); @@ -681,6 +742,10 @@ HandlerFn RouteRegistry::wrap_with_body_attachments( write_typed_error(res, body.error(), renderer); return; } + // Gate after the body parse (see wrap_with_body). + if (gate_blocked(res, gate, renderer)) { + return; + } http::TypedRequest typed_req(req); auto outcome = handler(typed_req, std::move(body.value())); if (outcome.has_value()) { @@ -700,9 +765,9 @@ HandlerFn RouteRegistry::wrap_with_body_attachments( template HandlerFn RouteRegistry::wrap_post_alternates( std::function>(http::TypedRequest, TBody)> handler, - std::shared_ptr renderer) { - return [handler = std::move(handler), renderer = std::move(renderer)](const httplib::Request & req, - httplib::Response & res) { + std::shared_ptr renderer, GateHandle gate) { + return [handler = std::move(handler), renderer = std::move(renderer), + gate = std::move(gate)](const httplib::Request & req, httplib::Response & res) { // Forwarding scope for remote-peer entities (see wrap_body_less / wrap_with_body). http::detail::ForwardResponseScope forward_scope(&res); auto body = detail::parse_request_body(req); @@ -710,6 +775,10 @@ HandlerFn RouteRegistry::wrap_post_alternates( write_typed_error(res, body.error(), renderer); return; } + // Gate after the body parse (see wrap_with_body). + if (gate_blocked(res, gate, renderer)) { + return; + } http::TypedRequest typed_req(req); auto outcome = handler(typed_req, std::move(body.value())); if (outcome.has_value()) { @@ -730,9 +799,9 @@ template HandlerFn RouteRegistry::wrap_post_alternates_with_attachments( std::function, http::ResponseAttachments>>(http::TypedRequest, TBody)> handler, - std::shared_ptr renderer) { - return [handler = std::move(handler), renderer = std::move(renderer)](const httplib::Request & req, - httplib::Response & res) { + std::shared_ptr renderer, GateHandle gate) { + return [handler = std::move(handler), renderer = std::move(renderer), + gate = std::move(gate)](const httplib::Request & req, httplib::Response & res) { // Forwarding scope for remote-peer entities (see wrap_body_less / wrap_with_body). http::detail::ForwardResponseScope forward_scope(&res); auto body = detail::parse_request_body(req); @@ -740,6 +809,10 @@ HandlerFn RouteRegistry::wrap_post_alternates_with_attachments( write_typed_error(res, body.error(), renderer); return; } + // Gate after the body parse (see wrap_with_body). + if (gate_blocked(res, gate, renderer)) { + return; + } http::TypedRequest typed_req(req); auto outcome = handler(typed_req, std::move(body.value())); if (outcome.has_value()) { @@ -762,11 +835,14 @@ HandlerFn RouteRegistry::wrap_post_alternates_with_attachments( template HandlerFn RouteRegistry::wrap_del_alternates(std::function>(http::TypedRequest)> handler, - std::shared_ptr renderer) { - return [handler = std::move(handler), renderer = std::move(renderer)](const httplib::Request & req, - httplib::Response & res) { + std::shared_ptr renderer, GateHandle gate) { + return [handler = std::move(handler), renderer = std::move(renderer), + gate = std::move(gate)](const httplib::Request & req, httplib::Response & res) { // Forwarding scope for remote-peer entities (see wrap_body_less / wrap_with_body). http::detail::ForwardResponseScope forward_scope(&res); + if (gate_blocked(res, gate, renderer)) { + return; + } http::TypedRequest typed_req(req); auto outcome = handler(typed_req); if (outcome.has_value()) { @@ -794,7 +870,7 @@ RouteEntry & RouteRegistry::get(const std::string & openapi_path, std::is_same_v, http::NoContent>, "typed get: T must be a DTO (or NoContent)"); auto & entry = add_route("get", openapi_path, /*placeholder*/ HandlerFn{}); - entry.handler_ = wrap_body_less(std::move(handler), entry.error_renderer_); + entry.handler_ = wrap_body_less(std::move(handler), entry.error_renderer_, entry.gate_); if constexpr (!std::is_same_v, http::NoContent>) { entry.template response>( http::dto_alternate_status::value, @@ -814,7 +890,7 @@ RouteEntry & RouteRegistry::get( std::is_same_v, http::NoContent>, "typed get: T must be a DTO (or NoContent)"); auto & entry = add_route("get", openapi_path, HandlerFn{}); - entry.handler_ = wrap_body_less_with_attachments(std::move(handler), entry.error_renderer_); + entry.handler_ = wrap_body_less_with_attachments(std::move(handler), entry.error_renderer_, entry.gate_); if constexpr (!std::is_same_v, http::NoContent>) { entry.template response>( http::dto_alternate_status::value, @@ -834,7 +910,7 @@ RouteEntry & RouteRegistry::post(const std::string & openapi_path, std::is_same_v, http::NoContent>, "typed post: T must be a DTO (or NoContent)"); auto & entry = add_route("post", openapi_path, HandlerFn{}); - entry.handler_ = wrap_with_body(std::move(handler), entry.error_renderer_); + entry.handler_ = wrap_with_body(std::move(handler), entry.error_renderer_, entry.gate_); entry.template request_body(""); if constexpr (!std::is_same_v, http::NoContent>) { entry.template response>( @@ -856,7 +932,7 @@ RouteEntry & RouteRegistry::post( std::is_same_v, http::NoContent>, "typed post: T must be a DTO (or NoContent)"); auto & entry = add_route("post", openapi_path, HandlerFn{}); - entry.handler_ = wrap_with_body_attachments(std::move(handler), entry.error_renderer_); + entry.handler_ = wrap_with_body_attachments(std::move(handler), entry.error_renderer_, entry.gate_); entry.template request_body(""); if constexpr (!std::is_same_v, http::NoContent>) { entry.template response>( @@ -876,7 +952,7 @@ RouteEntry & RouteRegistry::post(const std::string & openapi_path, std::is_same_v, http::NoContent>, "typed post: T must be a DTO (or NoContent)"); auto & entry = add_route("post", openapi_path, HandlerFn{}); - entry.handler_ = wrap_body_less(std::move(handler), entry.error_renderer_); + entry.handler_ = wrap_body_less(std::move(handler), entry.error_renderer_, entry.gate_); // No automatic request_body schema: body-less typed POST is reserved for // routes that parse the body manually (e.g. form-urlencoded auth endpoints). // Callers attach an explicit `.request_body(...)` to populate the OpenAPI @@ -900,7 +976,7 @@ RouteEntry & RouteRegistry::post( std::is_same_v, http::NoContent>, "typed post: T must be a DTO (or NoContent)"); auto & entry = add_route("post", openapi_path, HandlerFn{}); - entry.handler_ = wrap_body_less_with_attachments(std::move(handler), entry.error_renderer_); + entry.handler_ = wrap_body_less_with_attachments(std::move(handler), entry.error_renderer_, entry.gate_); if constexpr (!std::is_same_v, http::NoContent>) { entry.template response>( http::dto_alternate_status::value, @@ -920,7 +996,7 @@ RouteEntry & RouteRegistry::put(const std::string & openapi_path, std::is_same_v, http::NoContent>, "typed put: T must be a DTO (or NoContent)"); auto & entry = add_route("put", openapi_path, HandlerFn{}); - entry.handler_ = wrap_with_body(std::move(handler), entry.error_renderer_); + entry.handler_ = wrap_with_body(std::move(handler), entry.error_renderer_, entry.gate_); entry.template request_body(""); if constexpr (!std::is_same_v, http::NoContent>) { entry.template response>( @@ -942,7 +1018,7 @@ RouteEntry & RouteRegistry::put( std::is_same_v, http::NoContent>, "typed put: T must be a DTO (or NoContent)"); auto & entry = add_route("put", openapi_path, HandlerFn{}); - entry.handler_ = wrap_with_body_attachments(std::move(handler), entry.error_renderer_); + entry.handler_ = wrap_with_body_attachments(std::move(handler), entry.error_renderer_, entry.gate_); entry.template request_body(""); if constexpr (!std::is_same_v, http::NoContent>) { entry.template response>( @@ -962,7 +1038,7 @@ RouteEntry & RouteRegistry::put(const std::string & openapi_path, std::is_same_v, http::NoContent>, "typed put: T must be a DTO (or NoContent)"); auto & entry = add_route("put", openapi_path, HandlerFn{}); - entry.handler_ = wrap_body_less(std::move(handler), entry.error_renderer_); + entry.handler_ = wrap_body_less(std::move(handler), entry.error_renderer_, entry.gate_); // No automatic request_body schema: body-less typed PUT is reserved for // routes that take no payload at all (e.g. /updates/{id}/prepare). if constexpr (!std::is_same_v, http::NoContent>) { @@ -984,7 +1060,7 @@ RouteEntry & RouteRegistry::put( std::is_same_v, http::NoContent>, "typed put: T must be a DTO (or NoContent)"); auto & entry = add_route("put", openapi_path, HandlerFn{}); - entry.handler_ = wrap_body_less_with_attachments(std::move(handler), entry.error_renderer_); + entry.handler_ = wrap_body_less_with_attachments(std::move(handler), entry.error_renderer_, entry.gate_); if constexpr (!std::is_same_v, http::NoContent>) { entry.template response>( http::dto_alternate_status::value, @@ -1004,7 +1080,7 @@ RouteEntry & RouteRegistry::patch(const std::string & openapi_path, std::is_same_v, http::NoContent>, "typed patch: T must be a DTO (or NoContent)"); auto & entry = add_route("patch", openapi_path, HandlerFn{}); - entry.handler_ = wrap_with_body(std::move(handler), entry.error_renderer_); + entry.handler_ = wrap_with_body(std::move(handler), entry.error_renderer_, entry.gate_); entry.template request_body(""); if constexpr (!std::is_same_v, http::NoContent>) { entry.template response>( @@ -1026,7 +1102,7 @@ RouteEntry & RouteRegistry::patch( std::is_same_v, http::NoContent>, "typed patch: T must be a DTO (or NoContent)"); auto & entry = add_route("patch", openapi_path, HandlerFn{}); - entry.handler_ = wrap_with_body_attachments(std::move(handler), entry.error_renderer_); + entry.handler_ = wrap_with_body_attachments(std::move(handler), entry.error_renderer_, entry.gate_); entry.template request_body(""); if constexpr (!std::is_same_v, http::NoContent>) { entry.template response>( @@ -1046,7 +1122,7 @@ RouteEntry & RouteRegistry::del(const std::string & openapi_path, std::is_same_v, http::NoContent>, "typed del: T must be a DTO (or NoContent)"); auto & entry = add_route("delete", openapi_path, HandlerFn{}); - entry.handler_ = wrap_body_less(std::move(handler), entry.error_renderer_); + entry.handler_ = wrap_body_less(std::move(handler), entry.error_renderer_, entry.gate_); if constexpr (!std::is_same_v, http::NoContent>) { entry.template response>( http::dto_alternate_status::value, @@ -1066,7 +1142,7 @@ RouteEntry & RouteRegistry::del( std::is_same_v, http::NoContent>, "typed del: T must be a DTO (or NoContent)"); auto & entry = add_route("delete", openapi_path, HandlerFn{}); - entry.handler_ = wrap_body_less_with_attachments(std::move(handler), entry.error_renderer_); + entry.handler_ = wrap_body_less_with_attachments(std::move(handler), entry.error_renderer_, entry.gate_); if constexpr (!std::is_same_v, http::NoContent>) { entry.template response>( http::dto_alternate_status::value, @@ -1101,7 +1177,7 @@ RouteRegistry::post_alternates(const std::string & openapi_path, std::function>(http::TypedRequest, TBody)> handler) { static_assert(dto::is_dto_v, "post_alternates: TBody must be a DTO"); auto & entry = add_route("post", openapi_path, HandlerFn{}); - entry.handler_ = wrap_post_alternates(std::move(handler), entry.error_renderer_); + entry.handler_ = wrap_post_alternates(std::move(handler), entry.error_renderer_, entry.gate_); entry.template request_body(""); (detail::add_alternate_response(entry), ...); entry.mark_alternates(); @@ -1115,7 +1191,8 @@ RouteEntry & RouteRegistry::post_alternates( handler) { static_assert(dto::is_dto_v, "post_alternates: TBody must be a DTO"); auto & entry = add_route("post", openapi_path, HandlerFn{}); - entry.handler_ = wrap_post_alternates_with_attachments(std::move(handler), entry.error_renderer_); + entry.handler_ = + wrap_post_alternates_with_attachments(std::move(handler), entry.error_renderer_, entry.gate_); entry.template request_body(""); (detail::add_alternate_response(entry), ...); entry.mark_alternates(); @@ -1127,7 +1204,7 @@ RouteEntry & RouteRegistry::del_alternates(const std::string & openapi_path, std::function>(http::TypedRequest)> handler) { auto & entry = add_route("delete", openapi_path, HandlerFn{}); - entry.handler_ = wrap_del_alternates(std::move(handler), entry.error_renderer_); + entry.handler_ = wrap_del_alternates(std::move(handler), entry.error_renderer_, entry.gate_); (detail::add_alternate_response(entry), ...); entry.mark_alternates(); return entry; @@ -1147,9 +1224,13 @@ RouteEntry & RouteRegistry::multipart_upload( std::is_same_v, http::NoContent>, "multipart_upload: T must be a DTO (or NoContent)"); auto renderer = std::make_shared(ErrorRenderer::kSovdGenericError); - HandlerFn fn = [handler = std::move(handler), renderer](const httplib::Request & req, httplib::Response & res) { + auto gate = std::make_shared>(); + HandlerFn fn = [handler = std::move(handler), renderer, gate](const httplib::Request & req, httplib::Response & res) { // Forwarding scope for remote-peer entities (see wrap_body_less / wrap_with_body). http::detail::ForwardResponseScope forward_scope(&res); + if (gate_blocked(res, gate, renderer)) { + return; + } http::MultipartBody body; // body.parts default-constructs empty; the loop below populates it from req.files. // cpp-httplib exposes parsed multipart entries via `req.files`; surface @@ -1181,6 +1262,7 @@ RouteEntry & RouteRegistry::multipart_upload( }; auto & entry = add_route("post", openapi_path, std::move(fn)); entry.error_renderer_ = renderer; + entry.gate_ = gate; entry.request_body("Multipart upload", nlohmann::json{{"type", "object"}, {"additionalProperties", true}}, "multipart/form-data"); if constexpr (!std::is_same_v, http::NoContent>) { diff --git a/src/ros2_medkit_gateway/test/test_route_registry.cpp b/src/ros2_medkit_gateway/test/test_route_registry.cpp index bf3700cb0..703638076 100644 --- a/src/ros2_medkit_gateway/test/test_route_registry.cpp +++ b/src/ros2_medkit_gateway/test/test_route_registry.cpp @@ -54,6 +54,7 @@ inline constexpr std::string_view dto_name = "RouteReg } // namespace ros2_medkit_gateway using namespace ros2_medkit_gateway::openapi; +using ros2_medkit_gateway::ErrorInfo; using ros2_medkit_gateway::dto::FaultEntityListQuery; using ros2_medkit_gateway::dto::FaultListQuery; using ros2_medkit_gateway::dto::RouteRegistryTestSeedDto; @@ -763,3 +764,118 @@ TEST_F(RouteRegistryTest, ErrorResponsesUseGenericErrorRef) { // No inline description when using $ref EXPECT_FALSE(resp_400.contains("description")); } + +// ----------------------------------------------------------------------------- +// only_status / gated_on interaction +// ----------------------------------------------------------------------------- + +TEST_F(RouteRegistryTest, OnlyStatusPublishesErrorBodySchema) { + // A 501 stub answers with a GenericError body. Publishing the status without + // `content` would describe a bodyless response, which a generated client + // models as void and then receives JSON into. + seed_get(registry_, "/stub").tag("Test").summary("Stub").only_status(501, "Not implemented for ROS 2"); + + auto paths = registry_.to_openapi_paths(); + auto & responses = paths["/stub"]["get"]["responses"]; + + ASSERT_TRUE(responses.contains("501")); + EXPECT_EQ(responses["501"]["description"].get(), "Not implemented for ROS 2"); + ASSERT_TRUE(responses["501"].contains("content")); + EXPECT_EQ(responses["501"]["content"]["application/json"]["schema"]["$ref"].get(), + "#/components/schemas/GenericError"); + + // The single-outcome claim still drops the blanket set and the derived 200. + EXPECT_FALSE(responses.contains("200")); + EXPECT_FALSE(responses.contains("400")); + EXPECT_FALSE(responses.contains("404")); + EXPECT_FALSE(responses.contains("500")); +} + +TEST_F(RouteRegistryTest, OnlyStatusKeepsAnErrorStubComplete) { + // The stub declares no 2xx at all; validate_completeness must not ask it for + // a success schema it can never have. + seed_get(registry_, "/stub").tag("Test").summary("Stub").only_status(501, "Not implemented"); + + for (const auto & issue : registry_.validate_completeness()) { + EXPECT_NE(issue.severity, ValidationIssue::Severity::kError) << issue.route << ": " << issue.message; + } +} + +TEST_F(RouteRegistryTest, GatedRouteDeclaresTheGateStatus) { + ErrorInfo unavailable; + unavailable.code = "not-implemented"; + unavailable.message = "Feature not available"; + unavailable.http_status = 501; + + seed_get(registry_, "/gated") + .tag("Test") + .summary("Gated") + .gated_on( + [] { + return false; + }, + unavailable); + + auto paths = registry_.to_openapi_paths(); + auto & resp_501 = paths["/gated"]["get"]["responses"]["501"]; + ASSERT_TRUE(resp_501.contains("$ref")); + EXPECT_EQ(resp_501["$ref"].get(), "#/components/responses/GenericError"); +} + +TEST_F(RouteRegistryTest, GatedStatusSurvivesEitherBuilderOrder) { + // A live gate is a second reachable outcome, so only_status() must not drop + // it - regardless of which call comes last. Without this the very status the + // gate exists to return disappears from the document on one ordering. + ErrorInfo unavailable; + unavailable.code = "not-implemented"; + unavailable.message = "Feature not available"; + unavailable.http_status = 501; + auto never_available = [] { + return false; + }; + + seed_get(registry_, "/gate-then-only") + .tag("Test") + .summary("Gate then only") + .gated_on(never_available, unavailable) + .only_status(405, "Method not allowed"); + + seed_get(registry_, "/only-then-gate") + .tag("Test") + .summary("Only then gate") + .only_status(405, "Method not allowed") + .gated_on(never_available, unavailable); + + auto paths = registry_.to_openapi_paths(); + for (const char * path : {"/gate-then-only", "/only-then-gate"}) { + auto & responses = paths[path]["get"]["responses"]; + EXPECT_TRUE(responses.contains("501")) << path << " dropped the gate status"; + EXPECT_TRUE(responses.contains("405")) << path << " dropped the only_status code"; + } +} + +TEST_F(RouteRegistryTest, GateWithSubErrorStatusIsReportedNotPublished) { + // gated_on() declares through errors(), so a sub-400 status is rejected and + // surfaced instead of being published as a phantom success. + ErrorInfo misconfigured; + misconfigured.code = "nonsense"; + misconfigured.http_status = 302; + + seed_get(registry_, "/bad-gate") + .tag("Test") + .summary("Bad gate") + .gated_on( + [] { + return true; + }, + misconfigured); + + bool reported = false; + for (const auto & issue : registry_.validate_completeness()) { + if (issue.message.find("302") != std::string::npos) { + reported = true; + } + } + EXPECT_TRUE(reported) << "sub-400 gate status was silently dropped"; + EXPECT_FALSE(registry_.to_openapi_paths()["/bad-gate"]["get"]["responses"].contains("302")); +} 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 913e141cb..bd459fbfc 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 @@ -200,6 +200,14 @@ def test_docs_spec_completeness(self): summary = op.get('summary', '') if 'SSE' in summary or 'stream' in summary.lower(): has_schema = True + # An operation that declares no 2xx at all cannot return a + # success body to describe - the data-categories / data-groups + # stubs declare only their 501. Deliberately narrow: an + # operation that DOES declare a 2xx still owes a schema, which + # is the coverage this rule exists for. + declared = op.get('responses', {}) + if declared and not any(code.startswith('2') for code in declared): + has_schema = True if not has_schema: issues.append(f'{op_id}: no response schema') self.assertEqual(issues, [], f'Operations missing response schema: {issues}') diff --git a/src/ros2_medkit_integration_tests/test/features/test_triggers_data.test.py b/src/ros2_medkit_integration_tests/test/features/test_triggers_data.test.py index f10d2e67a..2d581ff96 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_triggers_data.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_triggers_data.test.py @@ -19,6 +19,11 @@ topic data changes. Uses the temp_sensor demo node which publishes Float64 temperature data. +A second gateway runs with ``triggers.enabled: false`` so the feature gate on +the six trigger routes is exercised: with the trigger manager absent the +handlers are never constructed, and the gate is the only thing standing +between a request and a null dereference. + """ import json @@ -30,16 +35,36 @@ import launch_testing.actions import requests -from ros2_medkit_test_utils.constants import ALLOWED_EXIT_CODES, API_BASE_PATH +from ros2_medkit_test_utils.constants import ( + ALLOWED_EXIT_CODES, + API_BASE_PATH, + get_test_port, +) from ros2_medkit_test_utils.gateway_test_case import GatewayTestCase -from ros2_medkit_test_utils.launch_helpers import create_test_launch +from ros2_medkit_test_utils.launch_helpers import create_gateway_node, create_test_launch + +HTTP_METHODS = {'get', 'post', 'put', 'delete', 'patch', 'head', 'options'} + +PORT_TRIGGERS_OFF = get_test_port(1) def generate_test_description(): - return create_test_launch( + launch_description, context = create_test_launch( demo_nodes=['temp_sensor', 'rpm_sensor', 'lidar_sensor'], fault_manager=False, ) + gateway_triggers_off = create_gateway_node( + name='gateway_triggers_off', + port=PORT_TRIGGERS_OFF, + extra_params={ + 'server.host': '127.0.0.1', + 'triggers.enabled': False, + }, + ) + # Prepend so it comes up alongside the primary gateway, before ReadyToTest. + launch_description.entities.insert(0, gateway_triggers_off) + context['gateway_triggers_off'] = gateway_triggers_off + return launch_description, context class TestTriggersData(GatewayTestCase): @@ -604,6 +629,123 @@ def test_30_trigger_on_component_data(self): self.assertIn(trig_id, ids) +class TestTriggersDisabled(GatewayTestCase): + """Trigger routes on a gateway launched with ``triggers.enabled: false``. + + With the trigger manager absent the handlers are never constructed, so the + registration gate is the only thing between a request and a null + dereference. Every assertion here therefore does double duty: it checks the + 501 wire shape and it proves the gate actually fires. + """ + + BASE_URL = f'http://127.0.0.1:{PORT_TRIGGERS_OFF}{API_BASE_PATH}' + MIN_EXPECTED_APPS = 0 + + APP = 'temp_sensor' + + def assert_not_available(self, resp): + """Every gated trigger route answers the same SOVD GenericError.""" + self.assertEqual(resp.status_code, 501, resp.text) + body = resp.json() + self.assertEqual(body['error_code'], 'not-implemented') + self.assertEqual(body['message'], 'Triggers not available') + + def test_01_list_triggers_returns_501(self): + """GET /apps/{id}/triggers is gated off.""" + self.assert_not_available( + requests.get(f'{self.BASE_URL}/apps/{self.APP}/triggers', timeout=5) + ) + + def test_02_create_trigger_returns_501(self): + """POST /apps/{id}/triggers is gated off.""" + self.assert_not_available( + requests.post( + f'{self.BASE_URL}/apps/{self.APP}/triggers', + json={ + 'resource': f'{API_BASE_PATH}/apps/{self.APP}/faults', + 'trigger_condition': {'condition_type': 'OnChange'}, + 'multishot': True, + }, + timeout=5, + ) + ) + + def test_03_get_trigger_returns_501(self): + """GET /apps/{id}/triggers/{trigger_id} is gated off.""" + self.assert_not_available( + requests.get( + f'{self.BASE_URL}/apps/{self.APP}/triggers/ghost', timeout=5 + ) + ) + + def test_04_update_trigger_returns_501(self): + """PUT /apps/{id}/triggers/{trigger_id} is gated off. + + The body has to be valid, because the gate runs after body parsing - + an invalid one would be answered with 400 before the gate is reached. + """ + self.assert_not_available( + requests.put( + f'{self.BASE_URL}/apps/{self.APP}/triggers/ghost', + json={'lifetime': 60}, + timeout=5, + ) + ) + + def test_05_delete_trigger_returns_501(self): + """DELETE /apps/{id}/triggers/{trigger_id} is gated off.""" + self.assert_not_available( + requests.delete( + f'{self.BASE_URL}/apps/{self.APP}/triggers/ghost', timeout=5 + ) + ) + + def test_06_trigger_events_stream_returns_501(self): + """GET /apps/{id}/triggers/{trigger_id}/events (SSE) is gated off. + + The SSE escape hatch has its own wrapper, so the gate has to be wired + there separately from the typed CRUD wrappers. + """ + self.assert_not_available( + requests.get( + f'{self.BASE_URL}/apps/{self.APP}/triggers/ghost/events', + timeout=5, + ) + ) + + def test_07_malformed_json_returns_400_not_501(self): + """A malformed body outranks the feature gate. + + The gate runs inside the typed wrapper, after body parsing. Trigger + POST goes through a different wrapper than the update routes + (``wrap_with_body`` rather than ``wrap_with_body_attachments``), so + the ordering is worth proving on both. + """ + resp = requests.post( + f'{self.BASE_URL}/apps/{self.APP}/triggers', + data='not{valid json', + headers={'Content-Type': 'application/json'}, + timeout=5, + ) + self.assertEqual(resp.status_code, 400, resp.text) + self.assertEqual(resp.json()['error_code'], 'invalid-request') + + # @verifies REQ_INTEROP_002 + def test_08_spec_declares_the_gated_status(self): + """Every documented trigger operation declares the 501 the gate returns.""" + spec = self.get_json('/docs') + checked = 0 + for path, item in spec['paths'].items(): + if '/triggers' not in path: + continue + for method, op in item.items(): + if method not in HTTP_METHODS: + continue + checked += 1 + self.assertIn('501', op['responses'], f'{method.upper()} {path}') + self.assertGreater(checked, 0, 'No trigger operations in the document') + + @launch_testing.post_shutdown_test() class TestShutdown(unittest.TestCase): diff --git a/src/ros2_medkit_integration_tests/test/features/test_updates.test.py b/src/ros2_medkit_integration_tests/test/features/test_updates.test.py index 4a248333c..b14eacdcc 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_updates.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_updates.test.py @@ -33,6 +33,8 @@ PORT_NO_PLUGIN = get_test_port(0) PORT_WITH_PLUGIN = get_test_port(1) +HTTP_METHODS = {'get', 'post', 'put', 'delete', 'patch', 'head', 'options'} + def _get_test_plugin_path(): """Get path to test_update_backend.so demo plugin.""" @@ -202,6 +204,41 @@ def test_08_delete_returns_501(self): self.assertEqual(r.status_code, 501) self.assertEqual(r.json()['error_code'], 'not-implemented') + # @verifies REQ_INTEROP_083 + def test_09_malformed_json_returns_400_not_501(self): + """A malformed body outranks the feature gate. + + The gate runs inside the typed wrapper, after body parsing, so a broken + payload is still reported as the client's own error. Moving the guard + ahead of the parse would answer 501 here and hide the real problem. + """ + r = requests.post( + f'{self.BASE_URL}/updates', + data='not{valid json', + headers={'Content-Type': 'application/json'}, + timeout=5, + ) + self.assertEqual(r.status_code, 400, r.text) + self.assertEqual(r.json()['error_code'], 'invalid-request') + + # @verifies REQ_INTEROP_002 + def test_disabled_feature_returns_501_and_declares_it(self): + """Every /updates operation documents the 501 the gate returns. + + The eight tests above prove the gateway answers 501 with the feature + off. This one proves the generated document says so, which is what a + generated client needs to handle the response instead of treating it + as an undeclared surprise. + """ + spec = self.get_json('/docs') + for path, item in spec['paths'].items(): + if not path.startswith('/updates'): + continue + for method, op in item.items(): + if method not in HTTP_METHODS: + continue + self.assertIn('501', op['responses'], f'{method.upper()} {path}') + class TestUpdatesCRUD(_UpdatesTestMixin, GatewayTestCase): """Scenario 2: CRUD lifecycle - register, list, get, delete.""" From 182015f9bf5bcf6497b7ce6865baa6218347621c Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Wed, 5 Aug 2026 08:43:02 +0200 Subject: [PATCH 04/17] feat(gateway): publish the response headers the gateway actually sets Twelve sites attached a header no operation declared. response_header() records it where it is set; declare_derived_response static_asserts that a 201 or 202 carries its Location, so publishing the header without sending it stops compiling. validate_completeness() runs at startup and is asserted by the suite, so a missing declaration is reported rather than silently dropped. --- docs/api/rest.rst | 16 +- .../design/dto_contract.rst | 101 +++++- .../core/http/handlers/trigger_handlers.hpp | 7 +- .../core/http/rest_server.hpp | 10 + .../http/handler_result.hpp | 17 + .../handlers/cyclic_subscription_handlers.hpp | 7 +- .../src/core/openapi/route_registry.cpp | 128 +++++++- .../src/http/handlers/bulkdata_handlers.cpp | 2 +- .../handlers/cyclic_subscription_handlers.cpp | 8 +- .../src/http/handlers/lifecycle_handlers.cpp | 9 +- .../src/http/handlers/lock_handlers.cpp | 2 +- .../src/http/handlers/operation_handlers.cpp | 13 +- .../src/http/handlers/script_handlers.cpp | 4 +- .../src/http/handlers/trigger_handlers.cpp | 10 +- .../src/http/handlers/update_handlers.cpp | 8 +- .../src/http/rest_server.cpp | 68 +++- .../src/openapi/openapi_spec_builder.cpp | 32 ++ .../src/openapi/route_registry.hpp | 302 +++++++++++------- .../test/test_openapi_spec_builder.cpp | 32 ++ .../test/test_route_registry.cpp | 228 +++++++++++++ .../test/test_typed_route_registry.cpp | 105 +++++- .../features/test_fault_triggers_api.test.py | 6 + .../features/test_openapi_contract.test.py | 108 ++++++- .../test_scenario_bulk_data_upload.test.py | 5 + 24 files changed, 1053 insertions(+), 175 deletions(-) diff --git a/docs/api/rest.rst b/docs/api/rest.rst index c3f52a817..9636d65ff 100644 --- a/docs/api/rest.rst +++ b/docs/api/rest.rst @@ -1225,8 +1225,14 @@ Download a specific bulk-data file. - ``Content-Type``: ``application/x-mcap`` (MCAP format) or ``application/x-sqlite3`` (db3) - ``Content-Disposition``: ``attachment; filename="FAULT_CODE.mcap"`` +- ``Accept-Ranges``: ``bytes`` - the download is served by a range-aware + provider, so a client may fetch part of the file - ``Access-Control-Expose-Headers``: ``Content-Disposition`` +A request carrying a satisfiable ``Range`` header is answered with **206 +Partial Content** and a ``Content-Range: bytes -/`` header +instead of ``200``; the body is the requested slice. + **Example:** .. code-block:: bash @@ -1236,6 +1242,7 @@ Download a specific bulk-data file. **Response Codes:** - **200 OK**: File content +- **206 Partial Content**: The byte range requested via ``Range``, with ``Content-Range`` - **404 Not Found**: Entity, category, or bulk-data ID not found Upload Bulk Data @@ -1508,6 +1515,9 @@ Subscriptions are temporary - they do not survive server restart. ``POST /api/v1/{entity_type}/{entity_id}/cyclic-subscriptions`` Create a new cyclic subscription. + Response: **201 Created** with a ``Location`` header pointing to the new + subscription. + **Applies to:** ``/apps``, ``/components``, ``/functions`` **Request Body:** @@ -1777,6 +1787,9 @@ Create Trigger ``POST /api/v1/{entity_type}/{entity_id}/triggers`` Create a new condition-based trigger. + Response: **201 Created** with a ``Location`` header pointing to the new + trigger. + **Request Body:** .. code-block:: json @@ -2110,7 +2123,8 @@ way as every other endpoint. Create a rule. Required: ``data_name``, ``operator`` (``>``, ``<``, ``>=``, ``<=``, ``==``), ``threshold`` (number), ``fault_code``, ``severity`` (``INFO``/``WARNING``/``ERROR``/``CRITICAL``). Optional: ``active`` - (default ``true``). Returns ``201`` with the created rule. + (default ``true``). Returns ``201`` with the created rule and a ``Location`` + header pointing to it. Validation: ``400`` for missing/invalid fields or a ``data_name`` the app does not expose (when enumerable); ``409`` when the ``fault_code`` is diff --git a/src/ros2_medkit_gateway/design/dto_contract.rst b/src/ros2_medkit_gateway/design/dto_contract.rst index 4211a7f68..1eaa92d22 100644 --- a/src/ros2_medkit_gateway/design/dto_contract.rst +++ b/src/ros2_medkit_gateway/design/dto_contract.rst @@ -365,7 +365,7 @@ wrapped) response: -> http::Result, http::ResponseAttachments>> { dto::Resp r; http::ResponseAttachments att; - att.with_header("Location", "/resources/123"); + att.with_location(api_path("/resources/123")); return std::make_pair(http::Created{std::move(r)}, std::move(att)); }); @@ -373,6 +373,28 @@ When the attachments carry no ``status_override``, the framework falls back to ``dto_alternate_status`` - never to a literal 200/204 - so wrapping a paired response is enough to move both the wire status and the declared one. +``with_location(uri)`` is the typed form of the ``Location`` attachment, and it +is not merely sugar. The registry declares a ``Location`` response header on +*every* derived 201 and 202, because ``Created`` / ``Accepted`` already +told it the status - so a handler behind one of those return types that does +not call ``with_location`` publishes a header it never sends. + +The obligation is enforced where it can be. The non-attachments overloads - +``get`` / ``post`` / ``put`` / ``patch`` / ``del`` and the +non-attachments ``post_alternates`` / ``del_alternates`` - give a handler no +channel for a header at all, so they ``static_assert`` against a ``TResponse`` +(or a variant alternate) whose status is 201 or 202. A route that would +advertise ``Location`` and be structurally unable to send it does not compile. +What the type system cannot see is a *pair-returning* handler that simply +forgets the call; that half is covered by the document contract test, which +asserts every declared 201/202 carries the header and that a real 201 puts it +on the wire. ``uri`` is the +absolute, API-prefixed path form every ``href`` in the document uses: build it +with ``api_path(...)``, or pass ``req.path() + "/" + id`` when the new resource +is a child of the request path (``TypedRequest::path()`` is already prefixed). +Writing the ``"/api/v1/"`` literal by hand is what let three spellings of the +same URI accumulate across the handlers. + The framework writes the response body via ``JsonWriter>``, applies the attachments, and renders any error branch via the route's configured ``ErrorRenderer`` @@ -421,6 +443,18 @@ which publishes the ``x-medkit-alternates: true`` operation extension, so the document contract test can tell a real variant from a route that declares a status it cannot return. Nothing else may set that marker. +There is exactly one other way a second 2xx is reachable, and it carries its +own marker rather than reusing that one. ``reg.binary_download`` handlers never +assign a status, so cpp-httplib answers 200 or **206 Partial Content** +depending on whether the request carried a satisfiable ``Range`` - it also +fills in ``Content-Range``. The helper therefore declares both statuses and +calls ``RouteEntry::mark_partial_content()``, publishing +``x-medkit-partial-content: true``. Two markers, not one: there the handler +chooses between variant members, here the handler returns one thing and the +HTTP layer decides how to frame it. A single marker covering both would let the +contract test wave through a route that declares a status it can never return. +Nothing outside ``binary_download`` may set it. + Two further ``RouteEntry`` knobs shape the published response set: - ``errors({409, 423})`` - declare error statuses this route can emit beyond @@ -439,6 +473,21 @@ Two further ``RouteEntry`` knobs shape the published response set: *is* safe with respect to ``gated_on()`` in either order - a live gate is a second reachable outcome, so ``only_status`` re-declares the gate's status rather than dropping it. +- ``response_header(status, {name, description, schema})`` - declare a header + this route sets on an **already declared** status. A header is a property of + a response, so a call aimed at a status no response declares is dropped and + reported by ``validate_completeness()`` rather than minting a + description-less response object for a status the handler cannot return + (a release build compiles ``assert`` out, so a precondition check there would + be no check at all). Re-declaring the same header name on the same status + replaces it, which is how a route overrides the framework's automatic + ``Location`` prose. Most routes never call it: ``Location`` comes from the + status, and the ``sse`` / ``binary_download`` helpers declare their own + framework-owned headers (``Cache-Control`` and ``X-Accel-Buffering``; + ``Content-Disposition`` and ``Accept-Ranges``) next to the code that sets + them. Declared headers carry no ``required`` flag - OpenAPI response headers + are optional by definition, which matches headers the gateway sets + conditionally. - ``gated_on(available, unavailable)`` - the route's backing feature can be absent. ``available`` is re-evaluated per request (a manager can appear after registration), and when it is false the framework answers with @@ -459,6 +508,50 @@ Two further ``RouteEntry`` knobs shape the published response set: manager - are declared with plain ``errors({501})`` until a handler-level seam exists. +Every self-check named above (``errors()`` handed a sub-400 status, +``response_header()`` aimed at an undeclared status, a route with no tag or no +success schema) reports through ``RouteRegistry::validate_completeness()``, and +``RESTServer::report_route_metadata_issues()`` calls it once at start-up and +logs what it finds. That call is what makes "reported" mean something: before +it existed the issues were collected and discarded outside the unit tests, so +the guarantee was words only. + +The report is logged, never fatal. Every issue it can raise is a defect in the +*document*, and a gateway that refused to serve traffic because one route is +missing a summary would trade a documentation bug for an outage. Its job is to +cover the route set a given configuration actually assembled - which feature +gates and plugins make impossible to enumerate in a test - while the OpenAPI +contract suite gates the shape of the document itself. + +The report is not merely logged. It emits a summary line unconditionally - +including for a clean route set, because a line that only appears on failure +cannot be asserted on - and +``test_openapi_contract.test.py::test_shipped_route_set_declares_complete_metadata`` +waits for that line with a zero error count, on the fixture that turns every +optional feature gate on. That is what makes it a gate rather than a diagnostic +nobody reads. + +One consequence worth stating: the request-body check reads the *registration*, +not the HTTP method - and only the **attachments** body-less ``put`` +is exempt. That overload is the fire-and-forget state-machine kick +(``/updates/{id}/prepare``, the lifecycle transitions), which genuinely takes no +payload, and it records that on the entry. The plain body-less ``put`` and the +body-less ``post`` are both **not** exempt: their callers read the body by hand +(``PUT /{entity}/data/{data_id}`` parses free-form JSON so plugin-owned entities +can send shapes ``DataWriteRequest`` does not describe; ``/auth/*`` parses +form-urlencoded), so a missing ``.request_body(...)`` there is a real gap the +check must keep reporting. + +Three statuses never reach a handler at all: the auth middleware answers 401 +and 403, and the rate limiter answers 429, both ahead of routing. No return +type can describe them and no ``RouteEntry`` can carry their headers, so they +are declared once as the shared component responses ``Unauthorized`` (carrying +``WWW-Authenticate``), ``Forbidden`` and ``RateLimited`` (carrying +``Retry-After`` and the ``X-RateLimit-*`` trio) in ``OpenApiSpecBuilder``. +Routes reference them - 401/403 when ``set_auth_enabled(true)``, 429 when +``set_rate_limit_enabled(true)`` - so the document mentions a middleware status +exactly when that middleware is live. + Escape Hatches -------------- @@ -483,7 +576,11 @@ remain compile-time-checked at their boundary. ``provider``, ``content_type``, ``filename``, ``supports_ranges``, and ``total_size``; the framework wires ``provider`` into cpp-httplib's range-aware content-provider machinery so partial-content fetches work - without manual ``Content-Range`` plumbing. + without manual ``Content-Range`` plumbing. The helper owns the whole header + and status story for these routes: it sends ``Content-Disposition`` when the + response names a file and ``Accept-Ranges: bytes`` when the provider is + range-capable (cpp-httplib only sets the latter for ``HEAD``), and it + declares 200, 206, and those headers - see ``mark_partial_content()`` above. - ``reg.multipart_upload(path, handler)`` - registers a ``multipart/form-data`` upload. The handler receives ``http::MultipartBody`` (already parsed by cpp-httplib) and returns diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/trigger_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/trigger_handlers.hpp index 54743c917..f194926de 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/trigger_handlers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/trigger_handlers.hpp @@ -67,9 +67,10 @@ class TriggerHandlers { /// POST /{entity}/triggers - create trigger. /// - /// On success returns the new `Trigger` body as 201 Created. - http::Result> post_trigger(const http::TypedRequest & req, - dto::TriggerCreateRequest body); + /// On success returns the new `Trigger` body as 201 Created, with the + /// `Location` header naming the trigger that was created. + http::Result, http::ResponseAttachments>> + post_trigger(const http::TypedRequest & req, dto::TriggerCreateRequest body); /// GET /{entity}/triggers - list all triggers for entity. http::Result> get_triggers(const http::TypedRequest & req); diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/rest_server.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/rest_server.hpp index 4e2199e76..026f2b854 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/rest_server.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/rest_server.hpp @@ -95,6 +95,16 @@ class RESTServer { private: void setup_routes(); + + /// Log every issue `RouteRegistry::validate_completeness()` finds for the + /// route set this configuration assembled, plus an unconditional summary line + /// carrying the error and warning counts. Called once, after registration. + /// + /// Never fatal - an incomplete OpenAPI declaration is a documentation defect, + /// not a reason to refuse traffic. The summary is what the integration suite + /// asserts on, so the check is gated rather than merely logged. + void report_route_metadata_issues() const; + void setup_pre_routing_handler(); void setup_global_error_handlers(); diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handler_result.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handler_result.hpp index 63ace3b57..9bbb44208 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handler_result.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handler_result.hpp @@ -114,6 +114,23 @@ struct ResponseAttachments { headers.emplace_back(std::move(name), std::move(value)); return *this; } + + /// Fluent setter for the `Location` header of a 201 / 202 response. + /// + /// `uri` is the absolute, API-prefixed path form every `href` in the + /// document already uses (`/api/v1/apps/x/triggers/7`). Build it with + /// `api_path(...)` when the handler assembles the target from parts, or pass + /// `req.path() + "/" + id` when the new resource is a child of the request + /// path - `TypedRequest::path()` is already prefixed. Never hand-roll the + /// `"/api/v1/"` literal: that is what let three different spellings of the + /// same URI accumulate across the handlers. + /// + /// The route registry declares this header on every derived 201 / 202, so a + /// handler that returns `Created` / `Accepted` without calling this + /// publishes a header it does not send. + ResponseAttachments & with_location(std::string uri) { + return with_header("Location", std::move(uri)); + } }; } // namespace http diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/cyclic_subscription_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/cyclic_subscription_handlers.hpp index 0c820050f..c5444fe91 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/cyclic_subscription_handlers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/cyclic_subscription_handlers.hpp @@ -68,9 +68,10 @@ class CyclicSubscriptionHandlers { /// POST /{entity}/cyclic-subscriptions - create subscription. /// - /// On success returns the new `CyclicSubscription` body as 201 Created. - http::Result> post_subscription(const http::TypedRequest & req, - dto::CyclicSubscriptionCreateRequest body); + /// On success returns the new `CyclicSubscription` body as 201 Created, with + /// the `Location` header naming the subscription that was created. + http::Result, http::ResponseAttachments>> + post_subscription(const http::TypedRequest & req, dto::CyclicSubscriptionCreateRequest body); /// GET /{entity}/cyclic-subscriptions - list all subscriptions for entity. http::Result> get_subscriptions(const http::TypedRequest & req); diff --git a/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp b/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp index a9168fa9a..58839c89c 100644 --- a/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp +++ b/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp @@ -143,6 +143,11 @@ RouteEntry & RouteEntry::mark_alternates() { return *this; } +RouteEntry & RouteEntry::mark_partial_content() { + partial_content_ = true; + return *this; +} + RouteEntry & RouteEntry::success_description(const std::string & desc) { for (auto & [code, info] : responses_) { if (code >= 200 && code < 300) { @@ -152,6 +157,27 @@ RouteEntry & RouteEntry::success_description(const std::string & desc) { return *this; } +RouteEntry & RouteEntry::response_header(int status_code, ResponseHeader header) { + auto it = responses_.find(status_code); + if (it == responses_.end()) { + // No assert: this is a release build, and an assert compiled out would let + // the header vanish silently. Record the miscall so validate_completeness() + // surfaces it at startup instead. + undeclared_header_statuses_.push_back(status_code); + return *this; + } + auto & headers = it->second.headers; + auto existing = std::find_if(headers.begin(), headers.end(), [&header](const ResponseHeader & h) { + return h.name == header.name; + }); + if (existing != headers.end()) { + *existing = std::move(header); + } else { + headers.push_back(std::move(header)); + } + return *this; +} + RouteEntry & RouteEntry::errors(std::initializer_list codes) { for (int code : codes) { if (code < 400) { @@ -359,6 +385,12 @@ RouteEntry & RouteRegistry::sse(const std::string & openapi_path, // SSE has no JSON schema; mark it explicitly so validate_completeness skips // the success-schema check via its SSE-name heuristic. entry.response(200, "Server-Sent Events stream"); + // Declared here, next to the `set_header` calls above, because that is what + // stops the two from drifting: the framework owns these headers, so no SSE + // route can be registered without them and none can document them wrongly. + entry.response_header(200, ResponseHeader{"Cache-Control", "Always `no-cache`; event streams are never cached."}); + entry.response_header( + 200, ResponseHeader{"X-Accel-Buffering", "Always `no`; disables response buffering in nginx-style proxies."}); return entry; } @@ -385,6 +417,10 @@ RouteRegistry::binary_download(const std::string & openapi_path, res.set_header("Content-Disposition", "attachment; filename=\"" + *bin->filename + "\""); } if (bin->supports_ranges) { + // RFC 9110 §14.3: a range-capable resource advertises the unit it accepts. + // cpp-httplib only fills this in for HEAD, so a GET would otherwise serve + // partial content no client knew it could ask for. + res.set_header("Accept-Ranges", "bytes"); res.set_content_provider(static_cast(bin->total_size), bin->content_type, [bin](std::size_t offset, std::size_t length, httplib::DataSink & sink) -> bool { return bin->provider(static_cast(offset), @@ -402,7 +438,33 @@ RouteRegistry::binary_download(const std::string & openapi_path, auto & entry = add_route("get", openapi_path, std::move(fn)); entry.error_renderer_ = renderer; entry.gate_ = gate; - entry.response(200, "Binary download", nlohmann::json{{"type", "string"}, {"format", "binary"}}); + const nlohmann::json binary_schema{{"type", "string"}, {"format", "binary"}}; + + // The handler never assigns `res.status`, so cpp-httplib decides it: 200, or + // 206 when the request carried a satisfiable `Range` - and it fills in + // `Content-Range` itself. Advertising `Accept-Ranges` while saying nothing + // about what a `Range` request answers would invite clients into an + // undocumented response. The `Range` request parameter and the 416 rejection + // belong to the full Range contract and are deliberately not declared here. + entry.response(200, "Binary download", binary_schema); + entry.response(206, "Requested byte range of the file", binary_schema); + entry.mark_partial_content(); + + // Set on the response before the content provider takes over, so they ride on + // whichever status cpp-httplib picks - hence declared on both. Each is + // conditional on the BinaryResponse the handler returned (a filename, a + // range-capable provider), which is why none is `required`: OpenAPI response + // headers are optional by definition. + const ResponseHeader content_disposition{ + "Content-Disposition", "`attachment; filename=\"...\"` when the download names a file. Absent otherwise."}; + const ResponseHeader accept_ranges{"Accept-Ranges", + "`bytes` when the download supports range requests. Absent otherwise."}; + entry.response_header(200, content_disposition); + entry.response_header(200, accept_ranges); + entry.response_header(206, content_disposition); + entry.response_header(206, accept_ranges); + entry.response_header( + 206, ResponseHeader{"Content-Range", "`bytes -/` for the range that was served."}); return entry; } @@ -574,6 +636,12 @@ nlohmann::json RouteRegistry::to_openapi_paths() const { // The handler returns a variant, so more than one 2xx code is genuine. operation["x-medkit-alternates"] = true; } + if (route.partial_content_) { + // cpp-httplib answers 206 instead of 200 when the request carries a + // satisfiable `Range`, so more than one 2xx code is genuine here too - + // for a different reason than a variant-returning handler. + operation["x-medkit-partial-content"] = true; + } // Parameters if (!route.parameters_.empty()) { @@ -585,8 +653,17 @@ nlohmann::json RouteRegistry::to_openapi_paths() const { { std::set explicit_params; for (const auto & p : route.parameters_) { - if (p.value("in", "") == "path") { - explicit_params.insert(p.value("name", "")); + // Looked up with find()/get_ref() rather than value(): the latter runs + // nlohmann's from_json conversion machinery, which GCC inlines into a + // -Wnull-dereference false positive here, and it silently inserted an + // empty name for a parameter object missing "name". + const auto in_it = p.find("in"); + if (in_it == p.end() || *in_it != "path") { + continue; + } + const auto name_it = p.find("name"); + if (name_it != p.end() && name_it->is_string()) { + explicit_params.insert(name_it->get_ref()); } } @@ -651,9 +728,16 @@ nlohmann::json RouteRegistry::to_openapi_paths() const { for (const auto & [code, info] : route.responses_) { std::string code_str = std::to_string(code); operation["responses"][code_str]["description"] = info.desc; + // Guard stays: a default-constructed nlohmann::json is `null`, so + // dropping this writes `"schema": null` onto every bodyless 204. if (!info.schema.empty()) { operation["responses"][code_str]["content"]["application/json"]["schema"] = info.schema; } + for (const auto & header : info.headers) { + auto & header_obj = operation["responses"][code_str]["headers"][header.name]; + header_obj["description"] = header.description; + header_obj["schema"] = header.schema; + } } } else { // Default 200 response @@ -663,11 +747,14 @@ nlohmann::json RouteRegistry::to_openapi_paths() const { // Add standard error responses as $ref to GenericError component. // Response-level $ref (not nested in content/schema) - the referenced // component is a complete response object with description and schema. - auto add_error_ref = [&operation](const std::string & code) { + auto add_response_ref = [&operation](const std::string & code, const std::string & component) { if (!operation["responses"].contains(code)) { - operation["responses"][code] = {{"$ref", "#/components/responses/GenericError"}}; + operation["responses"][code] = {{"$ref", "#/components/responses/" + component}}; } }; + auto add_error_ref = [&add_response_ref](const std::string & code) { + add_response_ref(code, "GenericError"); + }; for (int code : route.declared_errors_) { add_error_ref(std::to_string(code)); @@ -683,9 +770,16 @@ nlohmann::json RouteRegistry::to_openapi_paths() const { add_error_ref("500"); } + // The middleware answers these ahead of routing, on every route, so they + // are declared per-route but described once as shared components - that is + // the only place their headers (`WWW-Authenticate`, `Retry-After`, + // `X-RateLimit-*`) can live, since no handler return type produces them. if (auth_enabled_) { - add_error_ref("401"); - add_error_ref("403"); + add_response_ref("401", "Unauthorized"); + add_response_ref("403", "Forbidden"); + } + if (rate_limit_enabled_) { + add_response_ref("429", "RateLimited"); } // Use explicit operationId if set, otherwise auto-generate camelCase from path @@ -793,6 +887,15 @@ std::vector RouteRegistry::validate_completeness() const { "; use response() for success and redirect statuses"}); } + // response_header() attaches to an already-declared status. One aimed at a + // status this route never declares was dropped, so the header the handler + // sets would be missing from the document with nothing to show for it. + for (int code : route.undeclared_header_statuses_) { + issues.push_back({ValidationIssue::Severity::kError, route_id, + "response_header() targeted undeclared status " + std::to_string(code) + + "; declare the status first (success statuses come from the return type)"}); + } + // Check response schemas for non-DELETE methods if (route.method_ != "delete") { bool has_success_response_with_schema = false; @@ -838,8 +941,15 @@ std::vector RouteRegistry::validate_completeness() const { } } - // POST/PUT must have request_body - if ((route.method_ == "post" || route.method_ == "put") && !route.request_body_.has_value()) { + // POST/PUT must have request_body, unless the registration overload already + // said the route takes none. The body-less typed `put` is + // reserved for payload-free state-machine kicks (`/updates/{id}/prepare`, + // the lifecycle transitions); demanding a body schema of those reports 13 + // shipped routes that are correct as written. The body-less typed + // `post` is NOT exempt: its contract is that the handler parses + // a non-JSON body itself, so a missing declaration there is a real gap. + if ((route.method_ == "post" || route.method_ == "put") && !route.request_body_.has_value() && + !route.takes_no_request_body_) { // Exception: endpoints returning 405 (method not allowed) don't need request body bool is_405 = route.responses_.count(405) > 0; // Exception: PUT endpoints returning 204 (e.g., log config) don't need request body schema 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 280a1f063..ceb058a80 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/bulkdata_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/bulkdata_handlers.cpp @@ -563,7 +563,7 @@ BulkDataHandlers::upload(const http::TypedRequest & req, const http::MultipartBo } http::ResponseAttachments att; - att.with_header("Location", req.path() + "/" + stored.id); + att.with_location(req.path() + "/" + stored.id); return std::make_pair(http::Created{std::move(descriptor)}, std::move(att)); } diff --git a/src/ros2_medkit_gateway/src/http/handlers/cyclic_subscription_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/cyclic_subscription_handlers.cpp index f9f48687b..4906d3f81 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/cyclic_subscription_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/cyclic_subscription_handlers.cpp @@ -83,7 +83,7 @@ CyclicSubscriptionHandlers::CyclicSubscriptionHandlers(HandlerContext & ctx, Sub // --------------------------------------------------------------------------- // POST - create subscription // --------------------------------------------------------------------------- -http::Result> +http::Result, http::ResponseAttachments>> CyclicSubscriptionHandlers::post_subscription(const http::TypedRequest & req, dto::CyclicSubscriptionCreateRequest body) { auto id_result = read_entity_id(req); @@ -200,7 +200,11 @@ CyclicSubscriptionHandlers::post_subscription(const http::TypedRequest & req, } auto sub_dto = subscription_to_dto(*result, *event_source_result); - return http::Created{std::move(sub_dto)}; + http::ResponseAttachments att; + // The subscription is a child of the POST target, and `req.path()` already + // carries the API prefix, so this is the same absolute form every `href` uses. + att.with_location(req.path() + "/" + sub_dto.id); + return std::make_pair(http::Created{std::move(sub_dto)}, std::move(att)); } // --------------------------------------------------------------------------- diff --git a/src/ros2_medkit_gateway/src/http/handlers/lifecycle_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/lifecycle_handlers.cpp index efd23eaa2..ddc0fae55 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/lifecycle_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/lifecycle_handlers.cpp @@ -21,6 +21,7 @@ #include #include "ros2_medkit_gateway/core/http/error_codes.hpp" +#include "ros2_medkit_gateway/core/http/http_utils.hpp" #include "ros2_medkit_gateway/core/plugins/plugin_manager.hpp" #include "ros2_medkit_gateway/core/providers/lifecycle_provider.hpp" #include "ros2_medkit_gateway/core/status/lifecycle_state_reader.hpp" @@ -81,8 +82,7 @@ http::Result LifecycleHandlers::handle_get_status( } const auto & entity = *entity_result; - const std::string base = - std::string("/api/v1/") + (entity.type == EntityType::APP ? "apps/" : "components/") + entity.id; + const std::string base = api_path((entity.type == EntityType::APP ? "/apps/" : "/components/") + entity.id); // Delegate to LifecycleProvider when one is registered for this entity. if (plugin_mgr_) { @@ -208,8 +208,7 @@ LifecycleHandlers::handle_transition(const http::TypedRequest & req, std::string } const auto & entity = *entity_result; - const std::string base = - std::string("/api/v1/") + (entity.type == EntityType::APP ? "apps/" : "components/") + entity.id; + const std::string base = api_path((entity.type == EntityType::APP ? "/apps/" : "/components/") + entity.id); if (plugin_mgr_) { auto * provider = plugin_mgr_->get_lifecycle_provider_for_entity(entity_id); @@ -220,7 +219,7 @@ LifecycleHandlers::handle_transition(const http::TypedRequest & req, std::string return tl::make_unexpected(to_error_info(result.error())); } http::ResponseAttachments att; - att.with_header("Location", base + "/status"); + att.with_location(base + "/status"); return std::make_pair(http::Accepted{http::NoContent{}}, std::move(att)); } catch (const std::exception & e) { RCLCPP_ERROR(HandlerContext::logger(), "Plugin LifecycleProvider threw for entity '%s': %s", entity_id.c_str(), diff --git a/src/ros2_medkit_gateway/src/http/handlers/lock_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/lock_handlers.cpp index c7b6b30f0..d78532f71 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/lock_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/lock_handlers.cpp @@ -235,7 +235,7 @@ LockHandlers::post_lock(const http::TypedRequest & req, dto::AcquireLockRequest auto lock_dto = lock_info_to_dto(*result, client_id); http::ResponseAttachments att; - att.with_header("Location", std::string(req.path()) + "/" + result->lock_id); + att.with_location(std::string(req.path()) + "/" + result->lock_id); return std::make_pair(http::Created{std::move(lock_dto)}, std::move(att)); } catch (const std::exception & e) { diff --git a/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp index 972e67925..4144a4ef7 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp @@ -542,12 +542,12 @@ OperationHandlers::create_execution(const http::TypedRequest & req, dto::Executi async_dto.id = action_result.goal_id; async_dto.status = "running"; - const std::string base_path = (lookup->entity_type == "app") ? "/api/v1/apps/" : "/api/v1/components/"; + const std::string base_path = (lookup->entity_type == "app") ? "/apps/" : "/components/"; const std::string location = - base_path + entity_id + "/operations/" + operation_id + "/executions/" + action_result.goal_id; + api_path(base_path + entity_id + "/operations/" + operation_id + "/executions/" + action_result.goal_id); http::ResponseAttachments att; - att.with_header("Location", location); + att.with_location(location); // dto_alternate_status == 202, so the framework // emits the 202 status without an explicit override here. return SuccessPair{ResultVariant{std::move(async_dto)}, std::move(att)}; @@ -834,17 +834,16 @@ OperationHandlers::update_execution(const http::TypedRequest & req, const dto::E if (capability == "stop") { auto result = operation_mgr->cancel_action_goal(goal_info->action_path, execution_id); if (result.success && result.return_code == 0) { - const std::string base_path = - req.path().find("/apps/") != std::string::npos ? "/api/v1/apps/" : "/api/v1/components/"; + const std::string base_path = req.path().find("/apps/") != std::string::npos ? "/apps/" : "/components/"; const std::string location = - base_path + entity_id + "/operations/" + operation_id + "/executions/" + execution_id; + api_path(base_path + entity_id + "/operations/" + operation_id + "/executions/" + execution_id); dto::OperationExecution exec_dto; exec_dto.id = execution_id; exec_dto.status = "running"; // canceling is still "running" in SOVD terms http::ResponseAttachments att; - att.with_header("Location", location); + att.with_location(location); return SuccessPair{http::Accepted{std::move(exec_dto)}, std::move(att)}; } std::string error_msg; diff --git a/src/ros2_medkit_gateway/src/http/handlers/script_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/script_handlers.cpp index 853cad297..1c6d9fade 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/script_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/script_handlers.cpp @@ -249,7 +249,7 @@ ScriptHandlers::upload_script(const http::TypedRequest & req, const http::Multip upload_resp.name = result->name; http::ResponseAttachments att; - att.with_header("Location", script_path); + att.with_location(script_path); return std::make_pair(http::Created{std::move(upload_resp)}, std::move(att)); } catch (const std::exception & e) { return tl::unexpected(make_error(500, ERR_INTERNAL_ERROR, e.what())); @@ -427,7 +427,7 @@ ScriptHandlers::start_execution(const http::TypedRequest & req) { api_path("/" + entity_type_segment + "/" + entity_id + "/scripts/" + script_id + "/executions/" + result->id); http::ResponseAttachments att; - att.with_header("Location", exec_path); + att.with_location(exec_path); return std::make_pair(http::Accepted{execution_info_to_dto(*result)}, std::move(att)); } catch (const std::exception & e) { return tl::unexpected(make_error(500, ERR_INTERNAL_ERROR, e.what())); diff --git a/src/ros2_medkit_gateway/src/http/handlers/trigger_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/trigger_handlers.cpp index b6dd53ded..aa8e5a61f 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/trigger_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/trigger_handlers.cpp @@ -106,8 +106,8 @@ TriggerHandlers::TriggerHandlers(HandlerContext & ctx, TriggerManager & trigger_ // --------------------------------------------------------------------------- // POST - create trigger // --------------------------------------------------------------------------- -http::Result> TriggerHandlers::post_trigger(const http::TypedRequest & req, - dto::TriggerCreateRequest body) { +http::Result, http::ResponseAttachments>> +TriggerHandlers::post_trigger(const http::TypedRequest & req, dto::TriggerCreateRequest body) { auto id_result = read_entity_id(req); if (!id_result) { return tl::unexpected(id_result.error()); @@ -271,7 +271,11 @@ http::Result> TriggerHandlers::post_trigger(const ht auto event_source = build_event_source(*result); auto trigger_dto = trigger_info_to_dto(*result, event_source); - return http::Created{std::move(trigger_dto)}; + http::ResponseAttachments att; + // The trigger is a child of the POST target, and `req.path()` already carries + // the API prefix, so this is the same absolute form every `href` uses. + att.with_location(req.path() + "/" + trigger_dto.id); + return std::make_pair(http::Created{std::move(trigger_dto)}, std::move(att)); } // --------------------------------------------------------------------------- diff --git a/src/ros2_medkit_gateway/src/http/handlers/update_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/update_handlers.cpp index 935e49b9d..5d391c3e6 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/update_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/update_handlers.cpp @@ -269,7 +269,7 @@ UpdateHandlers::post_update(const http::TypedRequest & /*req*/, dto::UpdateRegis dto::UpdateRegisterResponse resp; resp.id = id; http::ResponseAttachments att; - att.with_header("Location", api_path("/updates/" + id)); + att.with_location(api_path("/updates/" + id)); return std::make_pair(http::Created{std::move(resp)}, std::move(att)); } catch (const std::exception & e) { return tl::unexpected(make_internal_error("post_update", e)); @@ -322,7 +322,7 @@ UpdateHandlers::put_prepare(const http::TypedRequest & req) { return tl::unexpected(map_prepare_error(result.error())); } http::ResponseAttachments att; - att.with_header("Location", api_path("/updates/" + id + "/status")); + att.with_location(api_path("/updates/" + id + "/status")); return std::make_pair(http::Accepted{http::NoContent{}}, std::move(att)); } catch (const std::exception & e) { return tl::unexpected(make_internal_error("put_prepare", e)); @@ -350,7 +350,7 @@ UpdateHandlers::put_execute(const http::TypedRequest & req) { return tl::unexpected(map_execute_error(result.error())); } http::ResponseAttachments att; - att.with_header("Location", api_path("/updates/" + id + "/status")); + att.with_location(api_path("/updates/" + id + "/status")); return std::make_pair(http::Accepted{http::NoContent{}}, std::move(att)); } catch (const std::exception & e) { return tl::unexpected(make_internal_error("put_execute", e)); @@ -378,7 +378,7 @@ UpdateHandlers::put_automated(const http::TypedRequest & req) { return tl::unexpected(map_automated_error(result.error())); } http::ResponseAttachments att; - att.with_header("Location", api_path("/updates/" + id + "/status")); + att.with_location(api_path("/updates/" + id + "/status")); return std::make_pair(http::Accepted{http::NoContent{}}, std::move(att)); } catch (const std::exception & e) { return tl::unexpected(make_internal_error("put_automated", e)); diff --git a/src/ros2_medkit_gateway/src/http/rest_server.cpp b/src/ros2_medkit_gateway/src/http/rest_server.cpp index 41693eaa0..95e97e7a1 100644 --- a/src/ros2_medkit_gateway/src/http/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/http/rest_server.cpp @@ -141,6 +141,9 @@ RESTServer::RESTServer(GatewayNode * node, const std::string & host, int port, c // the registry lazily at request time, so the pointer is valid. route_registry_ = std::make_unique(); route_registry_->set_auth_enabled(auth_config_.enabled); + // Read the same flag the middleware branch above reads, so the document + // declares 429 exactly when the limiter is live. + route_registry_->set_rate_limit_enabled(rate_limit_config.enabled); health_handlers_ = std::make_unique(*handler_ctx_, route_registry_.get()); discovery_handlers_ = std::make_unique(*handler_ctx_); @@ -388,6 +391,11 @@ void RESTServer::setup_routes() { ft_json_error(res, created.error().first, created.error().second); return; } + // Raw route: the typed registry's automatic 201 `Location` + // declaration cannot reach here, so the header is set - and + // declared below - by hand. `req.path` already carries the API + // prefix, matching the form every other 201 uses. + res.set_header("Location", req.path + "/" + created->id); res.status = 201; res.set_content(FaultTriggerEngine::rule_to_json(*created).dump(2), "application/json"); }) @@ -402,6 +410,10 @@ void RESTServer::setup_routes() { .request_body("Fault-trigger rule definition", nlohmann::json{{"type", "object"}, {"additionalProperties", true}}) .response(201, "Created rule", nlohmann::json{{"type", "object"}}) + .response_header( + 201, openapi::ResponseHeader{"Location", + "Absolute path of the created rule, API prefix included (`/api/v1/...`).", + nlohmann::json{{"type", "string"}, {"format", "uri-reference"}}}) // 400/404 come from the registry's automatic response-level // GenericError $ref; only 409 needs a manual declaration. .response(409, "fault_code already used by another rule", @@ -994,8 +1006,8 @@ void RESTServer::setup_routes() { reg.post>( entity_path + "/triggers", - [this](http::TypedRequest req, - dto::TriggerCreateRequest body) -> http::Result> { + [this](http::TypedRequest req, dto::TriggerCreateRequest body) + -> http::Result, http::ResponseAttachments>> { return trigger_handlers_->post_trigger(req, std::move(body)); }) .tag("Triggers") @@ -1075,8 +1087,8 @@ void RESTServer::setup_routes() { reg.post>( entity_path + "/cyclic-subscriptions", - [this](http::TypedRequest req, - dto::CyclicSubscriptionCreateRequest body) -> http::Result> { + [this](http::TypedRequest req, dto::CyclicSubscriptionCreateRequest body) + -> http::Result, http::ResponseAttachments>> { return cyclic_sub_handlers_->post_subscription(req, std::move(body)); }) .tag("Subscriptions") @@ -1561,6 +1573,12 @@ void RESTServer::setup_routes() { .tag("Faults") .summary("Clear all faults globally") .description("Clears all faults across the entire system.") + // A 204 cannot carry a body, so the "peers were not cleared" caveat this + // route ships travels as a header - which makes declaring it the only way + // a generated client can see it at all. + .response_header(204, openapi::ResponseHeader{"X-Medkit-Local-Only", + "`true` when only the local FaultManager was cleared; faults held " + "by aggregated peers are untouched and must be cleared per peer."}) .operation_id("clearAllFaults") .query(); @@ -1771,6 +1789,48 @@ void RESTServer::setup_routes() { // Register all routes with cpp-httplib route_registry_->register_all(*srv, API_BASE_PATH); + + report_route_metadata_issues(); +} + +void RESTServer::report_route_metadata_issues() const { + // The registry's self-checks (`errors()` handed a sub-400 status, + // `response_header()` aimed at a status no response declares, a route with no + // tag or no success schema) are only worth recording if something reads them. + // Until this call existed they were collected and thrown away outside the unit + // tests, so the comments promising they would be "surfaced" were promising + // nothing on a shipped gateway. + // + // Logged, never fatal: every issue here is a defect in the *document*, and a + // gateway that refused to serve traffic because one route is missing a summary + // would trade a documentation bug for an outage. + // + // The summary line below is emitted unconditionally, including for a clean + // route set, and that is deliberate: a log line that only appears on failure + // cannot be asserted on, so the check itself would be guarded by nothing. + // `test_openapi_contract.test.py::test_shipped_route_set_declares_complete_metadata` + // waits for it and requires the error count to be zero, which is what makes + // this a gate rather than a diagnostic nobody reads. + std::size_t errors = 0; + std::size_t warnings = 0; + for (const auto & issue : route_registry_->validate_completeness()) { + if (issue.severity == openapi::ValidationIssue::Severity::kError) { + ++errors; + RCLCPP_ERROR(rclcpp::get_logger("rest_server"), "OpenAPI metadata error on %s: %s", issue.route.c_str(), + issue.message.c_str()); + } else { + ++warnings; + RCLCPP_WARN(rclcpp::get_logger("rest_server"), "OpenAPI metadata warning on %s: %s", issue.route.c_str(), + issue.message.c_str()); + } + } + RCLCPP_INFO(rclcpp::get_logger("rest_server"), + "OpenAPI metadata check: %zu error(s), %zu warning(s) across %zu route(s)", errors, warnings, + route_registry_->size()); + if (errors > 0) { + RCLCPP_ERROR(rclcpp::get_logger("rest_server"), + "Routes above publish incomplete OpenAPI metadata; generated clients will be wrong for them"); + } } void RESTServer::start() { diff --git a/src/ros2_medkit_gateway/src/openapi/openapi_spec_builder.cpp b/src/ros2_medkit_gateway/src/openapi/openapi_spec_builder.cpp index 0c5beda60..67d47c00c 100644 --- a/src/ros2_medkit_gateway/src/openapi/openapi_spec_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/openapi_spec_builder.cpp @@ -134,6 +134,38 @@ nlohmann::json OpenApiSpecBuilder::build() const { spec["components"]["responses"]["GenericError"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("GenericError"); + // 6b. Middleware-owned responses. 401/403/429 never reach a handler - the + // auth and rate-limit middleware answer them ahead of routing - so no route's + // return type can describe them and no `RouteEntry` can carry their headers. + // They are declared once here and referenced from every route the middleware + // guards. + auto & responses = spec["components"]["responses"]; + + // AuthMiddleware puts the RFC 6749 `{error, error_description}` shape on the + // wire for 401/403, which no component schema describes yet; these two point + // at GenericError until that schema exists, because referencing an undefined + // component would break the document's ref-resolution contract outright. + responses["Unauthorized"]["description"] = "Authentication is missing or the bearer token is invalid."; + responses["Unauthorized"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("GenericError"); + responses["Unauthorized"]["headers"]["WWW-Authenticate"] = { + {"description", "Bearer challenge, e.g. `Bearer realm=\"ros2_medkit_gateway\", error=\"invalid_token\"`."}, + {"schema", {{"type", "string"}}}}; + + responses["Forbidden"]["description"] = "The token is valid but lacks the scope this operation requires."; + responses["Forbidden"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("GenericError"); + + // The rate limiter emits the SOVD GenericError shape, so unlike the two + // above this schema already matches the wire. + responses["RateLimited"]["description"] = "The client exceeded its request quota."; + responses["RateLimited"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("GenericError"); + responses["RateLimited"]["headers"] = { + {"Retry-After", {{"description", "Seconds to wait before retrying."}, {"schema", {{"type", "string"}}}}}, + {"X-RateLimit-Limit", {{"description", "Requests permitted per window."}, {"schema", {{"type", "string"}}}}}, + {"X-RateLimit-Remaining", + {{"description", "Requests left in the current window."}, {"schema", {{"type", "string"}}}}}, + {"X-RateLimit-Reset", + {{"description", "Unix timestamp at which the window resets."}, {"schema", {{"type", "string"}}}}}}; + // 7. Security schemes (if any) if (!security_schemes_.empty()) { spec["security"] = nlohmann::json::array(); diff --git a/src/ros2_medkit_gateway/src/openapi/route_registry.hpp b/src/ros2_medkit_gateway/src/openapi/route_registry.hpp index 713a5f9cc..a728de1ec 100644 --- a/src/ros2_medkit_gateway/src/openapi/route_registry.hpp +++ b/src/ros2_medkit_gateway/src/openapi/route_registry.hpp @@ -76,6 +76,22 @@ struct RouteGate { /// `.gated_on(...)` applied to the returned `RouteEntry` afterwards. using GateHandle = std::shared_ptr>; +/// One response header a route publishes, as an OpenAPI Header Object. +/// +/// Response headers are optional by definition in OpenAPI (there is no +/// `required` on the emitted object here), which matches how the gateway sets +/// them: `Content-Disposition` only when the download names a file, +/// `Accept-Ranges` only when the provider is range-capable. +struct ResponseHeader { + /// Header field name as it appears on the wire, e.g. `Location`. + std::string name; + /// Prose a generated client shows for the header. + std::string description; + /// OpenAPI schema for the value. Defaults to a plain string, which is what + /// every header the gateway sets is. + nlohmann::json schema{{"type", "string"}}; +}; + /// Fluent builder for a single route entry. class RouteEntry { public: @@ -127,6 +143,18 @@ class RouteEntry { /// that declares a status it can never return. RouteEntry & mark_alternates(); + /// Mark this route as answering 206 to a `Range` request, emitted as + /// `x-medkit-partial-content: true`. Set by the `binary_download` helper. + /// + /// This is the second - and only other - legitimate source of a multi-2xx + /// operation, and it is deliberately a different marker from + /// `mark_alternates()`: there the handler chooses between variant members, + /// here the handler returns one thing and cpp-httplib turns it into 200 or + /// 206 depending on the request. Collapsing the two would let the document + /// contract test wave through a route that declares a status it can never + /// return. Nothing outside `binary_download` may set this. + RouteEntry & mark_partial_content(); + /// Author the prose published for this route's success response(s), leaving /// the status and the schema derived from the handler's return type. Use it /// instead of a hand-attached `response(201, "Trigger created", ref(...))`: @@ -137,6 +165,20 @@ class RouteEntry { /// one `TResponse` produced. RouteEntry & success_description(const std::string & desc); + /// Declare a response header this route sets on `status_code`. + /// + /// The status must already be declared - it comes from the handler's return + /// type for every derived route - because a header is a property of a + /// response, not a response of its own. Attaching one to an undeclared + /// status would otherwise mint a description-less response object and put a + /// status in the document the handler cannot return; instead the call is + /// dropped and reported by `validate_completeness()`. + /// + /// Re-declaring the same header name on the same status replaces it, so the + /// framework's automatic `Location` declaration can be overridden with + /// route-specific prose. + RouteEntry & response_header(int status_code, ResponseHeader header); + /// Declare additional error statuses this route can emit. Statuses below 400 /// are ignored and reported by validate_completeness() - use response() for /// success and redirect statuses. @@ -188,8 +230,19 @@ class RouteEntry { bool hidden_{false}; /// Set by mark_alternates(); emitted as `x-medkit-alternates: true`. bool alternates_{false}; + /// Set by mark_partial_content(); emitted as `x-medkit-partial-content: true`. + bool partial_content_{false}; /// Set by only_status(); suppresses the blanket 400/404/500 injection. bool only_status_{false}; + /// Set by the *attachments* body-less typed `put` overload - the + /// fire-and-forget state-machine kicks, which are the only registrations that + /// take no payload at all. Without it validate_completeness() infers "PUT + /// therefore a request body" from the method and reports 13 shipped routes + /// that are correct as written - noise that would train readers to ignore the + /// whole channel. Deliberately NOT set by the plain body-less `put` or by + /// `post`: both of those parse a body by hand, so a missing + /// declaration there is a genuine gap the check must keep reporting. + bool takes_no_request_body_{false}; std::string operation_id_; /// Heap-allocated so the typed wrapper closure can hold a stable handle to @@ -204,9 +257,18 @@ class RouteEntry { struct ResponseInfo { std::string desc; nlohmann::json schema; + /// NSDMI, not a bare member: the three brace-initialisations of this + /// aggregate predate the field and must keep compiling warning-free under + /// -Wmissing-field-initializers. + std::vector headers{}; }; std::map responses_; + /// Statuses passed to response_header() that no response declares. Kept so + /// validate_completeness() reports the miscall rather than the document + /// silently losing a header the handler sets. + std::vector undeclared_header_statuses_; + /// Error statuses declared via errors(), rendered as GenericError $refs /// alongside the blanket 400/404/500 set. std::vector declared_errors_; @@ -445,6 +507,13 @@ class RouteRegistry { auth_enabled_ = enabled; } + /// Set whether rate limiting is enabled (controls 429 in OpenAPI output). + /// The limiter runs pre-routing on every non-OPTIONS request, so when it is + /// on, 429 is reachable on every route and the document has to say so. + void set_rate_limit_enabled(bool enabled) { + rate_limit_enabled_ = enabled; + } + /// Escape hatch for JSON routes without typed DTOs (e.g. the fault-trigger /// CRUD): registers a raw cpp-httplib handler under an OpenAPI-style path so /// the route shows up in the generated spec, Swagger UI and the endpoint @@ -469,6 +538,7 @@ class RouteRegistry { std::deque routes_; bool auth_enabled_{false}; + bool rate_limit_enabled_{false}; // --------------------------------------------------------------------------- // Typed-handler wrapper helpers. @@ -621,6 +691,60 @@ inline const char * default_success_description(int status) { } } +/// True when `TResponse` fixes a status whose declared response carries a +/// `Location` header - i.e. exactly the statuses `declare_location_header` +/// publishes one for. +/// +/// The obligation and the means to meet it live in different places: the +/// status comes from the return type, but only the `ResponseAttachments` +/// overloads give a handler any way to set a header. This trait is what lets +/// the non-attachments overloads reject the combination at compile time +/// instead of shipping a route that advertises a header it cannot send. +template +inline constexpr bool kStatusRequiresAttachments = + http::dto_alternate_status::value == 201 || http::dto_alternate_status::value == 202; + +/// Declare the `Location` header a 201 / 202 answer carries, deriving the fact +/// that it carries one from the status the return type already fixed. +/// +/// RFC 9110 §15.3.2: a 201 identifies the resource it created with `Location`. +/// The gateway's 202 answers apply the same convention to the resource whose +/// status the client polls (`/updates/{id}/status`, `/{entity}/status`, an +/// execution). Every handler behind a `Created` / `Accepted` return type +/// sets the header, so declaring it here - rather than at each registration - +/// is what keeps the two from drifting apart one route at a time. +inline void declare_location_header(RouteEntry & entry, int status) { + if (status != 201 && status != 202) { + return; + } + entry.response_header( + status, + ResponseHeader{"Location", + status == 201 + ? "Absolute path of the created resource, API prefix included (`/api/v1/...`)." + : "Absolute path of the resource whose status tracks this request, API prefix included.", + nlohmann::json{{"type", "string"}, {"format", "uri-reference"}}}); +} + +/// Declare the one success response a typed route derives from `TResponse`: +/// status from `dto_alternate_status`, schema from `status_payload_t`, prose +/// from the status, and the `Location` header when the status implies one. +/// +/// Every typed registration entry point funnels through here so the derivation +/// exists once. A hand-rolled copy at a registration site is how a route ends +/// up with a status and a document that disagree. +template +inline void declare_derived_response(RouteEntry & entry) { + constexpr int status = http::dto_alternate_status::value; + using Payload = http::status_payload_t; + if constexpr (std::is_same_v) { + entry.response(status, default_success_description(status)); + } else { + entry.template response(status, default_success_description(status)); + } + declare_location_header(entry, status); +} + } // namespace detail template @@ -869,16 +993,12 @@ RouteEntry & RouteRegistry::get(const std::string & openapi_path, static_assert(dto::has_dto_shape_v> || std::is_same_v, http::NoContent>, "typed get: T must be a DTO (or NoContent)"); + static_assert(!detail::kStatusRequiresAttachments, + "201/202 must use the ResponseAttachments overload: the document declares a Location " + "header for those statuses, and only that overload lets the handler send one"); auto & entry = add_route("get", openapi_path, /*placeholder*/ HandlerFn{}); entry.handler_ = wrap_body_less(std::move(handler), entry.error_renderer_, entry.gate_); - if constexpr (!std::is_same_v, http::NoContent>) { - entry.template response>( - http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } else { - entry.response(http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } + detail::declare_derived_response(entry); return entry; } @@ -891,14 +1011,7 @@ RouteEntry & RouteRegistry::get( "typed get: T must be a DTO (or NoContent)"); auto & entry = add_route("get", openapi_path, HandlerFn{}); entry.handler_ = wrap_body_less_with_attachments(std::move(handler), entry.error_renderer_, entry.gate_); - if constexpr (!std::is_same_v, http::NoContent>) { - entry.template response>( - http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } else { - entry.response(http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } + detail::declare_derived_response(entry); return entry; } @@ -909,17 +1022,13 @@ RouteEntry & RouteRegistry::post(const std::string & openapi_path, static_assert(dto::has_dto_shape_v> || std::is_same_v, http::NoContent>, "typed post: T must be a DTO (or NoContent)"); + static_assert(!detail::kStatusRequiresAttachments, + "201/202 must use the ResponseAttachments overload: the document declares a Location " + "header for those statuses, and only that overload lets the handler send one"); auto & entry = add_route("post", openapi_path, HandlerFn{}); entry.handler_ = wrap_with_body(std::move(handler), entry.error_renderer_, entry.gate_); entry.template request_body(""); - if constexpr (!std::is_same_v, http::NoContent>) { - entry.template response>( - http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } else { - entry.response(http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } + detail::declare_derived_response(entry); return entry; } @@ -934,14 +1043,7 @@ RouteEntry & RouteRegistry::post( auto & entry = add_route("post", openapi_path, HandlerFn{}); entry.handler_ = wrap_with_body_attachments(std::move(handler), entry.error_renderer_, entry.gate_); entry.template request_body(""); - if constexpr (!std::is_same_v, http::NoContent>) { - entry.template response>( - http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } else { - entry.response(http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } + detail::declare_derived_response(entry); return entry; } @@ -951,20 +1053,16 @@ RouteEntry & RouteRegistry::post(const std::string & openapi_path, static_assert(dto::has_dto_shape_v> || std::is_same_v, http::NoContent>, "typed post: T must be a DTO (or NoContent)"); + static_assert(!detail::kStatusRequiresAttachments, + "201/202 must use the ResponseAttachments overload: the document declares a Location " + "header for those statuses, and only that overload lets the handler send one"); auto & entry = add_route("post", openapi_path, HandlerFn{}); entry.handler_ = wrap_body_less(std::move(handler), entry.error_renderer_, entry.gate_); // No automatic request_body schema: body-less typed POST is reserved for // routes that parse the body manually (e.g. form-urlencoded auth endpoints). // Callers attach an explicit `.request_body(...)` to populate the OpenAPI // spec. - if constexpr (!std::is_same_v, http::NoContent>) { - entry.template response>( - http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } else { - entry.response(http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } + detail::declare_derived_response(entry); return entry; } @@ -977,14 +1075,7 @@ RouteEntry & RouteRegistry::post( "typed post: T must be a DTO (or NoContent)"); auto & entry = add_route("post", openapi_path, HandlerFn{}); entry.handler_ = wrap_body_less_with_attachments(std::move(handler), entry.error_renderer_, entry.gate_); - if constexpr (!std::is_same_v, http::NoContent>) { - entry.template response>( - http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } else { - entry.response(http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } + detail::declare_derived_response(entry); return entry; } @@ -995,17 +1086,13 @@ RouteEntry & RouteRegistry::put(const std::string & openapi_path, static_assert(dto::has_dto_shape_v> || std::is_same_v, http::NoContent>, "typed put: T must be a DTO (or NoContent)"); + static_assert(!detail::kStatusRequiresAttachments, + "201/202 must use the ResponseAttachments overload: the document declares a Location " + "header for those statuses, and only that overload lets the handler send one"); auto & entry = add_route("put", openapi_path, HandlerFn{}); entry.handler_ = wrap_with_body(std::move(handler), entry.error_renderer_, entry.gate_); entry.template request_body(""); - if constexpr (!std::is_same_v, http::NoContent>) { - entry.template response>( - http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } else { - entry.response(http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } + detail::declare_derived_response(entry); return entry; } @@ -1020,14 +1107,7 @@ RouteEntry & RouteRegistry::put( auto & entry = add_route("put", openapi_path, HandlerFn{}); entry.handler_ = wrap_with_body_attachments(std::move(handler), entry.error_renderer_, entry.gate_); entry.template request_body(""); - if constexpr (!std::is_same_v, http::NoContent>) { - entry.template response>( - http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } else { - entry.response(http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } + detail::declare_derived_response(entry); return entry; } @@ -1037,18 +1117,19 @@ RouteEntry & RouteRegistry::put(const std::string & openapi_path, static_assert(dto::has_dto_shape_v> || std::is_same_v, http::NoContent>, "typed put: T must be a DTO (or NoContent)"); + static_assert(!detail::kStatusRequiresAttachments, + "201/202 must use the ResponseAttachments overload: the document declares a Location " + "header for those statuses, and only that overload lets the handler send one"); auto & entry = add_route("put", openapi_path, HandlerFn{}); entry.handler_ = wrap_body_less(std::move(handler), entry.error_renderer_, entry.gate_); - // No automatic request_body schema: body-less typed PUT is reserved for - // routes that take no payload at all (e.g. /updates/{id}/prepare). - if constexpr (!std::is_same_v, http::NoContent>) { - entry.template response>( - http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } else { - entry.response(http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } + // No automatic request_body schema, and deliberately NOT exempt from the + // completeness check: this overload's caller reads the body itself + // (`PUT /{entity}/data/{data_id}` parses free-form JSON by hand so + // plugin-owned entities can accept shapes `DataWriteRequest` does not + // describe), exactly like the body-less typed POST. A route here that omits + // `.request_body(...)` has a real gap in its document, so it must still be + // reported. The payload-free routes live on the attachments overload below. + detail::declare_derived_response(entry); return entry; } @@ -1061,14 +1142,13 @@ RouteEntry & RouteRegistry::put( "typed put: T must be a DTO (or NoContent)"); auto & entry = add_route("put", openapi_path, HandlerFn{}); entry.handler_ = wrap_body_less_with_attachments(std::move(handler), entry.error_renderer_, entry.gate_); - if constexpr (!std::is_same_v, http::NoContent>) { - entry.template response>( - http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } else { - entry.response(http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } + // This is the payload-free shape: a fire-and-forget state-machine kick that + // answers 202 with a `Location` (`/updates/{id}/prepare`, the lifecycle + // transitions). Recording that on the route lets validate_completeness() read + // it instead of inferring "PUT therefore a body" from the method. The plain + // body-less overload above is NOT exempt - its caller parses a body by hand. + entry.takes_no_request_body_ = true; + detail::declare_derived_response(entry); return entry; } @@ -1079,17 +1159,13 @@ RouteEntry & RouteRegistry::patch(const std::string & openapi_path, static_assert(dto::has_dto_shape_v> || std::is_same_v, http::NoContent>, "typed patch: T must be a DTO (or NoContent)"); + static_assert(!detail::kStatusRequiresAttachments, + "201/202 must use the ResponseAttachments overload: the document declares a Location " + "header for those statuses, and only that overload lets the handler send one"); auto & entry = add_route("patch", openapi_path, HandlerFn{}); entry.handler_ = wrap_with_body(std::move(handler), entry.error_renderer_, entry.gate_); entry.template request_body(""); - if constexpr (!std::is_same_v, http::NoContent>) { - entry.template response>( - http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } else { - entry.response(http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } + detail::declare_derived_response(entry); return entry; } @@ -1104,14 +1180,7 @@ RouteEntry & RouteRegistry::patch( auto & entry = add_route("patch", openapi_path, HandlerFn{}); entry.handler_ = wrap_with_body_attachments(std::move(handler), entry.error_renderer_, entry.gate_); entry.template request_body(""); - if constexpr (!std::is_same_v, http::NoContent>) { - entry.template response>( - http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } else { - entry.response(http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } + detail::declare_derived_response(entry); return entry; } @@ -1121,16 +1190,12 @@ RouteEntry & RouteRegistry::del(const std::string & openapi_path, static_assert(dto::has_dto_shape_v> || std::is_same_v, http::NoContent>, "typed del: T must be a DTO (or NoContent)"); + static_assert(!detail::kStatusRequiresAttachments, + "201/202 must use the ResponseAttachments overload: the document declares a Location " + "header for those statuses, and only that overload lets the handler send one"); auto & entry = add_route("delete", openapi_path, HandlerFn{}); entry.handler_ = wrap_body_less(std::move(handler), entry.error_renderer_, entry.gate_); - if constexpr (!std::is_same_v, http::NoContent>) { - entry.template response>( - http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } else { - entry.response(http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } + detail::declare_derived_response(entry); return entry; } @@ -1143,14 +1208,7 @@ RouteEntry & RouteRegistry::del( "typed del: T must be a DTO (or NoContent)"); auto & entry = add_route("delete", openapi_path, HandlerFn{}); entry.handler_ = wrap_body_less_with_attachments(std::move(handler), entry.error_renderer_, entry.gate_); - if constexpr (!std::is_same_v, http::NoContent>) { - entry.template response>( - http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } else { - entry.response(http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } + detail::declare_derived_response(entry); return entry; } @@ -1167,6 +1225,7 @@ inline void add_alternate_response(RouteEntry & entry) { "alternate variant member must be a DTO (regular or opaque) or NoContent"); entry.template response(status, default_success_description(status)); } + declare_location_header(entry, status); } } // namespace detail @@ -1176,6 +1235,9 @@ RouteEntry & RouteRegistry::post_alternates(const std::string & openapi_path, std::function>(http::TypedRequest, TBody)> handler) { static_assert(dto::is_dto_v, "post_alternates: TBody must be a DTO"); + static_assert((!detail::kStatusRequiresAttachments && ...), + "a 201/202 alternate must use the ResponseAttachments overload: the document declares a " + "Location header for those statuses, and only that overload lets the handler send one"); auto & entry = add_route("post", openapi_path, HandlerFn{}); entry.handler_ = wrap_post_alternates(std::move(handler), entry.error_renderer_, entry.gate_); entry.template request_body(""); @@ -1203,6 +1265,9 @@ template RouteEntry & RouteRegistry::del_alternates(const std::string & openapi_path, std::function>(http::TypedRequest)> handler) { + static_assert((!detail::kStatusRequiresAttachments && ...), + "a 201/202 alternate must use the ResponseAttachments overload: the document declares a " + "Location header for those statuses, and only that overload lets the handler send one"); auto & entry = add_route("delete", openapi_path, HandlerFn{}); entry.handler_ = wrap_del_alternates(std::move(handler), entry.error_renderer_, entry.gate_); (detail::add_alternate_response(entry), ...); @@ -1265,14 +1330,7 @@ RouteEntry & RouteRegistry::multipart_upload( entry.gate_ = gate; entry.request_body("Multipart upload", nlohmann::json{{"type", "object"}, {"additionalProperties", true}}, "multipart/form-data"); - if constexpr (!std::is_same_v, http::NoContent>) { - entry.template response>( - http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } else { - entry.response(http::dto_alternate_status::value, - detail::default_success_description(http::dto_alternate_status::value)); - } + detail::declare_derived_response(entry); return entry; } diff --git a/src/ros2_medkit_gateway/test/test_openapi_spec_builder.cpp b/src/ros2_medkit_gateway/test/test_openapi_spec_builder.cpp index dcbe3935f..720ef9230 100644 --- a/src/ros2_medkit_gateway/test/test_openapi_spec_builder.cpp +++ b/src/ros2_medkit_gateway/test/test_openapi_spec_builder.cpp @@ -201,6 +201,38 @@ TEST_F(OpenApiSpecBuilderTest, AlwaysIncludesGenericErrorResponse) { EXPECT_EQ(schema["$ref"].get(), "#/components/schemas/GenericError"); } +// ============================================================================= +// Middleware-owned responses always present +// ============================================================================= + +TEST_F(OpenApiSpecBuilderTest, AlwaysIncludesMiddlewareOwnedResponses) { + // The auth and rate-limit middleware answer 401/403/429 ahead of routing, so + // no route's return type can describe them and no RouteEntry can carry their + // headers. Every document defines them, which is also what keeps the + // per-route $refs to them resolvable. + auto spec = builder_.info("API", "1.0.0").build(); + auto & responses = spec["components"]["responses"]; + + ASSERT_TRUE(responses.contains("Unauthorized")); + EXPECT_TRUE(responses["Unauthorized"]["headers"].contains("WWW-Authenticate")); + + ASSERT_TRUE(responses.contains("Forbidden")); + EXPECT_FALSE(responses["Forbidden"]["description"].get().empty()); + + ASSERT_TRUE(responses.contains("RateLimited")); + for (const char * header : {"Retry-After", "X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Reset"}) { + EXPECT_TRUE(responses["RateLimited"]["headers"].contains(header)) << header; + } + + // Every one of them refs a schema this document also defines, so a generated + // client can resolve the error body. + for (const char * name : {"Unauthorized", "Forbidden", "RateLimited"}) { + const auto ref = responses[name]["content"]["application/json"]["schema"]["$ref"].get(); + const auto schema_name = ref.substr(ref.rfind('/') + 1); + EXPECT_TRUE(spec["components"]["schemas"].contains(schema_name)) << name << " -> " << ref; + } +} + // ============================================================================= // Contact info // ============================================================================= diff --git a/src/ros2_medkit_gateway/test/test_route_registry.cpp b/src/ros2_medkit_gateway/test/test_route_registry.cpp index 703638076..48be08200 100644 --- a/src/ros2_medkit_gateway/test/test_route_registry.cpp +++ b/src/ros2_medkit_gateway/test/test_route_registry.cpp @@ -879,3 +879,231 @@ TEST_F(RouteRegistryTest, GateWithSubErrorStatusIsReportedNotPublished) { EXPECT_TRUE(reported) << "sub-400 gate status was silently dropped"; EXPECT_FALSE(registry_.to_openapi_paths()["/bad-gate"]["get"]["responses"].contains("302")); } + +// ============================================================================= +// Response headers +// ============================================================================= + +namespace { + +// Seed handler returning `Created` so the registry derives 201 - the +// status that carries the automatic `Location` declaration. It must be the +// attachments form: the non-attachments overloads static_assert against 201/202 +// precisely because they give the handler no way to send the header the +// document then advertises. +using SeedCreated = ros2_medkit_gateway::http::Created; +using SeedCreatedPair = std::pair; + +Result seed_created_handler(TypedRequest req, RouteRegistryTestSeedDto /*body*/) { + ros2_medkit_gateway::http::ResponseAttachments att; + att.with_location(req.path() + "/seed"); + return SeedCreatedPair{SeedCreated{RouteRegistryTestSeedDto{}}, std::move(att)}; +} + +RouteEntry & seed_created_post(RouteRegistry & reg, const std::string & path) { + std::function(TypedRequest, RouteRegistryTestSeedDto)> h = &seed_created_handler; + return reg.post(path, std::move(h)); +} + +} // namespace + +// @verifies REQ_INTEROP_002 +TEST_F(RouteRegistryTest, DerivedCreatedResponseDeclaresLocationHeader) { + // The 201 comes from `Created`; the `Location` declaration is derived from + // that same status, so a route cannot ship one without the other. + seed_created_post(registry_, "/items").tag("Test").summary("Create item"); + + auto paths = registry_.to_openapi_paths(); + auto & created = paths["/items"]["post"]["responses"]["201"]; + + ASSERT_TRUE(created.contains("headers")) << created.dump(); + ASSERT_TRUE(created["headers"].contains("Location")); + EXPECT_FALSE(created["headers"]["Location"]["description"].get().empty()); + EXPECT_EQ(created["headers"]["Location"]["schema"]["type"].get(), "string"); +} + +TEST_F(RouteRegistryTest, PlainSuccessResponseDeclaresNoLocationHeader) { + // 200 does not identify a newly created resource, so declaring `Location` + // there would advertise a header the handler never sets. + seed_get(registry_, "/items").tag("Test").summary("List items"); + + // Bind the document to a local: `to_openapi_paths()` returns by value, and a + // reference into the temporary dangles the moment the statement ends. + auto paths = registry_.to_openapi_paths(); + ASSERT_TRUE(paths["/items"]["get"]["responses"].contains("200")); + auto & ok = paths["/items"]["get"]["responses"]["200"]; + EXPECT_FALSE(ok.contains("headers")) << ok.dump(); +} + +TEST_F(RouteRegistryTest, ResponseHeaderReplacesSameNameOnSameStatus) { + seed_created_post(registry_, "/items") + .tag("Test") + .summary("Create item") + .response_header(201, ResponseHeader{"Location", "Bespoke prose"}); + + auto paths = registry_.to_openapi_paths(); + auto & headers = paths["/items"]["post"]["responses"]["201"]["headers"]; + EXPECT_EQ(headers.size(), 1u) << headers.dump(); + EXPECT_EQ(headers["Location"]["description"].get(), "Bespoke prose"); +} + +TEST_F(RouteRegistryTest, ResponseHeaderOnUndeclaredStatusIsReportedNotPublished) { + // A header aimed at a status no response declares would otherwise mint a + // description-less response object and publish a status the handler cannot + // return. It is dropped and surfaced by validate_completeness() instead - an + // assert would be compiled out of the release build the gateway ships. + seed_get(registry_, "/items") + .tag("Test") + .summary("List items") + .response_header(418, ResponseHeader{"X-Teapot", "Never declared"}); + + bool reported = false; + for (const auto & issue : registry_.validate_completeness()) { + if (issue.message.find("418") != std::string::npos) { + reported = true; + EXPECT_EQ(issue.severity, ValidationIssue::Severity::kError); + } + } + EXPECT_TRUE(reported) << "undeclared-status header was silently dropped"; + auto paths = registry_.to_openapi_paths(); + EXPECT_FALSE(paths["/items"]["get"]["responses"].contains("418")); +} + +TEST_F(RouteRegistryTest, BodylessResponseKeepsNoSchemaKeyWhenHeadersAreDeclared) { + // A default-constructed nlohmann::json is `null`. Emitting headers must not + // drag a `"schema": null` onto a 204 that legitimately has no body. + seed_del(registry_, "/items") + .tag("Test") + .summary("Delete item") + .response_header(204, ResponseHeader{"X-Medkit-Local-Only", "Local scope only"}); + + auto paths = registry_.to_openapi_paths(); + auto & no_content = paths["/items"]["delete"]["responses"]["204"]; + EXPECT_FALSE(no_content.contains("content")) << no_content.dump(); + ASSERT_TRUE(no_content.contains("headers")); + EXPECT_TRUE(no_content["headers"].contains("X-Medkit-Local-Only")); +} + +TEST_F(RouteRegistryTest, MiddlewareStatusesReferenceTheirOwnComponents) { + // 401/403/429 never reach a handler, so their headers (WWW-Authenticate, + // Retry-After, X-RateLimit-*) can only live on shared components. Pointing + // them at GenericError would describe the wrong body and no headers at all. + registry_.set_auth_enabled(true); + registry_.set_rate_limit_enabled(true); + seed_get(registry_, "/items").tag("Test").summary("List items"); + + auto paths = registry_.to_openapi_paths(); + auto & responses = paths["/items"]["get"]["responses"]; + EXPECT_EQ(responses["401"]["$ref"].get(), "#/components/responses/Unauthorized"); + EXPECT_EQ(responses["403"]["$ref"].get(), "#/components/responses/Forbidden"); + EXPECT_EQ(responses["429"]["$ref"].get(), "#/components/responses/RateLimited"); + EXPECT_EQ(responses["400"]["$ref"].get(), "#/components/responses/GenericError"); +} + +TEST_F(RouteRegistryTest, RateLimitedStatusAbsentWhenLimiterIsOff) { + seed_get(registry_, "/items").tag("Test").summary("List items"); + auto paths = registry_.to_openapi_paths(); + ASSERT_TRUE(paths["/items"]["get"]["responses"].contains("200")) << "route missing; absence check would be vacuous"; + EXPECT_FALSE(paths["/items"]["get"]["responses"].contains("429")); +} + +// ============================================================================= +// Request-body completeness reads the registration, not the HTTP method +// ============================================================================= + +namespace { + +using SeedAccepted = ros2_medkit_gateway::http::Accepted; +using SeedAcceptedPair = std::pair; + +Result seed_body_less_handler(TypedRequest /*req*/) { + return ros2_medkit_gateway::http::NoContent{}; +} + +// The payload-free state-machine kick: 202 + Location, no request body at all. +Result seed_kick_handler(TypedRequest req) { + ros2_medkit_gateway::http::ResponseAttachments att; + att.with_location(req.path() + "/status"); + return SeedAcceptedPair{SeedAccepted{ros2_medkit_gateway::http::NoContent{}}, std::move(att)}; +} + +/// Plain body-less PUT - the overload whose callers parse a body by hand. +RouteEntry & seed_body_less_put(RouteRegistry & reg, const std::string & path) { + std::function(TypedRequest)> h = &seed_body_less_handler; + return reg.put(path, std::move(h)); +} + +/// Attachments body-less PUT - the overload reserved for payload-free routes. +RouteEntry & seed_kick_put(RouteRegistry & reg, const std::string & path) { + std::function(TypedRequest)> h = &seed_kick_handler; + return reg.put(path, std::move(h)); +} + +RouteEntry & seed_body_less_post(RouteRegistry & reg, const std::string & path) { + std::function(TypedRequest)> h = &seed_body_less_handler; + return reg.post(path, std::move(h)); +} + +bool has_error_mentioning(const RouteRegistry & reg, const std::string & needle) { + for (const auto & issue : reg.validate_completeness()) { + if (issue.severity == ValidationIssue::Severity::kError && issue.message.find(needle) != std::string::npos) { + return true; + } + } + return false; +} + +} // namespace + +TEST_F(RouteRegistryTest, PayloadFreePutIsNotAskedForARequestBody) { + // The attachments body-less PUT is the payload-free state-machine kick + // (/updates/{id}/prepare, the lifecycle transitions). Inferring "PUT + // therefore a request body" from the method reported 13 shipped routes that + // are correct as written, which is how a diagnostic channel gets ignored. + seed_kick_put(registry_, "/items/prepare").tag("Test").summary("Prepare"); + + EXPECT_FALSE(has_error_mentioning(registry_, "request body")); +} + +TEST_F(RouteRegistryTest, PlainBodyLessPutIsStillAskedForARequestBody) { + // The plain body-less PUT is NOT the payload-free shape: its caller reads the + // body itself (PUT /{entity}/data/{data_id} parses free-form JSON by hand). + // Exempting it would blind the check for PUT to exactly the case kept covered + // for POST - a manual-body route that forgets `.request_body(...)`. + seed_body_less_put(registry_, "/items/value").tag("Test").summary("Write value"); + + EXPECT_TRUE(has_error_mentioning(registry_, "request body")); +} + +TEST_F(RouteRegistryTest, PlainBodyLessPutIsSatisfiedByAManualDeclaration) { + // ...and declaring the body by hand, which is what the data route does, is + // what clears it. Without this the test above could be satisfied by a check + // that reports the route no matter what. + seed_body_less_put(registry_, "/items/value") + .tag("Test") + .summary("Write value") + .request_body("Free-form value", json{{"type", "object"}}); + + EXPECT_FALSE(has_error_mentioning(registry_, "request body")); +} + +TEST_F(RouteRegistryTest, BodyLessPostIsStillAskedForARequestBody) { + // The body-less POST overload means the same thing: the handler parses a + // non-JSON body itself (form-urlencoded auth). A missing declaration there is + // a real gap in the document, so the check must stay. + seed_body_less_post(registry_, "/items/token").tag("Test").summary("Token"); + + EXPECT_TRUE(has_error_mentioning(registry_, "request body")); +} + +TEST_F(RouteRegistryTest, TypedPutWithABodyIsStillSatisfiedAutomatically) { + // Guard against the exemption leaking to the overload that does parse a body: + // it declares its schema from TBody, so it must never be reported either. + std::function(TypedRequest, RouteRegistryTestSeedDto)> h = &seed_post_handler; + registry_.put("/items", std::move(h)) + .tag("Test") + .summary("Replace item"); + + EXPECT_FALSE(has_error_mentioning(registry_, "request body")); + EXPECT_TRUE(registry_.to_openapi_paths()["/items"]["put"].contains("requestBody")); +} diff --git a/src/ros2_medkit_gateway/test/test_typed_route_registry.cpp b/src/ros2_medkit_gateway/test/test_typed_route_registry.cpp index a2e0ececf..b5b4da213 100644 --- a/src/ros2_medkit_gateway/test/test_typed_route_registry.cpp +++ b/src/ros2_medkit_gateway/test/test_typed_route_registry.cpp @@ -97,6 +97,7 @@ using ros2_medkit_gateway::http::ResponseAttachments; using ros2_medkit_gateway::http::Result; using ros2_medkit_gateway::http::TypedRequest; using ros2_medkit_gateway::openapi::ErrorRenderer; +using ros2_medkit_gateway::openapi::RouteEntry; using ros2_medkit_gateway::openapi::RouteRegistry; namespace { @@ -309,18 +310,28 @@ TEST(TypedRouteRegistry, AttachmentsApplyStatusAndHeaders) { TEST(TypedRouteRegistry, PostAlternatesPicksStatusFromActiveVariant) { // AltA -> 202 (specialized above), AltB -> 200 (default). + // + // The attachments overload is not incidental here: a 202 alternate makes the + // registry declare a `Location` header on that response, and only this + // overload gives the handler a channel to send one. The non-attachments + // overload rejects the combination at compile time. What this test proves is + // unchanged - the status still comes from the active alternate, not from the + // attachments, which carry nothing. RouteRegistry reg; using VarT = std::variant; - std::function(TypedRequest, TypedRouteTestReq)> handler = - [](TypedRequest /*req*/, const TypedRouteTestReq & body) -> Result { + using PairT = std::pair; + std::function(TypedRequest, TypedRouteTestReq)> handler = + [](TypedRequest /*req*/, const TypedRouteTestReq & body) -> Result { + ros2_medkit_gateway::http::ResponseAttachments att; if (body.greeting == "a") { TypedRouteAltA a; a.a = "alt-a"; - return VarT{a}; + att.with_location("/api/v1/test/alt/a"); + return PairT{VarT{a}, std::move(att)}; } TypedRouteAltB b; b.b = 99; - return VarT{b}; + return PairT{VarT{b}, std::move(att)}; }; reg.post_alternates("/test/alt", std::move(handler)) .tag("Test") @@ -334,6 +345,7 @@ TEST(TypedRouteRegistry, PostAlternatesPicksStatusFromActiveVariant) { auto r = cli.Post("/api/v1/test/alt", req_body.dump(), "application/json"); ASSERT_TRUE(r); EXPECT_EQ(r->status, 202) << "AltA must use the specialized 202 status"; + EXPECT_EQ(r->get_header_value("Location"), "/api/v1/test/alt/a"); auto body = nlohmann::json::parse(r->body); EXPECT_EQ(body["a"], "alt-a"); } @@ -458,3 +470,88 @@ TEST(TypedRouteRegistry, DocsSubtreeRegexRoutes) { EXPECT_EQ(r->status, 200); EXPECT_EQ(r->body, "docs:foo/bar.html"); } + +// ============================================================================= +// 7. binary_download: the Range contract the document now advertises +// ============================================================================= + +namespace { + +// Range-capable download over a fixed in-memory payload. `provider` honours +// offset/length, which is what makes cpp-httplib's range machinery usable. +constexpr std::string_view kDownloadPayload = "0123456789abcdef"; + +Result range_download_handler(TypedRequest /*req*/) { + ros2_medkit_gateway::http::BinaryResponse resp; + resp.content_type = "application/octet-stream"; + resp.filename = "payload.bin"; + resp.supports_ranges = true; + resp.total_size = kDownloadPayload.size(); + resp.provider = [](uint64_t offset, uint64_t length, httplib::DataSink & sink) -> bool { + sink.write(kDownloadPayload.data() + offset, static_cast(length)); + return true; + }; + return resp; +} + +RouteEntry & seed_download(RouteRegistry & reg, const std::string & path) { + std::function(TypedRequest)> h = &range_download_handler; + return reg.binary_download(path, std::move(h)); +} + +} // namespace + +TEST(TypedRouteRegistry, BinaryDownloadSendsAcceptRangesOnAPlainGet) { + // The document declares `Accept-Ranges` on the 200. cpp-httplib only fills it + // in for HEAD, so if the framework ever stops setting it the document starts + // advertising a header nobody sends. + RouteRegistry reg; + seed_download(reg, "/test/blob").tag("Test").summary("Download"); + + auto s = start_server(reg); + httplib::Client cli("127.0.0.1", s.port); + auto r = cli.Get("/api/v1/test/blob"); + ASSERT_TRUE(r); + EXPECT_EQ(r->status, 200); + EXPECT_EQ(r->get_header_value("Accept-Ranges"), "bytes"); + EXPECT_NE(r->get_header_value("Content-Disposition").find("payload.bin"), std::string::npos); + EXPECT_EQ(r->body, std::string(kDownloadPayload)); +} + +TEST(TypedRouteRegistry, BinaryDownloadAnswers206WithContentRangeForARangeRequest) { + // The 206 is not the handler's doing - it never assigns res.status, so + // cpp-httplib picks it from a non-empty req.ranges. This is the wire proof + // behind declaring 206 and `Content-Range` in the document. + RouteRegistry reg; + seed_download(reg, "/test/blob").tag("Test").summary("Download"); + + auto s = start_server(reg); + httplib::Client cli("127.0.0.1", s.port); + auto r = cli.Get("/api/v1/test/blob", {{"Range", "bytes=4-7"}}); + ASSERT_TRUE(r); + EXPECT_EQ(r->status, 206); + EXPECT_EQ(r->body, "4567"); + EXPECT_EQ(r->get_header_value("Content-Range"), "bytes 4-7/16"); + EXPECT_EQ(r->get_header_value("Accept-Ranges"), "bytes"); +} + +TEST(TypedRouteRegistry, BinaryDownloadDeclaresBothSuccessStatusesAndMarksItself) { + // Two 2xx codes are only legitimate because the route says why - the document + // contract test reads `x-medkit-partial-content` to tell this apart from a + // route declaring a status it can never return. + RouteRegistry reg; + seed_download(reg, "/test/blob").tag("Test").summary("Download"); + + auto paths = reg.to_openapi_paths(); + auto & op = paths["/test/blob"]["get"]; + EXPECT_TRUE(op["x-medkit-partial-content"].get()); + EXPECT_FALSE(op.contains("x-medkit-alternates")) << "partial content is not variant dispatch"; + + auto & responses = op["responses"]; + ASSERT_TRUE(responses.contains("200")); + ASSERT_TRUE(responses.contains("206")); + EXPECT_TRUE(responses["200"]["headers"].contains("Accept-Ranges")); + EXPECT_TRUE(responses["200"]["headers"].contains("Content-Disposition")); + EXPECT_TRUE(responses["206"]["headers"].contains("Content-Range")); + EXPECT_FALSE(responses["206"]["description"].get().empty()); +} diff --git a/src/ros2_medkit_integration_tests/test/features/test_fault_triggers_api.test.py b/src/ros2_medkit_integration_tests/test/features/test_fault_triggers_api.test.py index 2cab2654c..8cd48a445 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_fault_triggers_api.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_fault_triggers_api.test.py @@ -115,6 +115,12 @@ def test_04_create_list_fire_delete(self): self.assertEqual(resp.status_code, 201, resp.text) rule = resp.json() self.assertTrue(rule['id']) + # This route is a raw registration, so the Location the document + # declares for its 201 is set by hand - the typed registry's automatic + # declaration cannot reach it. Assert the two agree. + self.assertEqual( + resp.headers.get('Location'), + f'/api/v1/apps/{PLUGIN_APP}/fault-triggers/{rule["id"]}') listed = requests.get(self._url(), timeout=10).json()['items'] self.assertIn(rule['id'], [r['id'] for r in listed]) diff --git a/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py b/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py index 906b38289..afcc33462 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py @@ -252,15 +252,119 @@ def test_no_success_response_is_described_as_no_content(self): self.assertEqual(mislabelled, [], f'mislabelled: {mislabelled}') def test_no_operation_declares_a_status_it_cannot_return(self): - """Multiple 2xx codes only where the handler returns a variant.""" + """Multiple 2xx codes only where a second one is genuinely reachable. + + Exactly two things make a second 2xx reachable, and each declares + itself: a handler returning a ``std::variant`` + (``x-medkit-alternates``), and a range-capable download where the HTTP + layer answers 206 to a ``Range`` request + (``x-medkit-partial-content``). They are separate markers on purpose - + one marker covering both would wave through a route that declares a + status it can never return. + """ offenders = [] for path, method, op in self.operations(): codes = {c for c in op.get('responses', {}) if c.startswith('2')} - if len(codes) < 2 or op.get('x-medkit-alternates'): + if len(codes) < 2: + continue + if op.get('x-medkit-alternates') or op.get('x-medkit-partial-content'): continue offenders.append(f'{op.get("operationId")}: {sorted(codes)}') self.assertEqual(offenders, [], f'phantom success: {offenders}') + def test_partial_content_routes_declare_the_range_response(self): + """A route marked partial-content declares 206 with ``Content-Range``. + + The marker is what lets the rule above accept a second 2xx, so it must + not be usable as a blanket exemption: whatever carries it has to + actually describe the partial response. + """ + marked = [] + for path, method, op in self.operations(): + if not op.get('x-medkit-partial-content'): + continue + marked.append(f'{method.upper()} {path}') + partial = op.get('responses', {}).get('206') + self.assertIsNotNone(partial, f'{method.upper()} {path}: no 206 declared') + self.assertIn( + 'Content-Range', partial.get('headers', {}), + f'{method.upper()} {path}: 206 without Content-Range') + full = op.get('responses', {}).get('200') + self.assertIsNotNone(full, f'{method.upper()} {path}: no 200 declared') + self.assertIn( + 'Accept-Ranges', full.get('headers', {}), + f'{method.upper()} {path}: 200 without Accept-Ranges') + self.assertTrue(marked, 'No partial-content routes in the document') + + def test_every_created_or_accepted_declares_location(self): + """Every 201/202 publishes the `Location` header the handler sets. + + A 201 that does not name the resource it created, and a 202 that does + not name the resource whose status tracks the request, force a + generated client to guess the URI it was just handed. The gateway sets + `Location` on both; the document has to say so. + """ + missing = [] + checked = 0 + for path, method, op in self.operations(): + for code, resp in op.get('responses', {}).items(): + if code not in ('201', '202'): + continue + checked += 1 + if 'Location' not in resp.get('headers', {}): + missing.append(f'{op.get("operationId")}: {code}') + self.assertGreater(checked, 0, 'No 201/202 responses to check') + self.assertEqual(missing, [], f'no Location declared: {missing}') + + def test_created_response_sends_the_location_it_declares(self): + """The declared `Location` reaches the wire, in the documented form. + + Declaring the header is only half the contract - this drives a real + 201 and asserts the header is present and is the absolute, prefixed + path form every `href` in the document uses. + """ + op = self.spec()['paths']['/apps/{app_id}/triggers']['post'] + self.assertIn('Location', op['responses']['201'].get('headers', {})) + resp = requests.post( + f'{self.BASE_URL}/apps/temp_sensor/triggers', + json={ + 'resource': '/api/v1/apps/temp_sensor/faults', + 'trigger_condition': {'condition_type': 'OnChange'}, + 'multishot': True, + }, + timeout=10, + ) + self.assertEqual(resp.status_code, 201, resp.text) + trigger_id = resp.json()['id'] + self.addCleanup( + requests.delete, + f'{self.BASE_URL}/apps/temp_sensor/triggers/{trigger_id}', + timeout=10, + ) + self.assertEqual( + resp.headers.get('Location'), + f'/api/v1/apps/temp_sensor/triggers/{trigger_id}', + ) + + def test_shipped_route_set_declares_complete_metadata(self, proc_output): + """The gateway's own route-metadata check finds nothing on this fixture. + + `RouteRegistry::validate_completeness()` catches what the document + contract cannot see from the served JSON alone: `errors()` handed a + sub-400 status, `response_header()` aimed at a status no response + declares, a route registered without a tag or without a success schema. + The gateway runs it at start-up and logs a summary; without this + assertion that summary is a line nobody reads, which is the same defect + as collecting the issues and discarding them. + + This fixture launches every optional feature gate, so the route set + under test is the maximal one - a route added behind any gate is + covered here. + """ + # The `check: ` prefix is load-bearing - matching a bare `0 error(s)` + # would also match `10 error(s)` and pass on a broken route set. + proc_output.assertWaitFor('OpenAPI metadata check: 0 error(s)', timeout=20) + def test_every_ref_resolves(self): """No $ref points at a component the document does not define.""" spec = self.spec() diff --git a/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_bulk_data_upload.test.py b/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_bulk_data_upload.test.py index b0a5d4fb0..b07f9a318 100644 --- a/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_bulk_data_upload.test.py +++ b/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_bulk_data_upload.test.py @@ -350,6 +350,11 @@ def test_17_download_uploaded_file(self): # Check Content-Disposition cd = dl_r.headers.get('Content-Disposition', '') self.assertIn('hello.txt', cd) + # The download is served through a range-aware content provider, so it + # advertises the unit it accepts (RFC 9110 14.3). cpp-httplib only fills + # this in for HEAD, so a missing value means the framework stopped + # setting it and the OpenAPI document now advertises a header we drop. + self.assertEqual(dl_r.headers.get('Accept-Ranges'), 'bytes') def test_18_download_nonexistent_returns_404(self): """GET download with fake ID returns 404. From afa2dadee70b4dac84e5be5a9661802830d17a03 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Wed, 5 Aug 2026 08:43:02 +0200 Subject: [PATCH 05/17] feat(gateway): publish which writes take part in locking, and what a non-JSON route returns lock_guarded() marks the writes that answer 409 to a client without the lock, and the expected set is pinned so dropping a marker turns the suite red. Binary downloads stop emitting format: binary, which is not valid in OpenAPI 3.1, and the schema-less exemption now requires a non-JSON media type. The 416 cpp-httplib answers before routing is declared where it is reachable. --- docs/api/locking.rst | 86 ++++ docs/api/rest.rst | 177 +++++++- src/ros2_medkit_gateway/CMakeLists.txt | 12 + .../design/aggregation.rst | 33 +- .../design/dto_contract.rst | 297 ++++++++++++- .../core/http/handlers/bulkdata_handlers.hpp | 23 + .../http/parameter_error_classification.hpp | 11 + .../http/detail/status_recorder.hpp | 147 +++++++ .../http/handlers/handler_support.hpp | 19 + .../http/parameter_error_classification.cpp | 50 +++ .../src/core/openapi/route_registry.cpp | 346 +++++++++++++-- .../src/http/handlers/bulkdata_handlers.cpp | 9 + .../src/http/rest_server.cpp | 289 ++++++++++-- .../src/openapi/route_registry.hpp | 106 ++++- .../src/openapi/schema_builder.cpp | 4 - .../src/openapi/schema_builder.hpp | 3 - .../test/test_lock_manager.cpp | 60 +++ .../test/test_route_registry.cpp | 373 ++++++++++++++++ .../test/test_typed_route_registry.cpp | 6 +- .../gateway_test_case.py | 44 ++ .../test/features/test_health.test.py | 17 +- .../features/test_openapi_contract.test.py | 319 ++++++++++++++ .../test_openapi_error_coverage.test.py | 413 ++++++++++++++++++ .../test_scenario_bulk_data_download.test.py | 35 ++ .../test_scenario_bulk_data_upload.test.py | 102 +++++ 25 files changed, 2891 insertions(+), 90 deletions(-) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/detail/status_recorder.hpp create mode 100644 src/ros2_medkit_integration_tests/test/features/test_openapi_error_coverage.test.py diff --git a/docs/api/locking.rst b/docs/api/locking.rst index cec70ed3c..332fad41a 100644 --- a/docs/api/locking.rst +++ b/docs/api/locking.rst @@ -147,6 +147,92 @@ Release a lock. Requires ``X-Client-Id`` header (must be lock owner). **Response:** 204 No Content +.. _locking-blocked-operations: + +Which Operations a Lock Blocks +------------------------------ + +The five endpoints above manage locks. A lock only means something because +*other* endpoints honour it: every write below reads the caller's +``X-Client-Id`` and answers ``409`` when the entity's collection is held by a +different client. Sending no ``X-Client-Id`` makes the caller anonymous - the +write succeeds while nothing is locked and is refused once something is - so +the header is optional on these routes, not required. + +Each row applies to all four entity types (``areas``, ``components``, ``apps``, +``functions``) except bulk-data, which only exists for ``components`` and +``apps``. + +.. list-table:: + :header-rows: 1 + :widths: 45 20 35 + + * - Endpoint + - Lock scope + - Operation IDs + * - ``PUT /{entity}/data/{data_id}`` + - ``data`` + - ``put{Area,Component,App,Function}DataItem`` + * - ``POST /{entity}/operations/{id}/executions`` + - ``operations`` + - ``execute{...}Operation`` + * - ``PUT /{entity}/operations/{id}/executions/{exec_id}`` + - ``operations`` + - ``update{...}Execution`` + * - ``DELETE /{entity}/operations/{id}/executions/{exec_id}`` + - ``operations`` + - ``cancel{...}Execution`` + * - ``PUT /{entity}/configurations/{config_id}`` + - ``configurations`` + - ``set{...}Configuration`` + * - ``DELETE /{entity}/configurations/{config_id}`` + - ``configurations`` + - ``delete{...}Configuration`` + * - ``DELETE /{entity}/configurations`` + - ``configurations`` + - ``deleteAll{...}Configurations`` + * - ``DELETE /{entity}/faults/{fault_code}`` + - ``faults`` + - ``clear{...}Fault`` + * - ``DELETE /{entity}/faults`` + - ``faults`` + - ``clearAll{...}Faults`` + * - ``PUT /{entity}/logs/configuration`` + - ``logs`` + - ``set{...}LogConfiguration`` + * - ``POST /{entity}/bulk-data/{category_id}`` + - ``bulk-data`` + - ``upload{Component,App}BulkData`` + * - ``DELETE /{entity}/bulk-data/{category_id}/{file_id}`` + - ``bulk-data`` + - ``delete{Component,App}BulkData`` + +Every one of these operations carries ``x-medkit-lock-guarded: true`` in the +generated OpenAPI document, alongside the ``X-Client-Id`` parameter and the +``409`` response, so a generated client can select the lock-participating +surface without pattern-matching on paths. + +The marker is applied per route at registration time, not inferred from the +handler. It is pinned by +``test_openapi_contract.test.py::test_lock_guarded_set_matches_the_handlers`` +against a hand-maintained list, which catches the document losing a marker but +cannot catch a *new* lock-checking handler that was never added to the list. +Adding a lock check to a handler means updating that list too. + +The One Exception: Global Fault Clear +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``DELETE /api/v1/faults`` reads ``X-Client-Id`` like the writes above but never +answers ``409``. It walks every fault, **skips** the ones whose reporting +entity is locked by another client, clears the rest, and answers ``204``. +Nothing on the response says which faults were skipped - the +``X-Medkit-Local-Only: true`` header that 204 also carries is set +unconditionally and reports that aggregated *peers* were not cleared, not that +a lock intervened. A caller who needs to know re-reads the entity's faults to +see what survived. The operation declares ``X-Client-Id`` but carries no +``x-medkit-lock-guarded`` marker, because it cannot return the ``409`` the +marker implies. + Error Responses --------------- diff --git a/docs/api/rest.rst b/docs/api/rest.rst index 9636d65ff..85406293e 100644 --- a/docs/api/rest.rst +++ b/docs/api/rest.rst @@ -13,6 +13,49 @@ All endpoints are prefixed with ``/api/v1``. :local: :depth: 2 +Client Request Headers +---------------------- + +Two headers a client may send are read across many endpoints rather than +belonging to one. Both are optional, and both are declared per-operation in the +generated OpenAPI document, so a generated client sees them on exactly the +operations that read them. + +``X-Client-Id`` + Identifies the calling client for :doc:`resource locking `. Read by + every lock-participating write - those operations also carry + ``x-medkit-lock-guarded: true`` and declare a ``409``, and + :ref:`locking-blocked-operations` lists them. While a lock protects an + entity's collection, only the client holding it may write; every other + caller, including one that sends no ``X-Client-Id``, is answered ``409``. + The lock endpoints themselves also read it: ``POST``/``PUT``/``DELETE`` + ``/locks`` require it, and the two ``GET`` routes use it only to fill in the + ``owned`` field. + + ``DELETE /api/v1/faults`` is the one route that reads it without ever + answering ``409``; it silently skips faults on entities locked by another + client and still answers ``204``. Nothing on the response reports the skip - + ``X-Medkit-Local-Only`` is about aggregated peers, not locks - so re-read the + entity's faults to see what survived. + +``X-Medkit-No-Fan-Out`` + Answer from this gateway alone: do not query aggregated peers and do not + merge their items. Read by the **per-entity** resource-collection list + endpoints (data, operations, configurations, faults, logs) and by ``GET + /api/v1/version-info`` - exactly the operations that declare it in the + OpenAPI document. + + The gateway sets it on its own outbound peer requests, which stops + bidirectional aggregation from recursing **on the routes that check it**. + The global ``GET /api/v1/faults`` does not check it, so it neither declares + the header nor honours it (see :ref:`the fan-out design note + `). + + **Presence-only.** The value is never read, so ``X-Medkit-No-Fan-Out: + false`` suppresses fan-out exactly like any other value. The OpenAPI schema + is a string rather than a boolean for that reason. Omit the header to get + the aggregated answer. + Server Capabilities ------------------- @@ -1223,15 +1266,32 @@ Download a specific bulk-data file. **Response Headers:** -- ``Content-Type``: ``application/x-mcap`` (MCAP format) or ``application/x-sqlite3`` (db3) +- ``Content-Type``: the media type of the stored artifact - see below - ``Content-Disposition``: ``attachment; filename="FAULT_CODE.mcap"`` - ``Accept-Ranges``: ``bytes`` - the download is served by a range-aware provider, so a client may fetch part of the file - ``Access-Control-Expose-Headers``: ``Content-Disposition`` -A request carrying a satisfiable ``Range`` header is answered with **206 -Partial Content** and a ``Content-Range: bytes -/`` header -instead of ``200``; the body is the requested slice. +**Media types.** The OpenAPI document declares +``application/x-mcap``, ``application/x-sqlite3`` and +``application/octet-stream`` for this response, followed by ``*/*``. That is +not hedging: for the ``rosbags`` category the type is derived from the +recorded storage format and is one of the three named types, but every other +category serves back the media type recorded when the file was uploaded, which +is chosen by the uploading client. Uploading a ``text/csv`` makes the download +serve ``text/csv``. The named types are declared because they *are* +derivable; ``*/*`` is declared because the rest genuinely is not. + +There is no response schema, for either status. The body is raw file content, +and OpenAPI 3.1 has no way to say "bytes" - ``format: binary`` was an OpenAPI +3.0 idiom that 3.1 dropped when it aligned with JSON Schema 2020-12. A +schema-free media type entry is the accurate description. + +**Range requests.** A request carrying a ``Range`` header is answered with +**206 Partial Content** and a ``Content-Range: bytes -/`` +header instead of ``200``; the body is the requested slice. Several ranges in +one request are answered as a single ``multipart/byteranges`` body, which is +declared on the 206 only - the 200 can never carry it. **Example:** @@ -1239,11 +1299,17 @@ instead of ``200``; the body is the requested slice. curl -O -J http://localhost:8080/api/v1/apps/motor_controller/bulk-data/rosbags/550e8400-e29b-41d4-a716-446655440000 + # One byte range + curl -H 'Range: bytes=0-1023' \ + http://localhost:8080/api/v1/apps/motor_controller/bulk-data/rosbags/550e8400-e29b-41d4-a716-446655440000 + **Response Codes:** - **200 OK**: File content - **206 Partial Content**: The byte range requested via ``Range``, with ``Content-Range`` - **404 Not Found**: Entity, category, or bulk-data ID not found +- **416 Range Not Satisfiable**: The ``Range`` header could not be parsed. Not + specific to this endpoint - see :ref:`rest-range-rejection`. Upload Bulk Data ~~~~~~~~~~~~~~~~ @@ -1637,6 +1703,48 @@ Upload, manage, and execute diagnostic scripts on entities. Scripts are available on **Components** and **Apps** entity types. The feature must be enabled by setting ``scripts.scripts_dir`` in the gateway configuration. +Script Error Statuses +~~~~~~~~~~~~~~~~~~~~~ + +Beyond the usual 400 / 404 / 500, and 501 on every script endpoint when no +scripts backend is configured, each endpoint answers only what its own backend +call can produce. With the built-in backend: + +.. list-table:: + :header-rows: 1 + :widths: 45 55 + + * - Endpoint + - Extra statuses + * - ``POST .../scripts`` (upload) + - **413** ``script-file-too-large`` - file over the configured size limit + * - ``DELETE .../scripts/{script_id}`` + - **409** ``script-managed`` (manifest-owned, not editable) or + ``script-running`` + * - ``POST .../scripts/{script_id}/executions`` + - **429** ``script-concurrency-limit`` + * - ``PUT .../executions/{execution_id}`` + - **409** ``script-not-running`` + * - ``DELETE .../executions/{execution_id}`` + - **409** ``script-running`` + +The listing and read endpoints (``GET .../scripts``, +``GET .../scripts/{script_id}``, ``GET .../executions/{execution_id}``) add +nothing to the blanket set. + +The 429 is the **script manager's** concurrency limit, not the HTTP rate +limiter's: it is answered whether or not ``rate_limiting.enabled`` is set, and +carries no ``Retry-After`` or ``X-RateLimit-*`` headers. See +:ref:`rate-limiting` for the other 429. When the limiter is on, both can answer +429 on the execution-start route and the document can only describe one: the +route's own declaration wins, so that operation's 429 is documented as the +script manager's, without the limiter's headers. The body shape is the same +either way. + +``script-already-exists`` is defined for backends that maintain their own +registry (a plugin with a SQLite store, say); the built-in backend generates +ids and never returns it. + Upload Script ~~~~~~~~~~~~~ @@ -2136,6 +2244,8 @@ way as every other endpoint. the correlation cascade is skipped so the clear stays scoped to the rule's own fault. +.. _rate-limiting: + Rate Limiting ------------- @@ -2550,6 +2660,65 @@ Common Error Codes configured cap. Tune via ``data_provider.cold_wait_cap`` and ``data_provider.max_parallel_samples`` if this fires under normal load. +.. _rest-range-rejection: + +Range Rejection (416) +~~~~~~~~~~~~~~~~~~~~~ + +Every operation in the OpenAPI document declares **416 Range Not Satisfiable**, +including operations that have nothing to do with file downloads. This is not +over-declaration. The HTTP layer parses the ``Range`` header before routing the +request, so a syntactically invalid ``Range`` is rejected before any handler +runs - on any path, including paths that do not exist: + +.. code-block:: bash + + $ curl -i -H 'Range: furlongs=1-2' http://localhost:8080/api/v1/health + HTTP/1.1 416 Range Not Satisfiable + +The body is the usual ``GenericError`` shape, which is why the document +declares it as such rather than as a body-less response: the HTTP layer itself +writes 416 with an empty body, and the gateway's global error handler then +fills any body-less error response with a ``GenericError``. + +Only the six bulk-data download routes declare a ``Range`` *request* parameter, +because they are the only routes where sending one is useful. 416 is +nevertheless reachable everywhere. + +416 is not the only status answered this way, and the ``error_code`` in the +body it produces is a placeholder - see the next section. + +.. _rest-framework-error-bodies: + +Framework-Produced Error Bodies +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Some errors are answered by the HTTP layer itself, before any gateway handler +runs and sometimes before routing. cpp-httplib produces these with an **empty +body**, and the gateway's global error handler then fills any body-less error +response with a ``GenericError`` so that clients always receive the same +envelope. Statuses reaching a client this way include: + +- **400** - malformed request line or headers, or an unparseable + ``multipart/form-data`` boundary +- **413** - a form-urlencoded payload over the built-in length cap +- **414** - request URI too long +- **416** - unparseable ``Range`` header (see :ref:`rest-range-rejection`) +- **500** - an exception escaping a handler + +**The ``error_code`` on these bodies is a placeholder.** The global handler +writes ``resource-not-found`` regardless of the actual status, because it runs +after the fact and has no way to know why the HTTP layer rejected the request. +So a 413 and a 416 both arrive carrying ``"error_code": +"resource-not-found"``. (The 404 an unrouted request produces goes through the +same path, where that code happens to be right - which is why the mismatch is +easy to miss on the statuses above.) + +**Read the HTTP status, not the ``error_code``, whenever the status was not +produced by a handler.** The codes listed under `Common Error Codes`_ are +accurate only for errors the gateway itself raises. The ``parameters.status`` +field on these bodies repeats the real status, which is the reliable field. + Plugin Entity Delegation ~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/ros2_medkit_gateway/CMakeLists.txt b/src/ros2_medkit_gateway/CMakeLists.txt index 0d887ad71..f1a3e3034 100644 --- a/src/ros2_medkit_gateway/CMakeLists.txt +++ b/src/ros2_medkit_gateway/CMakeLists.txt @@ -53,6 +53,18 @@ find_package(OpenSSL REQUIRED) # This enables the httplib::SSLServer class for HTTPS support add_compile_definitions(CPPHTTPLIB_OPENSSL_SUPPORT) +# Emitted-status recorder (include/ros2_medkit_gateway/http/detail/status_recorder.hpp). +# Instruments make_error() and every registry-mounted route so +# test_openapi_error_coverage can assert the OpenAPI document declares every +# status the gateway actually serves. Directory scope and set before any +# target is created, because make_error() is an inline function shared by 13 +# translation units - a per-target definition would give it two bodies and +# break ODR. A shipped gateway compiles none of it: the Dockerfile builds with +# -DBUILD_TESTING=OFF. +if(BUILD_TESTING) + add_compile_definitions(MEDKIT_STATUS_RECORDER) +endif() + # Vendored header-only libraries (no network access on ROS build farm) # tl::expected v1.3.1 (CC0) - https://github.com/TartanLlama/expected add_library(tl_expected_iface INTERFACE) diff --git a/src/ros2_medkit_gateway/design/aggregation.rst b/src/ros2_medkit_gateway/design/aggregation.rst index 388a1229a..e5e5e1c44 100644 --- a/src/ros2_medkit_gateway/design/aggregation.rst +++ b/src/ros2_medkit_gateway/design/aggregation.rst @@ -414,15 +414,36 @@ maps to a peer, the request is forwarded transparently: populated during periodic cache refresh cycles that fetch entities from all healthy peers. +.. _aggregation-fan-out: + **Per-entity resource collections** (data, operations, faults, configurations, -logs) and the global ``GET /api/v1/faults`` endpoint use real-time **fan-out** -via ``fan_out_get()`` (handlers call the ``merge_peer_items()`` helper from -``fan_out_helpers.hpp``): the primary gateway sends the same request to all +logs) use real-time **fan-out** via ``fan_out_get()``, reached through the +``merge_peer_items()`` / ``fan_out_collection()`` helpers in +``fan_out_helpers.hpp``: the primary gateway sends the same request to all healthy peers, collects the responses, and merges the ``items`` arrays. If some peers fail, the response body includes ``x-medkit.partial: true`` and -``x-medkit.failed_peers``. Fan-out requests include an -``X-Medkit-No-Fan-Out`` header to prevent recursive loops when peers have -bidirectional aggregation. +``x-medkit.failed_peers``. Fan-out requests carry an ``X-Medkit-No-Fan-Out`` +header, and both helpers return early when the incoming request has it, so a +peer that aggregates back never fans out a second time. These are the routes +that declare the header in the OpenAPI document +(``RouteEntry::fan_out_aware()``). + +.. warning:: + + The global ``GET /api/v1/faults`` is **not** one of them. + ``FaultHandlers::list_all_faults`` calls ``fan_out_get()`` directly rather + than through either helper, so it never inspects ``X-Medkit-No-Fan-Out`` - + and ``fan_out_get()`` is the code that *sets* the header outbound. Nothing + else in ``aggregation/`` guards the loop. Two gateways that peer with each + other therefore recurse unbounded on this one route: A queries B, B queries + A, and each hop holds a ``std::async`` thread until its timeout, so the + thread cost grows with recursion depth. + + The route consequently does not declare the header either - advertising an + opt-out it ignores would be worse than silence. Fixing this means routing + ``list_all_faults`` through the helper (which changes the per-item wire + shape it deliberately preserves) or adding a loop guard inside + ``fan_out_get()``; both are aggregation changes, not documentation ones. **Target-filtered fan-out.** For per-entity paths, ``merge_peer_items()`` asks ``AggregationManager::get_peer_contributors(id)`` for the list of diff --git a/src/ros2_medkit_gateway/design/dto_contract.rst b/src/ros2_medkit_gateway/design/dto_contract.rst index 1eaa92d22..cb6ce8f90 100644 --- a/src/ros2_medkit_gateway/design/dto_contract.rst +++ b/src/ros2_medkit_gateway/design/dto_contract.rst @@ -87,7 +87,7 @@ erasure, and no separate code-generation step are needed. + post_alternates(path, handler) + del_alternates(path, handler) + sse(path, factory) - + binary_download(path, handler) + + binary_download(path, handler, media_types) + multipart_upload(path, handler) + static_asset(path, handler) + docs_endpoint(path, handler) @@ -276,7 +276,7 @@ survivors merged in. No runtime loop over a dynamic registry is required. The hand-written schema factories that remain on ``SchemaBuilder`` - ``from_ros_msg`` / ``from_ros_srv_request`` / ``from_ros_srv_response`` (for dynamic ROS 2 payloads whose field names are not known at compile time) and -``binary_schema`` / ``generic_object_schema`` - are no longer part of the +``generic_object_schema`` - are no longer part of the ``components/schemas`` map. They are called by the path builder (``src/openapi/path_builder.cpp``) to emit *inline* operation schemas for the per-topic / per-service / per-action routes, whose request and response shape is @@ -446,8 +446,11 @@ status it cannot return. Nothing else may set that marker. There is exactly one other way a second 2xx is reachable, and it carries its own marker rather than reusing that one. ``reg.binary_download`` handlers never assign a status, so cpp-httplib answers 200 or **206 Partial Content** -depending on whether the request carried a satisfiable ``Range`` - it also -fills in ``Content-Range``. The helper therefore declares both statuses and +depending on whether the request carried a ``Range`` at all - it also fills in +``Content-Range``. Note "at all", not "a satisfiable one": a ``Range`` that +parses but asks for bytes past the end of the file still yields 206, and one +that does not parse is rejected with 416 before routing, so by the time this +decision is made every surviving ``Range`` is a parseable one. The helper therefore declares both statuses and calls ``RouteEntry::mark_partial_content()``, publishing ``x-medkit-partial-content: true``. Two markers, not one: there the handler chooses between variant members, here the handler returns one thing and the @@ -455,12 +458,59 @@ HTTP layer decides how to frame it. A single marker covering both would let the contract test wave through a route that declares a status it can never return. Nothing outside ``binary_download`` may set it. -Two further ``RouteEntry`` knobs shape the published response set: +Media types +~~~~~~~~~~~ + +A response declared through the JSON overloads is published under +``application/json``, with the content entry omitted entirely when the schema +is empty - that is what keeps a 204 body-less rather than giving it a +``schema: null``. + +Non-JSON bodies use the four-argument overload +``response(status, desc, schema, content_types)``. Each media type becomes its +own ``content`` entry holding an **empty** Media Type Object. The missing +schema is the declaration, not an omission, for two independent reasons: + +- ``{"type": "string", "format": "binary"}`` is an OpenAPI 3.0 idiom. 3.1 + aligned with JSON Schema 2020-12, where ``format: binary`` carries no + meaning and ``type: string`` actively misdescribes raw bytes. +- The SSE families emit three different frame shapes, so any single schema + would be wrong for two of them. + +The ``schema`` argument exists only for signature symmetry and must be empty; a +caller that passes one has it dropped rather than attached to a media type it +may not describe, and the miscall is reported by ``validate_completeness()``. + +Both completeness gates - ``validate_completeness()`` and the served-document +check in ``test_health::test_docs_spec_completeness`` - treat a 2xx carrying a +non-JSON media type as complete without a schema. That replaced an older rule +that exempted a route when its *summary* contained "SSE" or "stream", so the +exemption now follows what a route declares rather than what it is named. + +**Open media-type sets.** Where the served type is not enumerable the +declaration says so, by listing the derivable types *and* ``*/*``. The +bulk-data download is the case in the tree: +``BulkDataHandlers::download_media_types()`` names the three types +``get_rosbag_mimetype()`` can return, then ``*/*`` for the store-backed +categories, which serve back whatever media type the uploading client put on +its multipart part. Declaring only the concrete types would under-declare the +route; declaring only ``*/*`` would throw away the half that is derivable. + +That declaration is checked against a run, not a review. The integration suite +downloads real artifacts and asserts the served ``Content-Type`` against the +document, distinguishing the two halves: a rosbag type must match a **named** +content key (matching only via ``*/*`` fails), while a client-supplied type may +match via the catch-all, which the test also asserts is present. Both +directions have been shown to fail on an injected defect. + +Further ``RouteEntry`` knobs shape the published operation: - ``errors({409, 423})`` - declare error statuses this route can emit beyond the blanket set; each is rendered as a ``GenericError`` response ``$ref``. Statuses below 400 are ignored and reported by ``validate_completeness()``, - because a success status belongs in the return type, not here. + because a success status belongs in the return type, not here. This is the + one knob whose completeness is checked against a *run* rather than a review - + see `Emitted-status recorder`_ below. - ``only_status(code, desc)`` - this route has exactly one outcome. Clears every other response and suppresses the blanket 400/404/500 injection. The auth 401/403 refs stay when authentication is enabled: they come from the @@ -507,9 +557,55 @@ Two further ``RouteEntry`` knobs shape the published response set: because its own backend is unconfigured, e.g. ``LockHandlers`` without a lock manager - are declared with plain ``errors({501})`` until a handler-level seam exists. +- ``lock_guarded()`` - this route takes part in entity locking. One call + publishes all three halves of that contract: the ``X-Client-Id`` request + header the handler reads, the 409 it answers when the entity's collection is + locked by a different client, and an ``x-medkit-lock-guarded: true`` operation + extension. The header is declared **optional** on purpose - a caller that + sends none is an anonymous client, which succeeds while nothing is locked and + is refused once something is; declaring it required would describe a gateway + that rejects the header-less request outright, which is not what happens. + + Unlike everything else in this section, **this one is declared and not + derived, and its test only checks half of it.** The header read that decides + the 409 lives in ``HandlerContext::validate_lock_access``, which 12 handlers + across 6 files call, and the document is regenerated per ``/docs`` request + rather than captured at registration time - so a registration cannot see + through that call, and no accessor on ``TypedRequest`` changes that. + ``test_openapi_contract.test.py::test_lock_guarded_set_matches_the_handlers`` + pins the marked set against ``EXPECTED_LOCK_GUARDED``, a hand-maintained + literal committed next to the test. That catches the **document** drifting + away from the list: dropping a ``.lock_guarded()`` from a registration turns + the suite red. It does **not** catch the list drifting away from the + handlers - a new route that calls ``validate_lock_access`` and forgets both + the decorator and the list entry passes every gate. Adding a lock check to a + handler means editing that list by hand. + + Two companion tests keep the marker from degenerating into a label: + ``test_lock_guarded_routes_declare_the_contract`` asserts every marked + operation really publishes the header and the 409, and + ``test_lock_guarded_route_answers_the_409_it_declares`` drives a real locked + write through the gateway so the declared status is one the wire returns. + + ``DELETE /faults`` is the deliberate near-miss: it reads ``X-Client-Id`` like + every lock-guarded write but never answers 409 - it *skips* faults on + entities locked by somebody else and still answers 204. Nothing on the + response says which ones were skipped; ``X-Medkit-Local-Only`` on that 204 is + about aggregated peers, not locks. It therefore declares the header with its + own prose via ``header_param`` and does not call ``lock_guarded()``; marking + it would publish a status it cannot return. +- ``fan_out_aware()`` - this route reads the ``X-Medkit-No-Fan-Out`` request + header, i.e. a client can ask it to answer from this gateway alone instead of + merging aggregated peers. Carried by the routes whose handlers go through + ``fan_out_collection`` or ``merge_peer_items``. The declared schema is a bare + string, not a boolean: the gateway tests ``has_header`` and never reads the + value, so ``X-Medkit-No-Fan-Out: false`` still suppresses fan-out and a + boolean schema would promise a generated client the opposite. Also + hand-applied, with the same caveat as ``lock_guarded()`` above. Every self-check named above (``errors()`` handed a sub-400 status, -``response_header()`` aimed at an undeclared status, a route with no tag or no +``response_header()`` aimed at an undeclared status, a ``lock_guarded()`` marker +whose 409 a later ``only_status()`` cleared, a route with no tag or no success schema) reports through ``RouteRegistry::validate_completeness()``, and ``RESTServer::report_route_metadata_issues()`` calls it once at start-up and logs what it finds. That call is what makes "reported" mean something: before @@ -542,7 +638,33 @@ can send shapes ``DataWriteRequest`` does not describe; ``/auth/*`` parses form-urlencoded), so a missing ``.request_body(...)`` there is a real gap the check must keep reporting. -Three statuses never reach a handler at all: the auth middleware answers 401 +A fourth status never reaches a handler either, and it is the only one gated on +nothing: **416**. cpp-httplib parses the ``Range`` header in +``Server::process_request``, before routing, and rejects an unparseable one +outright - on any path, including paths that do not exist. It is therefore +declared on every operation, next to the blanket 400/404/500, rather than on +the six download routes where sending a ``Range`` is *useful*. Those six carry +the ``Range`` request *parameter*; the status itself is universal. + +It is declared as a ``GenericError`` ``$ref`` like the other error statuses, +and getting there needs both halves of the picture: cpp-httplib writes 416 with +an empty body, and ``RESTServer::setup_global_error_handlers`` then fills any +body-less error response with a ``GenericError``. Reading only the vendored +header suggests a body-less response and would have published one - the wire +assertion in +``test_openapi_contract.test.py::test_range_rejection_is_answered_on_a_route_that_declares_it`` +is what settles it, deliberately against ``/health`` rather than a download so +the universality is the thing being proven. + +Unlike the limiter's 429 there is no configuration knob to gate it on, and +unlike a handler status the emitted-status recorder cannot observe it - no +handler runs. It is therefore a framework-level constant verified by +``RouteRegistryTest.EveryDocumentedRouteDeclaresTheFrameworkAnsweredRangeRejection`` +plus that wire test, not by a recorded run. It also sits outside +``only_status()``, for the same reason 401/403 do: that knob constrains what +the *handler* can return. + +Three further statuses never reach a handler: the auth middleware answers 401 and 403, and the rate limiter answers 429, both ahead of routing. No return type can describe them and no ``RouteEntry`` can carry their headers, so they are declared once as the shared component responses ``Unauthorized`` (carrying @@ -552,6 +674,143 @@ Routes reference them - 401/403 when ``set_auth_enabled(true)``, 429 when ``set_rate_limit_enabled(true)`` - so the document mentions a middleware status exactly when that middleware is live. +The 429 gate covers the **rate limiter's** 429 and nothing else. A handler can +answer 429 for a reason of its own - the script manager's concurrent-execution +limit is the one that exists today - and that one is reachable whether or not +``rate_limiting.enabled`` is set, so it is declared on its route with +``errors({429})`` like any other handler status. Reading the two as one status +makes the coverage rule below unsatisfiable: with the limiter off, the document +would have to both omit 429 (no limiter) and declare it (the execution-start +route answers it). + +Where a route declares a status the middleware also owns, the route wins: +``add_response_ref`` is first-wins and the ``errors()`` loop runs first. So the +execution-start route publishes its own ``GenericError`` 429 rather than the +``RateLimited`` component, and loses that component's ``Retry-After`` and +``X-RateLimit-*`` headers; a lifecycle route that declares 403 shadows +``Forbidden`` the same way. OpenAPI allows one response object per status, so +one description has to lose, and the route-specific one is the more useful. +The body shape is unaffected - all three components reference the same +``GenericError`` schema - so what is lost is the header list and the prose. +Pinned by ``RouteRegistryTest.RouteDeclaredStatusWinsOverTheMiddlewareComponent`` +so the precedence is a decision on record rather than an accident of statement +order. + +A fourth gate, ``set_aggregation_enabled(bool)``, works the same way for peer +federation. When an entity turns out to belong to a peer, the request is +proxied from inside ``validate_entity_for_route``, and the statuses the gateway +itself writes there are 502 (peer unknown, unreachable, or its response over the +size cap) and 503 (this gateway is shutting down and refuses to forward). They +are declared on entity-scoped routes only - the entity id has to come from the +path for the lookup to happen at all - and only when aggregation is on, because +``aggregation.enabled`` defaults false and the ``AggregationManager`` is only +constructed when it is set, so with it off no entity can be remote. The gate +reads the manager pointer rather than the parameter, so it cannot drift from the +branch it describes. Unlike the middleware refs it also respects +``only_status``: the forward happens *inside* the handler, so a route that +declares itself single-outcome genuinely cannot reach it. + +What is **not** declared there is the status a healthy peer returns, which is +copied through verbatim. No finite ``errors({...})`` describes "whatever the +peer said", and choosing what the document should promise is an +aggregation-contract question rather than a documentation one. + +.. _emitted-status-recorder: + +Emitted-status recorder +----------------------- + +Everything above is a *declaration*. ``errors()``, ``response_header()``, +``lock_guarded()`` - each is something a person typed next to a registration, +and each can fall behind the handler it describes without any test noticing. A +new ``make_error(503, ...)`` in a handler nobody re-reads is invisible to every +check in this document. + +The recorder is the one mechanism that notices, and it maintains no list. +``include/ros2_medkit_gateway/http/detail/status_recorder.hpp`` compiles - in +test builds only, gated on ``MEDKIT_STATUS_RECORDER``, which +``CMakeLists.txt`` sets exactly when ``BUILD_TESTING`` is on - two observers: + +- ``StatusRecordingScope``, installed by ``RouteRegistry::register_all`` around + every mounted handler, which records ``(method, OpenAPI templated path, + status)`` for the status that actually reached ``httplib::Response``. It is + installed at the mounting point because that is the only place that knows + both the route's identity and everything the route can answer. This is the + authoritative half: it sees the status the client receives, including one no + ``make_error()`` built (a peer-forwarded status, a raw ``res.status`` write, + the entity-not-found 404 that comes from ``validate_entity_for_route``). +- a call in ``make_error()`` that records the ``file:line`` of each error + construction, which is what lets a run report *how much* of the ~281-site + error surface it exercised rather than implying it saw all of it. + +The two halves are deliberately not joined. The scope carries the route +identity as its own member, so nothing travels out-of-band, and +``make_error()``'s hook is route-agnostic - it contributes to a site set, not to +the ``(route, status)`` set the assertion reads. The consequence worth stating: +``make_error()`` touches no thread-local storage at all, which matters because +it is an ``inline`` header function whose out-of-line copy lands in +``gateway_ros2``, linked into six MODULE targets. Route-attributing the sites +would need an ambient carrier, and that carrier would have to be a +namespace-scope ``extern thread_local`` (the ``tl_forward_response`` pattern), +never a function-local ``static thread_local``, which compiles to initial-exec +TLS a shared object cannot relocate. Not needing it is the stronger position, +and the wire-status set is strictly more accurate than route-attributed +construction sites would be. + +``test_openapi_error_coverage.test.py`` drives the whole documented surface into +its error branches - every parameterised operation called with an absent id, +with a malformed id, and (where a trailing absent id makes the call safe) with a +real leading entity - then asserts **declared is a superset of observed**. The +sweep is derived from the served document, so a route added tomorrow is swept +tomorrow, and its companion assertions stop the rule passing vacuously: the only +operations allowed to go unreached are state-changing verbs on parameterless +paths, and at least one observed status must be outside the blanket set. + +What the recorder cannot see has to be declared by hand, and that is the whole +list: + +- anything answered ahead of routing - the rate limiter's 429, the auth + middleware's 401/403, the CORS reject, the OPTIONS pre-flight; +- anything cpp-httplib answers itself - 404/405 for an unrouted request, 413 + over ``set_payload_max_length``, 416 for an **unparseable** ``Range`` (an + unsatisfiable-but-parseable one yields 206, not 416); +- routes mounted straight onto the server rather than through the registry + (``/docs``, the Swagger UI subtree); +- statuses on branches no test run drives - a provider that reports + ``AccessDenied``, a fault store that cannot be read, an update already in + flight. These are the ``errors({...})`` calls in ``rest_server.cpp`` that + carry a "the recorder cannot reach it" comment. + +Fourteen ``make_error`` sites pass a **computed** status rather than a literal, +and they split into two classes that get opposite treatment: + +- **Seven are first-party with a finite range**, and are declared. Four go + through ``classify_parameter_error``, whose ``ParameterErrorCode`` switch can + only produce ``{400, 403, 404, 500, 503}``; three go through ``LockError``, + where ``extend`` and ``release`` can only produce ``{400, 403, 404}``. Neither + set is copied by hand. The parameter routes declare + ``handlers::parameter_error_statuses()``, which runs the classifier over every + enumerator - so a new enumerator mapping to a new status widens the + declaration with no edit at the registration - and a switch with no + ``default`` next to that array makes ``-Werror=switch-enum`` fail the build if + somebody adds an enumerator without listing it. The lock claim is behavioural + rather than textual, so it is pinned behaviourally: + ``LockManagerTest.extend_and_release_answer_only_400_403_404`` drives every + reachable failure path of both verbs and asserts the exact status set, + including that 409 is **not** among them - only ``acquire`` conflicts. +- **Seven are plugin-clamped and stay undeclared**: ``make_plugin_error`` in the + data, fault, lifecycle and operation handlers passes a provider-supplied + status clamped only to 400-599. A plugin can answer any of ~200 statuses, so + no finite ``errors({...})`` describes it - the same reason the peer + pass-through above is not declared. Left undeclared deliberately, not + overlooked: this is the boundary where "declare what the gateway can emit" + stops being a finite question, and both sides of it are named here so the + next reader does not have to re-derive which half is which. + +A shipped gateway compiles none of it: the ``Dockerfile`` builds with +``-DBUILD_TESTING=OFF``, so ``make_error()`` is byte-identical to what it was +and ``register_all`` mounts the handler directly. + Escape Hatches -------------- @@ -570,17 +829,25 @@ remain compile-time-checked at their boundary. - ``reg.sse(path, factory)`` - registers a Server-Sent Events route. The factory returns a ``Result`` whose ``next_event`` callback the framework drives via cpp-httplib's chunked content provider. - Used by the fault SSE stream and by cyclic-subscription event streams. -- ``reg.binary_download(path, handler)`` - registers a range-aware binary - download. The handler returns a ``Result`` carrying - ``provider``, ``content_type``, ``filename``, ``supports_ranges``, and - ``total_size``; the framework wires ``provider`` into cpp-httplib's + Used by the fault SSE stream and by cyclic-subscription event streams. The + helper declares ``text/event-stream`` on the 200 from the same string it + hands cpp-httplib, and declares **no** frame schema: the three SSE families + put different shapes in ``data:``, so one schema would be wrong for two of + them. +- ``reg.binary_download(path, handler, media_types)`` - registers a range-aware + binary download. The handler returns a ``Result`` + carrying ``provider``, ``content_type``, ``filename``, ``supports_ranges``, + and ``total_size``; the framework wires ``provider`` into cpp-httplib's range-aware content-provider machinery so partial-content fetches work without manual ``Content-Range`` plumbing. The helper owns the whole header and status story for these routes: it sends ``Content-Disposition`` when the response names a file and ``Accept-Ranges: bytes`` when the provider is range-capable (cpp-httplib only sets the latter for ``HEAD``), and it - declares 200, 206, and those headers - see ``mark_partial_content()`` above. + declares 200, 206, those headers, the ``Range`` request parameter and + ``multipart/byteranges`` on the 206 - see ``mark_partial_content()`` above. + ``media_types`` is required rather than defaulted so a new download route + cannot inherit another route's answer, and it must cover every value the + handler can put in ``BinaryResponse::content_type``. - ``reg.multipart_upload(path, handler)`` - registers a ``multipart/form-data`` upload. The handler receives ``http::MultipartBody`` (already parsed by cpp-httplib) and returns @@ -774,7 +1041,7 @@ The published ``openapi.json`` is assembled mechanically from two sources: on the route via the fluent ``RouteEntry`` builder. The per-topic / per-service / per-action routes for genuinely dynamic ROS 2 payloads carry an *inline* schema built by ``SchemaBuilder``'s ``from_ros_msg`` / - ``from_ros_srv_request`` / ``from_ros_srv_response`` / ``binary_schema`` / + ``from_ros_srv_request`` / ``from_ros_srv_response`` / ``generic_object_schema`` factories (these feed path operations, not ``components/schemas``). diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/bulkdata_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/bulkdata_handlers.hpp index 264a47936..d740c8754 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/bulkdata_handlers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/bulkdata_handlers.hpp @@ -78,6 +78,29 @@ class BulkDataHandlers { */ static std::string get_rosbag_mimetype(const std::string & format); + /** + * @brief Media types `download()` can put on the wire, for the OpenAPI + * document to declare on the six binary-download routes. + * + * Lives here rather than at the registration because this is the file that + * decides the value: the concrete types are exactly the range of + * get_rosbag_mimetype(), and a registration cannot see through the handler + * to find them. + * + * The list ends with `*/*` and that entry is load-bearing, not filler. A + * non-rosbag category serves BulkDataStore::ItemDescriptor::mime_type, which + * is whatever the uploading client put on its multipart part + * (bulk_data_store.cpp, `mime_type = content_type.empty() ? ... : + * content_type`). Uploading a `text/csv` makes the download serve + * `text/csv`, so the served set is open and no finite list is truthful. + * Declaring only the three concrete types would under-declare the route - + * the defect this document's derivation exists to remove - and declaring + * only the catch-all would throw away the part that IS derivable. + * + * @return Concrete rosbag media types followed by the `*/*` catch-all. + */ + static std::vector download_media_types(); + private: HandlerContext & ctx_; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/parameter_error_classification.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/parameter_error_classification.hpp index b1e3aa115..b65606063 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/parameter_error_classification.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/parameter_error_classification.hpp @@ -15,6 +15,7 @@ #pragma once #include +#include #include "ros2_medkit_gateway/core/configuration/parameter_types.hpp" @@ -34,5 +35,15 @@ struct ParameterErrorClassification { /// internal-error - it is never guessed from the free-form message text. ParameterErrorClassification classify_parameter_error(const ParameterResult & result); +/// Every HTTP status `classify_parameter_error` can produce, ascending and +/// deduplicated. +/// +/// Computed by running the classifier over every `ParameterErrorCode`, not by +/// listing statuses by hand: the routes that surface parameter failures declare +/// their error set from this, so a new enumerator mapping to a new status +/// widens those declarations with no edit at the registrations. Hand-listing is +/// what let three 503-emitting sites go undeclared earlier in this work. +const std::vector & parameter_error_statuses(); + } // namespace handlers } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/detail/status_recorder.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/detail/status_recorder.hpp new file mode 100644 index 000000000..603feefa4 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/detail/status_recorder.hpp @@ -0,0 +1,147 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +/** + * @file + * @brief Emitted-status recorder: observes what the gateway actually puts on + * the wire so an integration test can assert the OpenAPI document + * declares it. + * + * Every other check on the document compares it against a list somebody typed + * - and every such list can rot silently. This is the one mechanism that + * notices `declared` falling behind `observed` without anyone maintaining a + * list: the recorder reports pairs, the test asserts the superset. + * + * **Test builds only.** The whole file is behind `MEDKIT_STATUS_RECORDER`, + * which `ros2_medkit_gateway/CMakeLists.txt` defines exactly when + * `BUILD_TESTING` is on. A shipped gateway - the Docker image builds with + * `-DBUILD_TESTING=OFF` - compiles none of it, and `make_error()` is + * byte-identical to what it was. + * + * ### What it observes + * + * - The status that reached `httplib::Response` for every route the + * `RouteRegistry` mounts, attributed to that route's OpenAPI *templated* + * path (`/apps/{app_id}/data/{data_id}`), not the concrete request path. + * This is the authoritative observation: it is the status the client + * receives, whether or not a `make_error()` produced it. + * - The `file:line` of every `make_error()` call that ran, which is how a + * reader can tell how much of the ~281-site error surface a given run + * exercised rather than assuming a recorder that saw something saw + * everything. + * + * ### Why there is no ambient thread-local here + * + * The route identity is a member of `StatusRecordingScope`, the same object + * that reads the final status, so nothing has to travel out-of-band. + * `make_error()`'s hook is deliberately route-agnostic: it contributes to a + * site set, not to the `(route, status)` set the assertion reads. That leaves + * `make_error()` - an `inline` header function whose out-of-line copy lands in + * `gateway_ros2`, which is linked into six MODULE targets - with no + * thread-local access at all. + * + * Route-attributing the sites *would* need an ambient carrier, and that + * carrier would have to be a namespace-scope `extern thread_local` (the + * `tl_forward_response` pattern in `forward_response_scope.hpp`), never a + * function-local `static thread_local`, which compiles to initial-exec TLS a + * shared object cannot relocate. Not needing the carrier is the stronger + * position, and the wire-status set is strictly more accurate than + * route-attributed construction sites would be (it sees the status the client + * receives, including one no `make_error()` built), so it is not planned. + * + * ### What it structurally cannot see + * + * - Anything answered **before** routing: the rate limiter's 429 + * (`rate_limiter.cpp` writes `res.status` directly), the auth middleware's + * 401/403, the CORS reject's 403, the OPTIONS pre-flight 204. + * - Anything cpp-httplib answers by itself: 404/405 for an unrouted request, + * 413 for a payload over `set_payload_max_length`, 400 for a malformed + * request line, 416 for an **unparseable** `Range` (an unsatisfiable but + * parseable one yields 206, not 416). + * - Routes registered straight onto the server rather than through the + * registry (`/docs`, the Swagger UI subtree, this endpoint). + * - Any status on a code path the test run never drives. + * + * Those are declared by hand; the recorder's own output is what says which. + */ + +#ifdef MEDKIT_STATUS_RECORDER + +#include + +#include + +#include + +namespace ros2_medkit_gateway { +namespace http { +namespace detail { + +/// Record that a `make_error()` at *file*:*line* built an error carrying +/// *status*. Called from `make_error()` itself, so it runs on request threads +/// and on background threads alike; the site set is deliberately not +/// route-attributed (see the file comment). +void record_error_site(int status, const char * file, int line); + +/// Record that *status* reached the wire for the route mounted at *method* +/// *path*, where *path* is the OpenAPI templated form. +void record_emitted_status(const std::string & method, const std::string & path, int status); + +/// Snapshot of everything recorded so far: +/// `{"emitted": [{"method","path","status"}...], "error_sites": ["f:12 -> 404"...]}`. +nlohmann::json emitted_status_report(); + +/** + * @brief RAII scope installed around every registry-mounted handler. + * + * On destruction it records the status the handler left on the response. The + * `-1` normalisation mirrors cpp-httplib's own default (`httplib.h`: a handler + * that sets no status yields 200, or 206 when the request carried a `Range`), + * so a streaming or download handler that never touches `res.status` is + * recorded as what the client will actually receive. + * + * Non-copyable, non-movable: it holds references for the duration of one + * handler call and must not outlive or escape it. + */ +class StatusRecordingScope { + public: + StatusRecordingScope(const std::string & method, const std::string & path, const httplib::Request & req, + const httplib::Response & res) + : method_(method), path_(path), req_(req), res_(res) { + } + + ~StatusRecordingScope() { + const int status = res_.status > 0 ? res_.status : (req_.ranges.empty() ? 200 : 206); + record_emitted_status(method_, path_, status); + } + + StatusRecordingScope(const StatusRecordingScope &) = delete; + StatusRecordingScope & operator=(const StatusRecordingScope &) = delete; + StatusRecordingScope(StatusRecordingScope &&) = delete; + StatusRecordingScope & operator=(StatusRecordingScope &&) = delete; + + private: + const std::string & method_; + const std::string & path_; + const httplib::Request & req_; + const httplib::Response & res_; +}; + +} // namespace detail +} // namespace http +} // namespace ros2_medkit_gateway + +#endif // MEDKIT_STATUS_RECORDER diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handler_support.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handler_support.hpp index 6b7b4452a..b2cf64a61 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handler_support.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handler_support.hpp @@ -22,6 +22,7 @@ #include #include "ros2_medkit_gateway/core/models/error_info.hpp" +#include "ros2_medkit_gateway/http/detail/status_recorder.hpp" #include "ros2_medkit_gateway/http/handlers/handler_context.hpp" #include "ros2_medkit_gateway/http/typed_router.hpp" @@ -31,7 +32,25 @@ namespace handlers { /// Build a SOVD-shaped ErrorInfo. Empty `params` are dropped so the wire body /// matches the legacy `send_error` default and integration tests stay byte- /// identical. Shared by every typed handler (was duplicated per handler TU). +/// +/// In test builds (`MEDKIT_STATUS_RECORDER`, set from `BUILD_TESTING`) the +/// call site is recorded so a run can report which of the ~281 error sites it +/// actually reached - see `http/detail/status_recorder.hpp`. The two extra +/// parameters default to `__builtin_FILE()` / `__builtin_LINE()`, which +/// evaluate at the *caller*, so no call site changes. A shipped gateway +/// compiles neither the parameters nor the call and pays nothing. +/// +/// The body is written once, under the conditional signature, rather than as +/// two overloads sharing a helper: the extra inlining frontier that a helper +/// introduces makes GCC 13's libstdc++ report `-Wnull-dereference` false +/// positives here (15 of them, verified by compiling this file both ways). +#ifdef MEDKIT_STATUS_RECORDER +inline ErrorInfo make_error(int status, const std::string & code, std::string message, nlohmann::json params = {}, + const char * site_file = __builtin_FILE(), int site_line = __builtin_LINE()) { + http::detail::record_error_site(status, site_file, site_line); +#else inline ErrorInfo make_error(int status, const std::string & code, std::string message, nlohmann::json params = {}) { +#endif ErrorInfo err; err.code = code; err.message = std::move(message); diff --git a/src/ros2_medkit_gateway/src/core/http/parameter_error_classification.cpp b/src/ros2_medkit_gateway/src/core/http/parameter_error_classification.cpp index f6e25af0c..1c487bff6 100644 --- a/src/ros2_medkit_gateway/src/core/http/parameter_error_classification.cpp +++ b/src/ros2_medkit_gateway/src/core/http/parameter_error_classification.cpp @@ -14,6 +14,9 @@ #include "ros2_medkit_gateway/core/http/parameter_error_classification.hpp" +#include +#include + #include "ros2_medkit_gateway/core/http/error_codes.hpp" namespace ros2_medkit_gateway { @@ -66,11 +69,58 @@ ParameterErrorClassification classify_error_code(ParameterErrorCode error_code) return result; } +/// Every `ParameterErrorCode`, so `parameter_error_statuses()` can run the +/// classifier over the whole enum instead of trusting a hand-written status +/// list. +constexpr std::array kAllParameterErrorCodes{ + ParameterErrorCode::NONE, ParameterErrorCode::NOT_FOUND, + ParameterErrorCode::READ_ONLY, ParameterErrorCode::SERVICE_UNAVAILABLE, + ParameterErrorCode::TIMEOUT, ParameterErrorCode::TYPE_MISMATCH, + ParameterErrorCode::INVALID_VALUE, ParameterErrorCode::NO_DEFAULTS_CACHED, + ParameterErrorCode::SHUT_DOWN, ParameterErrorCode::INTERNAL_ERROR}; + +/// Compile-time guard on the array above. The switch has a case per enumerator +/// and deliberately no `default`, so `-Werror=switch-enum` turns "somebody +/// added a ParameterErrorCode" into a build failure here - three lines from the +/// array that then has to list it - rather than into a status the routes +/// quietly stop declaring. +constexpr bool is_known_parameter_error_code(ParameterErrorCode code) { + switch (code) { + case ParameterErrorCode::NONE: + case ParameterErrorCode::NOT_FOUND: + case ParameterErrorCode::READ_ONLY: + case ParameterErrorCode::SERVICE_UNAVAILABLE: + case ParameterErrorCode::TIMEOUT: + case ParameterErrorCode::TYPE_MISMATCH: + case ParameterErrorCode::INVALID_VALUE: + case ParameterErrorCode::NO_DEFAULTS_CACHED: + case ParameterErrorCode::SHUT_DOWN: + case ParameterErrorCode::INTERNAL_ERROR: + return true; + } + return false; +} + +static_assert(is_known_parameter_error_code(ParameterErrorCode::NONE), "guard must be constexpr-evaluable"); + } // namespace ParameterErrorClassification classify_parameter_error(const ParameterResult & result) { return classify_error_code(result.error_code); } +const std::vector & parameter_error_statuses() { + static const std::vector statuses = [] { + std::vector out; + for (ParameterErrorCode code : kAllParameterErrorCodes) { + out.push_back(classify_error_code(code).status_code); + } + std::sort(out.begin(), out.end()); + out.erase(std::unique(out.begin(), out.end()), out.end()); + return out; + }(); + return statuses; +} + } // namespace handlers } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp b/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp index 58839c89c..18d4aef8f 100644 --- a/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp +++ b/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp @@ -15,6 +15,7 @@ #include "route_registry.hpp" #include +#include #include #include #include @@ -23,10 +24,17 @@ #include #include #include +#include #include "ros2_medkit_gateway/core/http/error_codes.hpp" #include "ros2_medkit_gateway/http/detail/forward_response_scope.hpp" #include "ros2_medkit_gateway/http/detail/primitives.hpp" +#include "ros2_medkit_gateway/http/detail/status_recorder.hpp" + +#ifdef MEDKIT_STATUS_RECORDER +#include +#include +#endif namespace ros2_medkit_gateway { @@ -42,6 +50,66 @@ namespace ros2_medkit_gateway { namespace http { namespace detail { thread_local httplib::Response * tl_forward_response = nullptr; + +#ifdef MEDKIT_STATUS_RECORDER +// Definitions for the test-build-only emitted-status recorder declared in +// `status_recorder.hpp`. Same placement and the same reason as the sink +// above: one definition per program, in the core library both static +// libraries resolve against. Unlike the sink, the recorder needs no +// thread-local state at all - `StatusRecordingScope` carries the route +// identity as its own member and `make_error()`'s hook is route-agnostic. +// See the header for what the recorder observes and what it structurally +// cannot. + +namespace { + +struct RecorderState { + std::mutex mu; + /// (method, OpenAPI templated path, status) actually put on the wire. + std::set> emitted; + /// "file:line -> status" for every `make_error()` that ran. + std::set error_sites; +}; + +RecorderState & recorder_state() { + // Function-local static, NOT thread_local: the state is process-wide and + // guarded by its own mutex, so it carries none of the initial-exec TLS + // hazard that rules out a `static thread_local` in `make_error()`. + static RecorderState state; + return state; +} + +} // namespace + +void record_error_site(int status, const char * file, int line) { + RecorderState & state = recorder_state(); + std::string site = (file != nullptr ? std::string(file) : std::string("")) + ":" + std::to_string(line) + + " -> " + std::to_string(status); + const std::lock_guard lock(state.mu); + state.error_sites.insert(std::move(site)); +} + +void record_emitted_status(const std::string & method, const std::string & path, int status) { + RecorderState & state = recorder_state(); + const std::lock_guard lock(state.mu); + state.emitted.emplace(method, path, status); +} + +nlohmann::json emitted_status_report() { + RecorderState & state = recorder_state(); + const std::lock_guard lock(state.mu); + nlohmann::json emitted = nlohmann::json::array(); + for (const auto & [method, path, status] : state.emitted) { + emitted.push_back({{"method", method}, {"path", path}, {"status", status}}); + } + nlohmann::json sites = nlohmann::json::array(); + for (const auto & site : state.error_sites) { + sites.push_back(site); + } + return nlohmann::json{{"emitted", std::move(emitted)}, {"error_sites", std::move(sites)}}; +} +#endif // MEDKIT_STATUS_RECORDER + } // namespace detail } // namespace http @@ -76,6 +144,18 @@ RouteEntry & RouteEntry::response(int status_code, const std::string & desc, con return *this; } +RouteEntry & RouteEntry::response(int status_code, const std::string & desc, const nlohmann::json & schema, + const std::vector & content_types) { + if (!schema.empty()) { + // Reported, not published, and not asserted - this is a Release build. A + // JSON Schema attached to `application/octet-stream` or + // `text/event-stream` would describe a body shape nothing validates. + schema_on_non_json_statuses_.push_back(status_code); + } + responses_[status_code] = {desc, {}, {}, content_types}; + return *this; +} + RouteEntry & RouteEntry::request_body(const std::string & desc, const nlohmann::json & schema, const std::string & content_type) { request_body_ = RequestBodyInfo{desc, schema, content_type}; @@ -179,6 +259,10 @@ RouteEntry & RouteEntry::response_header(int status_code, ResponseHeader header) } RouteEntry & RouteEntry::errors(std::initializer_list codes) { + return errors(std::vector(codes)); +} + +RouteEntry & RouteEntry::errors(const std::vector & codes) { for (int code : codes) { if (code < 400) { // Not an error status. Recording it rather than silently dropping it is @@ -191,6 +275,36 @@ RouteEntry & RouteEntry::errors(std::initializer_list codes) { return *this; } +RouteEntry & RouteEntry::lock_guarded() { + lock_guarded_ = true; + // Optional, not required: a caller that sends no `X-Client-Id` is treated as + // an anonymous client, which succeeds while nothing is locked and is refused + // once something is. Declaring it required would describe a gateway that + // rejects the header-less request outright, which is not what happens. + header_param("X-Client-Id", + "Identifies the calling client for lock ownership. While a lock protects this " + "entity's resource collection, only the client holding it may write; every other " + "caller - including one that sends no `X-Client-Id` - is answered 409.", + false, nlohmann::json{{"type", "string"}}); + // Through errors(), not response(): a 409 here carries the SOVD GenericError + // body, and errors() is what publishes it against the shared component + // response instead of minting a bespoke bodyless one. + return errors({409}); +} + +RouteEntry & RouteEntry::fan_out_aware() { + // The prose deliberately does not promise loop-freedom in general: the + // gateway sets this header on its own outbound peer requests, but that only + // terminates a recursion on routes that read it - which is exactly the set + // carrying this declaration. The global `GET /faults` fans out without + // checking it and so is not declared here. + return header_param("X-Medkit-No-Fan-Out", + "Present at any value: answer from this gateway alone and do not query " + "aggregated peers. The gateway sets it on its own outbound peer requests, so " + "bidirectional aggregation terminates at the first peer for this operation.", + false, nlohmann::json{{"type", "string"}}); +} + RouteEntry & RouteEntry::only_status(int code, const std::string & desc) { responses_.clear(); declared_errors_.clear(); @@ -382,9 +496,12 @@ RouteEntry & RouteRegistry::sse(const std::string & openapi_path, auto & entry = add_route("get", openapi_path, std::move(fn)); entry.error_renderer_ = renderer; entry.gate_ = gate; - // SSE has no JSON schema; mark it explicitly so validate_completeness skips - // the success-schema check via its SSE-name heuristic. - entry.response(200, "Server-Sent Events stream"); + // Declared with the media type cpp-httplib is handed two lines above, so the + // document and the wire come from one fact rather than from a summary string + // containing the word "stream". No frame schema: the three SSE families + // (trigger events, subscription data, fault notifications) put different + // shapes in `data:`, so a single schema here would be wrong for two of them. + entry.response(200, "Server-Sent Events stream", nlohmann::json{}, {"text/event-stream"}); // Declared here, next to the `set_header` calls above, because that is what // stops the two from drifting: the framework owns these headers, so no SSE // route can be registered without them and none can document them wrongly. @@ -396,7 +513,8 @@ RouteEntry & RouteRegistry::sse(const std::string & openapi_path, RouteEntry & RouteRegistry::binary_download(const std::string & openapi_path, - std::function(http::TypedRequest)> handler) { + std::function(http::TypedRequest)> handler, + const std::vector & media_types) { auto renderer = std::make_shared(ErrorRenderer::kSovdGenericError); auto gate = std::make_shared>(); HandlerFn fn = [handler = std::move(handler), renderer, gate](const httplib::Request & req, httplib::Response & res) { @@ -438,18 +556,32 @@ RouteRegistry::binary_download(const std::string & openapi_path, auto & entry = add_route("get", openapi_path, std::move(fn)); entry.error_renderer_ = renderer; entry.gate_ = gate; - const nlohmann::json binary_schema{{"type", "string"}, {"format", "binary"}}; - // The handler never assigns `res.status`, so cpp-httplib decides it: 200, or - // 206 when the request carried a satisfiable `Range` - and it fills in - // `Content-Range` itself. Advertising `Accept-Ranges` while saying nothing - // about what a `Range` request answers would invite clients into an - // undocumented response. The `Range` request parameter and the 416 rejection - // belong to the full Range contract and are deliberately not declared here. - entry.response(200, "Binary download", binary_schema); - entry.response(206, "Requested byte range of the file", binary_schema); + // 206 when the request carried a `Range` - and it fills in `Content-Range` + // itself. Advertising `Accept-Ranges` while saying nothing about what a + // `Range` request answers would invite clients into an undocumented response. + entry.response(200, "Binary download", nlohmann::json{}, media_types); + + // A multi-range request is answered by wrapping the parts in + // `multipart/byteranges` (cpp-httplib `apply_ranges`, the + // `req.ranges.size() > 1` branch, which rewrites Content-Type and generates a + // boundary). That is a media type the 200 can never carry, so it is declared + // on the 206 only, and it is derived from the framework's own behaviour + // rather than from what the caller passed in. + std::vector partial_media_types = media_types; + partial_media_types.emplace_back("multipart/byteranges"); + entry.response(206, "Requested byte range of the file", nlohmann::json{}, partial_media_types); entry.mark_partial_content(); + // The request half of the same contract. Optional: without it the route + // answers 200 with the whole body, which is the overwhelmingly common case. + // ASCII on purpose: every other description the document emits is ASCII, and + // the section sign appears in this file only inside comments. + entry.header_param("Range", + "Byte range to fetch, per RFC 9110 section 14.2, e.g. `bytes=0-1023`. Several ranges " + "are answered as one `multipart/byteranges` body.", + false, nlohmann::json{{"type", "string"}}); + // Set on the response before the content provider takes over, so they ride on // whichever status cpp-httplib picks - hence declared on both. Each is // conditional on the BinaryResponse the handler returned (a filename, a @@ -582,6 +714,17 @@ std::string RouteRegistry::to_regex_path(const std::string & openapi_path, const return result; } +// ----------------------------------------------------------------------------- +// entity_scoped - can this route resolve an entity, and therefore forward? +// ----------------------------------------------------------------------------- + +bool RouteRegistry::entity_scoped(const std::string & openapi_path) { + static const std::array kEntityParams = {"{area_id}", "{component_id}", "{app_id}", "{function_id}"}; + return std::any_of(kEntityParams.begin(), kEntityParams.end(), [&openapi_path](const char * param) { + return openapi_path.find(param) != std::string::npos; + }); +} + // ----------------------------------------------------------------------------- // register_all - register all routes with cpp-httplib server // ----------------------------------------------------------------------------- @@ -589,7 +732,21 @@ std::string RouteRegistry::to_regex_path(const std::string & openapi_path, const void RouteRegistry::register_all(httplib::Server & server, const std::string & api_prefix) const { for (const auto & route : routes_) { std::string full_path = api_prefix + route.regex_path_; +#ifdef MEDKIT_STATUS_RECORDER + // Test builds only. Mounting point is the one place that knows both the + // route's OpenAPI templated path and every response the route produces, + // so the recorder attaches here rather than inside the typed wrappers + // (which see the handler but not its identity). The identity strings are + // captured by value: cpp-httplib owns the resulting std::function and + // must not outlive-dangle into the registry's deque. + HandlerFn handler = [method = route.method_, path = route.path_, + inner = route.handler_](const httplib::Request & req, httplib::Response & res) { + const http::detail::StatusRecordingScope scope(method, path, req, res); + inner(req, res); + }; +#else const auto & handler = route.handler_; +#endif if (route.method_ == "get") { server.Get(full_path, handler); @@ -637,11 +794,18 @@ nlohmann::json RouteRegistry::to_openapi_paths() const { operation["x-medkit-alternates"] = true; } if (route.partial_content_) { - // cpp-httplib answers 206 instead of 200 when the request carries a - // satisfiable `Range`, so more than one 2xx code is genuine here too - - // for a different reason than a variant-returning handler. + // cpp-httplib answers 206 instead of 200 whenever the request carries a + // `Range` at all, so more than one 2xx code is genuine here too - for a + // different reason than a variant-returning handler. Not "a satisfiable + // Range": a parseable range past the end of the file still yields 206, + // and an unparseable one is rejected with 416 before routing. operation["x-medkit-partial-content"] = true; } + if (route.lock_guarded_) { + // Declared by the registration, never inferred from the handler - see + // RouteEntry::lock_guarded() for why that derivation is not available. + operation["x-medkit-lock-guarded"] = true; + } // Parameters if (!route.parameters_.empty()) { @@ -728,10 +892,20 @@ nlohmann::json RouteRegistry::to_openapi_paths() const { for (const auto & [code, info] : route.responses_) { std::string code_str = std::to_string(code); operation["responses"][code_str]["description"] = info.desc; - // Guard stays: a default-constructed nlohmann::json is `null`, so - // dropping this writes `"schema": null` onto every bodyless 204. - if (!info.schema.empty()) { - operation["responses"][code_str]["content"]["application/json"]["schema"] = info.schema; + if (info.content_types.empty()) { + // JSON default. Guard stays: a default-constructed nlohmann::json is + // `null`, so dropping it writes `"schema": null` onto every bodyless + // 204. + if (!info.schema.empty()) { + operation["responses"][code_str]["content"]["application/json"]["schema"] = info.schema; + } + } else { + // Non-JSON body: one `content` entry per media type, each an empty + // Media Type Object. The absent schema is the point, not an omission + // - see RouteEntry::response(status, desc, schema, content_types). + for (const auto & media_type : info.content_types) { + operation["responses"][code_str]["content"][media_type] = nlohmann::json::object(); + } } for (const auto & header : info.headers) { auto & header_obj = operation["responses"][code_str]["headers"][header.name]; @@ -774,6 +948,17 @@ nlohmann::json RouteRegistry::to_openapi_paths() const { // are declared per-route but described once as shared components - that is // the only place their headers (`WWW-Authenticate`, `Retry-After`, // `X-RateLimit-*`) can live, since no handler return type produces them. + // + // These run AFTER the `errors()` loop above and `add_response_ref` is + // first-wins, so a route that declares a status the middleware also owns + // keeps its own: the script manager's concurrency 429 and a lifecycle + // provider's AccessDenied 403 both shadow the middleware component on the + // routes that declare them. That is deliberate - OpenAPI allows one + // response object per status and the route-specific description is the more + // useful of the two - but it costs the middleware's headers on those + // operations. The body shape is unaffected: Unauthorized, Forbidden and + // RateLimited all reference the same GenericError schema. Pinned by + // RouteRegistryTest.RouteDeclaredStatusWinsOverTheMiddlewareComponent. if (auth_enabled_) { add_response_ref("401", "Unauthorized"); add_response_ref("403", "Forbidden"); @@ -782,6 +967,66 @@ nlohmann::json RouteRegistry::to_openapi_paths() const { add_response_ref("429", "RateLimited"); } + // 416 is answered by cpp-httplib itself, before any handler and before + // routing: `Server::process_request` rejects an unparseable `Range` header + // and writes the response straight out (vendored httplib.h:6616-6622). It + // therefore reaches every operation in this document, including paths that + // do not exist, which is why it is declared here rather than on the six + // download routes - those are where a `Range` is *useful*, not where the + // status originates. + // + // It carries the GenericError body like any other error status, but by a + // different route than the rest: cpp-httplib writes no body at all, and + // `RESTServer::setup_global_error_handlers` (rest_server.cpp:265-286) fills every + // body-less error response with a GenericError on the way out. So the + // shape is the same and the $ref is correct - reading only the vendored + // header suggests a body-less response, and the gateway's own handler is + // what makes that wrong. Pinned on the wire by + // test_openapi_contract::test_range_rejection_is_answered_on_a_route_that_declares_it. + // + // Unlike the limiter's 429 this is gated on nothing: rate limiting is + // opt-in, whereas cpp-httplib's Range parsing has no configuration knob + // and cannot be turned off. + // + // Outside only_status() for the same reason as the auth middleware's + // 401/403: only_status says the *handler* has one outcome, and no handler + // runs before this status is decided. + // + // The status recorder cannot observe it either - it wraps handlers - so + // unlike the statuses derived from recorded runs, this declaration is a + // framework-level constant, pinned by + // RouteRegistryTest.EveryDocumentedRouteDeclaresTheFrameworkAnsweredRangeRejection. + add_error_ref("416"); + + // Peer aggregation. When an entity turns out to belong to a peer, the + // request is proxied inside `validate_entity_for_route`, and the statuses + // the *gateway itself* writes on that path are 502 (peer unknown, + // unreachable, or its response over the size cap) and 503 (this gateway is + // shutting down and refuses to forward) - see `peer_client.cpp` and + // `aggregation_manager.cpp`. Both carry the SOVD GenericError body, so the + // shared component describes them correctly. + // + // Gated the same way as the limiter's 429 and for the same reason: + // `aggregation.enabled` defaults false and the AggregationManager is only + // constructed when it is set, so with aggregation off no entity is remote + // and declaring either status would document an unreachable outcome. + // + // NOT declared here: the status a healthy peer returns, which is copied + // through verbatim (`peer_client.cpp` assigns the peer's own status). No + // finite `errors({...})` describes "whatever the peer said", and deciding + // what the document should promise there is an aggregation-contract + // question, not a documentation one. + // + // Guarded by `only_status_` as well, for the same reason as the blanket + // 400/404/500 above and unlike the middleware refs below: the forward + // happens *inside* the handler, so a route that declares itself + // single-outcome (the data-categories / data-groups 501 stubs ignore the + // request entirely and never resolve an entity) genuinely cannot reach it. + if (aggregation_enabled_ && !route.only_status_ && entity_scoped(route.path_)) { + add_error_ref("502"); + add_error_ref("503"); + } + // Use explicit operationId if set, otherwise auto-generate camelCase from path if (!route.operation_id_.empty()) { operation["operationId"] = route.operation_id_; @@ -887,6 +1132,26 @@ std::vector RouteRegistry::validate_completeness() const { "; use response() for success and redirect statuses"}); } + // A lock-guarded route without its 409 publishes a marker a client cannot + // act on. lock_guarded() declares both, so the only way to lose one is a + // later only_status(), which clears declared_errors_ and leaves the marker + // standing. Reported rather than asserted: this is a release build. + const bool declares_409 = std::count(route.declared_errors_.begin(), route.declared_errors_.end(), 409) > 0 || + route.responses_.count(409) > 0; + if (route.lock_guarded_ && !declares_409) { + issues.push_back({ValidationIssue::Severity::kError, route_id, + "lock_guarded() marker without a declared 409; a later only_status() clears " + "the status the marker promises"}); + } + + // A schema handed to the non-JSON response() overload was dropped rather + // than attached to a media type it may not describe. + for (int code : route.schema_on_non_json_statuses_) { + issues.push_back({ValidationIssue::Severity::kError, route_id, + "response() dropped the schema given for non-JSON status " + std::to_string(code) + + "; a non-JSON body is declared by media type, without a schema"}); + } + // response_header() attaches to an already-declared status. One aimed at a // status this route never declares was dropped, so the header the handler // sets would be missing from the document with nothing to show for it. @@ -906,11 +1171,39 @@ std::vector RouteRegistry::validate_completeness() const { } } - // SSE endpoints use text/event-stream, not JSON schema - skip schema check - // Convention: SSE endpoints have "SSE" or "stream" in summary - bool is_sse = route.summary_.find("SSE") != std::string::npos || - route.summary_.find("stream") != std::string::npos || - route.summary_.find("Stream") != std::string::npos; + // A 2xx whose body is a non-JSON media type is complete without a schema: + // the media type IS the description of the body, and attaching a JSON + // Schema to `application/octet-stream` or `text/event-stream` would + // describe a shape nothing validates. Covers both escape hatches - the + // SSE streams and the range-aware binary downloads - and it reads the + // declaration the helper made rather than sniffing the summary string for + // the word "stream", so a route cannot acquire the exemption by being + // named a certain way. + // + // The media type has to actually be non-JSON. Accepting any non-empty + // `content_types` would exempt a route that declares + // `application/json` and no schema, which is precisely the case this + // check exists for - a JSON body still owes a shape. That is also the + // rule stated in dto_contract.rst and the one + // `test_health::test_docs_spec_completeness` enforces on the served + // document, so the two gates agree by construction rather than by + // coincidence. Pinned by + // RouteRegistryTest.SchemaLessJsonSuccessIsStillReported. + bool has_non_json_success = false; + for (const auto & [code, info] : route.responses_) { + if (code < 200 || code >= 300) { + continue; + } + for (const auto & media_type : info.content_types) { + if (media_type != "application/json") { + has_non_json_success = true; + break; + } + } + if (has_non_json_success) { + break; + } + } // Body-less success statuses need no schema: 204 never carries a body, // and a 202 declared without one is an accepted asynchronous transition @@ -931,7 +1224,8 @@ std::vector RouteRegistry::validate_completeness() const { } } - if (!has_success_response_with_schema && !is_sse && !has_bodyless_success && !has_only_error_responses) { + if (!has_success_response_with_schema && !has_non_json_success && !has_bodyless_success && + !has_only_error_responses) { issues.push_back({ValidationIssue::Severity::kError, route_id, "Missing response schema for success (2xx)"}); } } else { 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 ceb058a80..666b08bb8 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/bulkdata_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/bulkdata_handlers.cpp @@ -76,6 +76,15 @@ std::string BulkDataHandlers::get_rosbag_mimetype(const std::string & format) { return "application/octet-stream"; } +std::vector BulkDataHandlers::download_media_types() { + // The first three are the complete range of get_rosbag_mimetype() directly + // above - add a branch there and this list needs the type it returns. The + // catch-all covers the store-backed categories, whose type is client-supplied + // at upload and therefore not enumerable here; see the header for why the + // route declares both halves rather than picking one. + return {"application/x-mcap", "application/x-sqlite3", "application/octet-stream", "*/*"}; +} + std::string BulkDataHandlers::resolve_rosbag_file_path(const std::string & path) { // If it's a regular file, return as-is if (std::filesystem::is_regular_file(path)) { diff --git a/src/ros2_medkit_gateway/src/http/rest_server.cpp b/src/ros2_medkit_gateway/src/http/rest_server.cpp index 95e97e7a1..632ab9ab6 100644 --- a/src/ros2_medkit_gateway/src/http/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/http/rest_server.cpp @@ -22,8 +22,10 @@ #include "ros2_medkit_gateway/core/auth/auth_middleware.hpp" #include "ros2_medkit_gateway/core/http/error_codes.hpp" #include "ros2_medkit_gateway/core/http/http_utils.hpp" +#include "ros2_medkit_gateway/core/http/parameter_error_classification.hpp" #include "ros2_medkit_gateway/core/thread_pool_config.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" +#include "ros2_medkit_gateway/http/detail/status_recorder.hpp" #include "ros2_medkit_gateway/ros2/status/ros2_lifecycle_state_reader.hpp" #include "../openapi/route_registry.hpp" @@ -293,6 +295,15 @@ void RESTServer::set_trigger_handlers(TriggerManager & trigger_mgr) { void RESTServer::set_aggregation_manager(AggregationManager * mgr) { handler_ctx_->set_aggregation_manager(mgr); + // Read the same pointer the forwarding branch reads + // (`HandlerContext::validate_entity_for_route` forwards only when + // `aggregation_mgr_` is non-null), so the document's 502/503 cannot drift + // from the condition that makes them reachable. Called before the first + // `/docs` request, and the document is generated per request, so setting it + // after `setup_routes()` is fine. + if (route_registry_) { + route_registry_->set_aggregation_enabled(mgr != nullptr); + } } RESTServer::~RESTServer() { @@ -315,6 +326,19 @@ void RESTServer::setup_routes() { docs_handlers_->handle_docs_any_path(req, res); }); +#ifdef MEDKIT_STATUS_RECORDER + // Test builds only (BUILD_TESTING; see CMakeLists.txt). Serves what the + // emitted-status recorder has observed so far so an integration test can + // assert the served document declares every status the gateway actually + // put on the wire. Registered straight onto the server rather than through + // the RouteRegistry: it must not appear in the document it is used to + // check, and it must not be recorded by the recorder it reads. + srv->Get(api_path("/x-medkit-status-coverage"), [](const httplib::Request &, httplib::Response & res) { + res.status = 200; + res.set_content(http::detail::emitted_status_report().dump(), "application/json"); + }); +#endif + #ifdef ENABLE_SWAGGER_UI // Swagger UI - interactive API documentation browser srv->Get(api_path("/swagger-ui"), [this](const httplib::Request & req, httplib::Response & res) { @@ -487,6 +511,8 @@ void RESTServer::setup_routes() { .tag("Server") .summary("SOVD version information") .description("Returns SOVD specification version and vendor info.") + // HealthHandlers::get_version_info -> merge_peer_items (peer vendor blocks). + .fan_out_aware() .operation_id("getVersionInfo"); // === Discovery - entity collections === @@ -582,6 +608,10 @@ void RESTServer::setup_routes() { .tag("Data") .summary(std::string("Get data item for ") + et.singular) .description(std::string("Returns the latest value from a ROS 2 topic for this ") + et.singular + ".") + // DataHandlers::get_data_item answers 503 when topic sampling is not + // configured. The emitted-status recorder cannot reach it: the test + // fixture configures sampling, so no run drives that branch. + .errors({503}) .operation_id(std::string("get") + capitalize(et.singular) + "DataItem"); reg.put(entity_path + "/data/{data_id}", @@ -592,6 +622,8 @@ void RESTServer::setup_routes() { .summary(std::string("Write data item for ") + et.singular) .description(std::string("Publishes a value to a ROS 2 topic on this ") + et.singular + ".") .request_body("Data value to write", SB::ref("DataWriteRequest")) + // DataHandlers::put_data_item -> HandlerContext::validate_lock_access("data"). + .lock_guarded() .operation_id(std::string("put") + capitalize(et.singular) + "DataItem"); // Data-categories. Unconditionally 501: `only_status` drops both the @@ -631,6 +663,8 @@ void RESTServer::setup_routes() { .tag("Data") .summary(std::string("List data items for ") + et.singular) .description(std::string("Lists all data items (ROS 2 topics) available on this ") + et.singular + ".") + // DataHandlers::list_data -> fan_out_collection. + .fan_out_aware() .operation_id(std::string("list") + capitalize(et.singular) + "Data"); // --- Operations --- @@ -652,6 +686,8 @@ void RESTServer::setup_routes() { .tag("Operations") .summary(std::string("List operations for ") + et.singular) .description(std::string("Lists all ROS 2 services and actions available on this ") + et.singular + ".") + // OperationHandlers::list_operations -> fan_out_collection. + .fan_out_aware() .operation_id(std::string("list") + capitalize(et.singular) + "Operations"); reg.get(entity_path + "/operations/{operation_id}", @@ -678,6 +714,8 @@ void RESTServer::setup_routes() { .tag("Operations") .summary(std::string("Start operation execution for ") + et.singular) .description("Starts a new execution. Returns 200 for synchronous, 202 for asynchronous operations.") + // OperationHandlers::create_execution -> validate_lock_access("operations"). + .lock_guarded() .operation_id(std::string("execute") + capitalize(et.singular) + "Operation"); reg.get>( @@ -711,6 +749,8 @@ void RESTServer::setup_routes() { .summary(std::string("Update execution for ") + et.singular) .description("Sends a control command to a running execution.") .success_description("Accepted (asynchronous control)") + // OperationHandlers::update_execution -> validate_lock_access("operations"). + .lock_guarded() .operation_id(std::string("update") + capitalize(et.singular) + "Execution"); reg.del(entity_path + "/operations/{operation_id}/executions/{execution_id}", @@ -720,6 +760,8 @@ void RESTServer::setup_routes() { .tag("Operations") .summary(std::string("Cancel execution for ") + et.singular) .description("Cancels a running execution.") + // OperationHandlers::cancel_execution -> validate_lock_access("operations"). + .lock_guarded() .operation_id(std::string("cancel") + capitalize(et.singular) + "Execution"); // --- Configurations --- @@ -742,6 +784,15 @@ void RESTServer::setup_routes() { .tag("Configuration") .summary(std::string("List configurations for ") + et.singular) .description(std::string("Lists all ROS 2 node parameters for this ") + et.singular + ".") + // ConfigHandlers::list_configurations -> fan_out_collection. + .fan_out_aware() + // Parameter failures reach the wire through `classify_parameter_error`, + // whose whole range is declared here rather than copied: 403 (the + // parameter is read-only) and 503 (the backing node is unreachable or + // timed out) are first-party statuses `Ros2ParameterTransport` produces + // and no blanket rule adds. Out of the emitted-status recorder's reach - + // the fixture's nodes answer and none of their parameters is read-only. + .errors(handlers::parameter_error_statuses()) .operation_id(std::string("list") + capitalize(et.singular) + "Configurations"); reg.get(entity_path + "/configurations/{config_id}", @@ -751,6 +802,13 @@ void RESTServer::setup_routes() { .tag("Configuration") .summary(std::string("Get specific configuration for ") + et.singular) .description(std::string("Returns a specific ROS 2 node parameter for this ") + et.singular + ".") + // Parameter failures reach the wire through `classify_parameter_error`, + // whose whole range is declared here rather than copied: 403 (the + // parameter is read-only) and 503 (the backing node is unreachable or + // timed out) are first-party statuses `Ros2ParameterTransport` produces + // and no blanket rule adds. Out of the emitted-status recorder's reach - + // the fixture's nodes answer and none of their parameters is read-only. + .errors(handlers::parameter_error_statuses()) .operation_id(std::string("get") + capitalize(et.singular) + "Configuration"); reg.put( @@ -762,6 +820,15 @@ void RESTServer::setup_routes() { .tag("Configuration") .summary(std::string("Set configuration for ") + et.singular) .description(std::string("Sets a ROS 2 node parameter value for this ") + et.singular + ".") + // ConfigHandlers::set_configuration -> validate_lock_access("configurations"). + .lock_guarded() + // Parameter failures reach the wire through `classify_parameter_error`, + // whose whole range is declared here rather than copied: 403 (the + // parameter is read-only) and 503 (the backing node is unreachable or + // timed out) are first-party statuses `Ros2ParameterTransport` produces + // and no blanket rule adds. Out of the emitted-status recorder's reach - + // the fixture's nodes answer and none of their parameters is read-only. + .errors(handlers::parameter_error_statuses()) .operation_id(std::string("set") + capitalize(et.singular) + "Configuration"); reg.del(entity_path + "/configurations/{config_id}", @@ -771,6 +838,15 @@ void RESTServer::setup_routes() { .tag("Configuration") .summary(std::string("Delete configuration for ") + et.singular) .description(std::string("Resets a configuration parameter to its default for this ") + et.singular + ".") + // ConfigHandlers::delete_configuration -> validate_lock_access("configurations"). + .lock_guarded() + // Parameter failures reach the wire through `classify_parameter_error`, + // whose whole range is declared here rather than copied: 403 (the + // parameter is read-only) and 503 (the backing node is unreachable or + // timed out) are first-party statuses `Ros2ParameterTransport` produces + // and no blanket rule adds. Out of the emitted-status recorder's reach - + // the fixture's nodes answer and none of their parameters is read-only. + .errors(handlers::parameter_error_statuses()) .operation_id(std::string("delete") + capitalize(et.singular) + "Configuration"); reg.del_alternates( @@ -784,6 +860,8 @@ void RESTServer::setup_routes() { .tag("Configuration") .summary(std::string("Delete all configurations for ") + et.singular) .description(std::string("Resets all configuration parameters for this ") + et.singular + ".") + // ConfigHandlers::delete_all_configurations -> validate_lock_access("configurations"). + .lock_guarded() .operation_id(std::string("deleteAll") + capitalize(et.singular) + "Configurations"); // --- Faults --- @@ -805,6 +883,12 @@ void RESTServer::setup_routes() { .tag("Faults") .summary(std::string("List faults for ") + et.singular) .description(std::string("Returns all active faults reported by this ") + et.singular + ".") + // FaultHandlers::list_faults -> merge_peer_items. + .fan_out_aware() + // FaultHandlers::list_faults answers 503 when the fault store cannot + // be read. The recorder cannot reach it: the fixture's store is + // healthy, so no run drives that branch. + .errors({503}) .operation_id(std::string("list") + capitalize(et.singular) + "Faults") .query(); @@ -815,6 +899,9 @@ void RESTServer::setup_routes() { .tag("Faults") .summary(std::string("Get specific fault for ") + et.singular) .description("Returns fault details including SOVD status, environment data, and rosbag snapshots.") + // 503 when the fault store cannot be read - same branch as the list + // routes, and equally out of the recorder's reach. + .errors({503}) .operation_id(std::string("get") + capitalize(et.singular) + "Fault"); reg.del_alternates( @@ -826,6 +913,11 @@ void RESTServer::setup_routes() { .tag("Faults") .summary(std::string("Clear fault for ") + et.singular) .description(std::string("Clears a specific fault for this ") + et.singular + ".") + // FaultHandlers::clear_fault -> validate_lock_access("faults"). + .lock_guarded() + // 503 when the fault store cannot be read - clear_fault reads the fault + // before clearing it, so it answers the same status the read does. + .errors({503}) .operation_id(std::string("clear") + capitalize(et.singular) + "Fault"); reg.del(entity_path + "/faults", @@ -835,6 +927,11 @@ void RESTServer::setup_routes() { .tag("Faults") .summary(std::string("Clear all faults for ") + et.singular) .description(std::string("Clears all faults for this ") + et.singular + ".") + // FaultHandlers::clear_all_faults -> validate_lock_access("faults"). + .lock_guarded() + // 503 when the fault store cannot be read - same unreachable-in-test + // branch as the list route above. + .errors({503}) .operation_id(std::string("clearAll") + capitalize(et.singular) + "Faults"); // --- Logs --- @@ -851,6 +948,12 @@ void RESTServer::setup_routes() { .tag("Logs") .summary(std::string("Query log entries for ") + et.singular) .description(std::string("Queries application log entries for this ") + et.singular + ".") + // LogHandlers::get_logs -> fan_out_collection. + .fan_out_aware() + // All three log routes answer 503 when no LogManager is attached, or + // when it refuses the read. The recorder cannot reach it: the fixture + // always has one, so no run drives that branch. + .errors({503}) .operation_id(std::string("list") + capitalize(et.singular) + "Logs") .query(); @@ -861,6 +964,7 @@ void RESTServer::setup_routes() { .tag("Logs") .summary(std::string("Get log configuration for ") + et.singular) .description(std::string("Returns the log filter configuration for this ") + et.singular + ".") + .errors({503}) // No LogManager attached - see the list route above. .operation_id(std::string("get") + capitalize(et.singular) + "LogConfiguration"); reg.put( @@ -871,6 +975,9 @@ void RESTServer::setup_routes() { .tag("Logs") .summary(std::string("Update log configuration for ") + et.singular) .description(std::string("Updates the log severity filter and max entries for this ") + et.singular + ".") + // LogHandlers::put_logs_configuration -> validate_lock_access("logs"). + .lock_guarded() + .errors({503}) // No LogManager attached - see the list route above. .operation_id(std::string("set") + capitalize(et.singular) + "LogConfiguration"); // --- Bulk Data --- @@ -903,10 +1010,12 @@ void RESTServer::setup_routes() { .description(std::string("Lists downloadable files in a bulk-data category for this ") + et.singular + ".") .operation_id(std::string("list") + capitalize(et.singular) + "BulkDataDescriptors"); - reg.binary_download(entity_path + "/bulk-data/{category_id}/{file_id}", - [this](http::TypedRequest req) -> http::Result { - return bulkdata_handlers_->download(req); - }) + reg.binary_download( + entity_path + "/bulk-data/{category_id}/{file_id}", + [this](http::TypedRequest req) -> http::Result { + return bulkdata_handlers_->download(req); + }, + handlers::BulkDataHandlers::download_media_types()) .tag("Bulk Data") .summary(std::string("Download bulk-data file for ") + et.singular) .description("Downloads a bulk-data file (binary content).") @@ -925,6 +1034,12 @@ void RESTServer::setup_routes() { .summary(std::string("Upload bulk-data for ") + et.singular) .description(std::string("Uploads a file to a bulk-data category for this ") + et.singular + ".") .success_description("File uploaded") + // BulkDataHandlers::upload -> validate_lock_access("bulk-data"). + .lock_guarded() + // BulkDataHandlers::upload answers 413 when the part exceeds + // `bulk_data.max_upload_bytes`. The recorder cannot reach it: the + // fixture leaves the limit unbounded, so no run drives it. + .errors({413}) .operation_id(std::string("upload") + capitalize(et.singular) + "BulkData"); reg.del(entity_path + "/bulk-data/{category_id}/{file_id}", @@ -934,6 +1049,8 @@ void RESTServer::setup_routes() { .tag("Bulk Data") .summary(std::string("Delete bulk-data file for ") + et.singular) .description(std::string("Deletes a bulk-data file for this ") + et.singular + ".") + // BulkDataHandlers::remove -> validate_lock_access("bulk-data"). + .lock_guarded() .operation_id(std::string("delete") + capitalize(et.singular) + "BulkData"); } else { // 405 stub routes for entity types that cannot host uploaded bulk-data @@ -1002,6 +1119,10 @@ void RESTServer::setup_routes() { .summary(std::string("SSE events stream for trigger on ") + et.singular) .description(std::string("Server-Sent Events stream for trigger notifications on this ") + et.singular + ".") .gated_on(triggers_available, triggers_unavailable) + // TriggerHandlers::sse_trigger_events answers 503 once the SSE + // client limit is reached. The recorder cannot reach it: the fixture + // never opens enough concurrent streams. + .errors({503}) .operation_id(std::string("stream") + capitalize(et.singular) + "TriggerEvents"); reg.post>( @@ -1015,6 +1136,10 @@ void RESTServer::setup_routes() { .description(std::string("Creates a new event trigger for this ") + et.singular + ".") .success_description("Trigger created") .gated_on(triggers_available, triggers_unavailable) + // TriggerHandlers::post_trigger answers 503 when the trigger engine + // refuses the rule or the resource subscription fails. The recorder + // cannot reach it: the fixture's engine accepts every rule. + .errors({503}) .operation_id(std::string("create") + capitalize(et.singular) + "Trigger"); reg.get>( @@ -1095,6 +1220,10 @@ void RESTServer::setup_routes() { .summary(std::string("Create cyclic subscription for ") + et.singular) .description(std::string("Creates a new cyclic data subscription for this ") + et.singular + ".") .success_description("Subscription created") + // CyclicSubscriptionHandlers::post_subscription answers 503 when the + // subscription manager or the event source refuses. The recorder + // cannot reach it: both accept in the fixture. + .errors({503}) .operation_id(std::string("create") + capitalize(et.singular) + "Subscription"); reg.get>( @@ -1158,7 +1287,12 @@ void RESTServer::setup_routes() { .description(std::string("Acquires an exclusive lock on this ") + et.singular + ".") .header_param("X-Client-Id", "Unique client identifier for lock ownership", true, client_id_schema) .success_description("Lock acquired") - .errors({501}) // Locking disabled: LockHandlers::check_locking_enabled + // 409 from LockManager::acquire, passed through verbatim by + // post_lock: `lock-conflict` when the entity is already locked and + // `break_lock` was not requested, `lock-not-breakable` when it was + // but the existing lock forbids it. Acquire is the only lock verb + // that can 409 - extend and release answer 400/403/404. + .errors({409, 501}) // 501: locking disabled, LockHandlers::check_locking_enabled .operation_id(std::string("acquire") + capitalize(et.singular) + "Lock"); reg.get>(entity_path + "/locks", @@ -1194,7 +1328,13 @@ void RESTServer::setup_routes() { .summary(std::string("Extend lock on ") + et.singular) .description(std::string("Extends the expiration of a lock on this ") + et.singular + ".") .header_param("X-Client-Id", "Unique client identifier for lock ownership", true, client_id_schema) - .errors({501}) // Locking disabled: LockHandlers::check_locking_enabled + // 403 `lock-not-owner` from LockManager, passed through verbatim: + // the lock exists but belongs to a different client. Nothing to do + // with the auth middleware's 403, so it is declared here and stands + // whether or not authentication is on. 400 and 404 (invalid + // expiration, no such lock) are in the blanket set already, and 409 + // is deliberately absent - acquire is the only verb that conflicts. + .errors({403, 501}) // 501: locking disabled, LockHandlers::check_locking_enabled .operation_id(std::string("extend") + capitalize(et.singular) + "Lock"); reg.del(entity_path + "/locks/{lock_id}", @@ -1205,7 +1345,13 @@ void RESTServer::setup_routes() { .summary(std::string("Release lock on ") + et.singular) .description(std::string("Releases a lock on this ") + et.singular + ".") .header_param("X-Client-Id", "Unique client identifier for lock ownership", true, client_id_schema) - .errors({501}) // Locking disabled: LockHandlers::check_locking_enabled + // 403 `lock-not-owner` from LockManager, passed through verbatim: + // the lock exists but belongs to a different client. Nothing to do + // with the auth middleware's 403, so it is declared here and stands + // whether or not authentication is on. 400 and 404 (invalid + // expiration, no such lock) are in the blanket set already, and 409 + // is deliberately absent - acquire is the only verb that conflicts. + .errors({403, 501}) // 501: locking disabled, LockHandlers::check_locking_enabled .operation_id(std::string("release") + capitalize(et.singular) + "Lock"); } @@ -1220,6 +1366,35 @@ void RESTServer::setup_routes() { // template parameters; per-route .request_body() / .response() builder // calls stay only where the schema differs (multipart upload + free-form // start-execution body). + // + // All eight routes funnel backend failures through one total mapper, + // `script_backend_error` (script_handlers.cpp), but the mapper's totality + // is NOT a licence to declare its whole range everywhere: what each route + // can answer is what its own `ScriptProvider` method returns. Declared per + // route against the shipped `DefaultScriptProvider`, so + // `GET .../scripts` does not tell a client it might answer 413: + // + // list_scripts - no backend error at all + // get_script - NotFound / Internal + // upload_script - FileTooLarge (413), InvalidInput, Internal + // delete_script - ManagedScript / AlreadyRunning (409), NotFound, Internal + // start_execution - ConcurrencyLimit (429), UnsupportedType, InvalidInput, NotFound + // get_execution - NotFound + // control_execution - NotRunning (409), InvalidInput, NotFound + // delete_execution - AlreadyRunning (409), NotFound + // + // The 429 is the script manager's concurrency limit, not the HTTP rate + // limiter's: it is reachable whether or not `rate_limiting.enabled` is set, + // which is why it is declared on the one route that answers it rather than + // by the registry's limiter gate. 409 / 413 / 429 are all out of the + // emitted-status recorder's reach - they need a backend in a state no + // integration fixture puts it in. + // + // A `ScriptProvider` plugin may return codes its `DefaultScriptProvider` + // counterpart never does (`AlreadyExists` exists for exactly that, see + // `script_provider.hpp`). These declarations describe the shipped backend; + // widening them to the mapper's full range for every route would make the + // document describe no backend at all. if (script_handlers_ && (et_type_str == "apps" || et_type_str == "components")) { reg.multipart_upload>( entity_path + "/scripts", @@ -1231,7 +1406,8 @@ void RESTServer::setup_routes() { .summary(std::string("Upload diagnostic script for ") + et.singular) .description(std::string("Uploads a diagnostic script for this ") + et.singular + ".") .success_description("Script uploaded") - .errors({501}) // No scripts backend configured + // DefaultScriptProvider::upload_script -> FileTooLarge + .errors({413, 501}) .operation_id(std::string("upload") + capitalize(et.singular) + "Script"); reg.get(entity_path + "/scripts", @@ -1241,7 +1417,8 @@ void RESTServer::setup_routes() { .tag("Scripts") .summary(std::string("List scripts for ") + et.singular) .description(std::string("Lists all diagnostic scripts for this ") + et.singular + ".") - .errors({501}) // No scripts backend configured + // DefaultScriptProvider::list_scripts returns no backend error + .errors({501}) .operation_id(std::string("list") + capitalize(et.singular) + "Scripts"); reg.get(entity_path + "/scripts/{script_id}", @@ -1251,7 +1428,8 @@ void RESTServer::setup_routes() { .tag("Scripts") .summary(std::string("Get script metadata for ") + et.singular) .description(std::string("Returns metadata of a specific script for this ") + et.singular + ".") - .errors({501}) // No scripts backend configured + // DefaultScriptProvider::get_script -> NotFound / Internal only + .errors({501}) .operation_id(std::string("get") + capitalize(et.singular) + "Script"); reg.del(entity_path + "/scripts/{script_id}", @@ -1261,7 +1439,8 @@ void RESTServer::setup_routes() { .tag("Scripts") .summary(std::string("Delete script for ") + et.singular) .description(std::string("Deletes a diagnostic script from this ") + et.singular + ".") - .errors({501}) // No scripts backend configured + // DefaultScriptProvider::delete_script -> ManagedScript / AlreadyRunning + .errors({409, 501}) .operation_id(std::string("delete") + capitalize(et.singular) + "Script"); reg.post>( @@ -1275,7 +1454,8 @@ void RESTServer::setup_routes() { .description(std::string("Starts execution of a diagnostic script on this ") + et.singular + ".") .request_body("Execution parameters", SB::generic_object_schema()) .success_description("Execution started") - .errors({501}) // No scripts backend configured + // DefaultScriptProvider::start_execution -> ConcurrencyLimit + .errors({429, 501}) .operation_id(std::string("start") + capitalize(et.singular) + "ScriptExecution"); reg.get(entity_path + "/scripts/{script_id}/executions/{execution_id}", @@ -1285,7 +1465,8 @@ void RESTServer::setup_routes() { .tag("Scripts") .summary(std::string("Get execution status for ") + et.singular) .description("Returns the current status of a script execution.") - .errors({501}) // No scripts backend configured + // DefaultScriptProvider::get_script -> NotFound / Internal only + .errors({501}) .operation_id(std::string("get") + capitalize(et.singular) + "ScriptExecution"); reg.put( @@ -1297,7 +1478,8 @@ void RESTServer::setup_routes() { .tag("Scripts") .summary(std::string("Terminate script execution for ") + et.singular) .description("Sends a control command (e.g., terminate) to a running script execution.") - .errors({501}) // No scripts backend configured + // DefaultScriptProvider::control_execution -> NotRunning + .errors({409, 501}) .operation_id(std::string("control") + capitalize(et.singular) + "ScriptExecution"); reg.del(entity_path + "/scripts/{script_id}/executions/{execution_id}", @@ -1307,7 +1489,8 @@ void RESTServer::setup_routes() { .tag("Scripts") .summary(std::string("Remove completed execution for ") + et.singular) .description("Removes a completed script execution record.") - .errors({501}) // No scripts backend configured + // DefaultScriptProvider::delete_execution -> AlreadyRunning + .errors({409, 501}) .operation_id(std::string("remove") + capitalize(et.singular) + "ScriptExecution"); } @@ -1495,10 +1678,12 @@ void RESTServer::setup_routes() { .description("Lists bulk-data descriptors for a subarea.") .operation_id("listSubareaBulkDataDescriptors"); - reg.binary_download("/areas/{area_id}/subareas/{subarea_id}/bulk-data/{category_id}/{file_id}", - [this](http::TypedRequest req) -> http::Result { - return bulkdata_handlers_->download(req); - }) + reg.binary_download( + "/areas/{area_id}/subareas/{subarea_id}/bulk-data/{category_id}/{file_id}", + [this](http::TypedRequest req) -> http::Result { + return bulkdata_handlers_->download(req); + }, + handlers::BulkDataHandlers::download_media_types()) .tag("Bulk Data") .summary("Download bulk-data file for subarea") .description("Downloads a bulk-data file for a subarea.") @@ -1524,10 +1709,12 @@ void RESTServer::setup_routes() { .description("Lists bulk-data descriptors for a subcomponent.") .operation_id("listSubcomponentBulkDataDescriptors"); - reg.binary_download("/components/{component_id}/subcomponents/{subcomponent_id}/bulk-data/{category_id}/{file_id}", - [this](http::TypedRequest req) -> http::Result { - return bulkdata_handlers_->download(req); - }) + reg.binary_download( + "/components/{component_id}/subcomponents/{subcomponent_id}/bulk-data/{category_id}/{file_id}", + [this](http::TypedRequest req) -> http::Result { + return bulkdata_handlers_->download(req); + }, + handlers::BulkDataHandlers::download_media_types()) .tag("Bulk Data") .summary("Download bulk-data file for subcomponent") .description("Downloads a bulk-data file for a subcomponent.") @@ -1562,6 +1749,9 @@ void RESTServer::setup_routes() { .tag("Faults") .summary("List all faults globally") .description("Retrieve all faults across the system.") + // 503 when the fault store cannot be read - same branch as the + // per-entity list, and equally out of the recorder's reach. + .errors({503}) .operation_id("listAllFaults") .query(); @@ -1579,6 +1769,24 @@ void RESTServer::setup_routes() { .response_header(204, openapi::ResponseHeader{"X-Medkit-Local-Only", "`true` when only the local FaultManager was cleared; faults held " "by aggregated peers are untouched and must be cleared per peer."}) + // Reads `X-Client-Id` like every lock-guarded write, but deliberately not + // `.lock_guarded()`: FaultHandlers::clear_all_faults_global never answers + // 409. It consults the lock manager per fault and *skips* the ones on + // entities locked by somebody else, then answers 204 anyway. Marking it + // lock-guarded would publish a 409 this route cannot return - the exact + // defect the marker exists to make visible. + // + // Nothing on the response reports which faults were skipped. The + // `X-Medkit-Local-Only` header declared above is set unconditionally and + // is about aggregated peers, not locks - do not read it as the skip + // signal. A client that needs to know re-reads the entity's faults. + .header_param("X-Client-Id", + "Identifies the calling client for lock ownership. Faults on entities locked by " + "a different client are silently skipped rather than cleared, and the request " + "still answers 204 - re-read the entity's faults to see what survived.", + false, nlohmann::json{{"type", "string"}}) + // 503 when the fault store cannot be read - see the list route above. + .errors({503}) .operation_id("clearAllFaults") .query(); @@ -1653,6 +1861,11 @@ void RESTServer::setup_routes() { .description("Prepares an update for execution (downloads, validates).") .success_description("Update preparation started") .gated_on(updates_available, kUpdate501) + // 409 when another update is already in progress or this one is being + // deleted (update_handlers.cpp maps UpdateErrorCode::InProgress and + // ::Deleting). The recorder cannot reach it: no fixture drives two + // overlapping updates. + .errors({409}) .operation_id("prepareUpdate"); reg.put>( @@ -1666,6 +1879,11 @@ void RESTServer::setup_routes() { .description("Starts executing a prepared update.") .success_description("Update execution started") .gated_on(updates_available, kUpdate501) + // 409 when another update is already in progress or this one is being + // deleted (update_handlers.cpp maps UpdateErrorCode::InProgress and + // ::Deleting). The recorder cannot reach it: no fixture drives two + // overlapping updates. + .errors({409}) .operation_id("executeUpdate"); reg.put>( @@ -1679,6 +1897,11 @@ void RESTServer::setup_routes() { .description("Runs a fully automated update (prepare + execute).") .success_description("Automated update started") .gated_on(updates_available, kUpdate501) + // 409 when another update is already in progress or this one is being + // deleted (update_handlers.cpp maps UpdateErrorCode::InProgress and + // ::Deleting). The recorder cannot reach it: no fixture drives two + // overlapping updates. + .errors({409}) .operation_id("automateUpdate"); reg.get("/updates/{update_id}", @@ -1699,6 +1922,11 @@ void RESTServer::setup_routes() { .summary("Delete update") .description("Removes an update registration.") .gated_on(updates_available, kUpdate501) + // 409 when another update is already in progress or this one is being + // deleted (update_handlers.cpp maps UpdateErrorCode::InProgress and + // ::Deleting). The recorder cannot reach it: no fixture drives two + // overlapping updates. + .errors({409}) .operation_id("deleteUpdate"); // === Authentication === @@ -1773,7 +2001,16 @@ void RESTServer::setup_routes() { .tag("Lifecycle") .summary(std::string("Request lifecycle transition '") + action + "'") .success_description("Lifecycle transition accepted") - .errors({501}) // No LifecycleProvider, or the provider reports the transition unsupported + // 501: no LifecycleProvider, or the provider reports the transition + // unsupported. 403 and 409 come from the same total mapper + // (`to_error_info`, lifecycle_handlers.cpp): the provider can refuse + // the transition outright (AccessDenied) or report the entity is not + // in a state that allows it (PreconditionFailed). Neither is the auth + // middleware's 403 or a lock 409 - they are the provider's own + // answers, so they are declared here whether or not auth is on. The + // recorder cannot reach them: the fixture has no provider at all, so + // every run stops at the 501. + .errors({403, 409, 501}) .operation_id(std::string("put").append(entity_cap).append("Status").append(action_cap)); } @@ -1783,7 +2020,9 @@ void RESTServer::setup_routes() { }) .tag("Lifecycle") .summary(std::string("Get ") + et_lc.second + " lifecycle status") - .errors({501}) // LifecycleProvider reports the entity unsupported + // 501 when the provider reports the entity unsupported; 403/409 from + // the same mapper - see the transition routes above. + .errors({403, 409, 501}) .operation_id(std::string("get") + entity_cap + "Status"); } diff --git a/src/ros2_medkit_gateway/src/openapi/route_registry.hpp b/src/ros2_medkit_gateway/src/openapi/route_registry.hpp index a728de1ec..56c182916 100644 --- a/src/ros2_medkit_gateway/src/openapi/route_registry.hpp +++ b/src/ros2_medkit_gateway/src/openapi/route_registry.hpp @@ -100,6 +100,24 @@ class RouteEntry { RouteEntry & description(const std::string & desc); RouteEntry & response(int status_code, const std::string & desc); RouteEntry & response(int status_code, const std::string & desc, const nlohmann::json & schema); + + /// Declare a response whose body is **not** JSON. + /// + /// Each media type becomes its own `content` entry with an empty Media Type + /// Object - deliberately **no schema**. Two independent reasons: + /// `{"type":"string","format":"binary"}` is an OpenAPI 3.0 idiom that 3.1 + /// dropped (3.1 aligned with JSON Schema 2020-12, where `format: binary` has + /// no meaning and a `string` type actively misdescribes raw bytes); and the + /// SSE routes emit three different frame shapes, so one schema here would be + /// wrong for at least two of them. + /// + /// `schema` is accepted for signature symmetry with the JSON overloads and + /// must be empty; a non-empty schema is recorded and reported by + /// `validate_completeness()` rather than published against a media type it + /// may not describe. + RouteEntry & response(int status_code, const std::string & desc, const nlohmann::json & schema, + const std::vector & content_types); + RouteEntry & request_body(const std::string & desc, const nlohmann::json & schema, const std::string & content_type = "application/json"); @@ -184,6 +202,44 @@ class RouteEntry { /// success and redirect statuses. RouteEntry & errors(std::initializer_list codes); + /// Same, for a set computed at run time rather than written at the call site. + /// Exists so a route can declare `handlers::parameter_error_statuses()` - + /// derived by running the classifier over its whole enum - instead of a + /// hand-copied list that goes stale when the enum grows. + RouteEntry & errors(const std::vector & codes); + + /// Declare that this route takes part in entity locking: it reads the + /// caller's `X-Client-Id` and answers 409 when the entity's resource + /// collection is locked by a different client. + /// + /// Three declarations in one call because they are one contract - the + /// request header, the 409, and an `x-medkit-lock-guarded: true` operation + /// extension a generated client or a contract test can select on. Splitting + /// them is what lets a route publish two thirds of the contract. + /// + /// **Hand-applied, and only half-checked - do not read it as derived.** The + /// header read that decides the 409 lives in + /// `HandlerContext::validate_lock_access`, which 12 handlers call; a + /// registration cannot see through that call, and the document is + /// regenerated per `/docs` request rather than captured at registration + /// time. Nothing therefore infers this marker from the handler: omitting the + /// call on a new lock-checking route ships a route that can 409 without + /// saying so, and no build or test failure follows. + /// `test_openapi_contract.test.py::test_lock_guarded_set_matches_the_handlers` + /// pins the marked set against a hand-maintained list, which catches the + /// *document* drifting from that list - not the list drifting from the + /// handlers. + RouteEntry & lock_guarded(); + + /// Declare the `X-Medkit-No-Fan-Out` request header this route reads. + /// + /// Presence-only: `TypedRequest::fan_out_disabled()` and the + /// `fan_out_helpers` guards test `has_header` and never look at the value. + /// The declared schema is therefore a bare string, not a boolean - a client + /// sending `false` still suppresses fan-out, and a boolean schema would + /// promise the opposite. + RouteEntry & fan_out_aware(); + /// This route can only ever return `code`. Clears every other response and /// suppresses the blanket 400/404/500 injection. RouteEntry & only_status(int code, const std::string & desc); @@ -232,6 +288,8 @@ class RouteEntry { bool alternates_{false}; /// Set by mark_partial_content(); emitted as `x-medkit-partial-content: true`. bool partial_content_{false}; + /// Set by lock_guarded(); emitted as `x-medkit-lock-guarded: true`. + bool lock_guarded_{false}; /// Set by only_status(); suppresses the blanket 400/404/500 injection. bool only_status_{false}; /// Set by the *attachments* body-less typed `put` overload - the @@ -261,9 +319,21 @@ class RouteEntry { /// aggregate predate the field and must keep compiling warning-free under /// -Wmissing-field-initializers. std::vector headers{}; + /// Media types this response's body can carry. Empty is the JSON default: + /// the emitter writes `application/json` iff `schema` is non-empty, which + /// is what every DTO-backed route wants and keeps a 204 content-free. + /// Non-empty means a non-JSON body, and each entry is emitted with no + /// schema - see the four-argument `response()` for why. NSDMI for the same + /// aggregate-initialisation reason as `headers`. + std::vector content_types{}; }; std::map responses_; + /// Statuses whose non-JSON `response()` also carried a schema. The schema was + /// dropped rather than published against a media type it may not describe; + /// kept so validate_completeness() reports the miscall. + std::vector schema_on_non_json_statuses_; + /// Statuses passed to response_header() that no response declares. Kept so /// validate_completeness() reports the miscall rather than the document /// silently losing a header the handler sets. @@ -448,8 +518,22 @@ class RouteRegistry { std::function(http::TypedRequest)> stream_factory); /// Register a binary download (range-aware where the provider supports it). + /// + /// `media_types` is what the route publishes as the response body's possible + /// media types, and it is a required argument rather than a default so a new + /// download route cannot inherit somebody else's answer. It must cover every + /// value the handler can put in `BinaryResponse::content_type`; where that + /// set is open (a store that echoes a client-supplied type) the list carries + /// `*/*` as its catch-all alongside the types that *are* derivable. + /// + /// The helper owns the whole Range contract - 200, 206, `Accept-Ranges`, + /// `Content-Range`, the `Range` parameter, and the `x-medkit-partial-content` + /// marker - because cpp-httplib produces the 206 from the request, not from + /// the handler. Declaring any part of it at a call site would let a route + /// publish some of the contract and not the rest. RouteEntry & binary_download(const std::string & openapi_path, - std::function(http::TypedRequest)> handler); + std::function(http::TypedRequest)> handler, + const std::vector & media_types); /// Register a `multipart/form-data` upload endpoint. The handler receives /// the typed request plus the parsed multipart body, and returns a typed @@ -514,6 +598,17 @@ class RouteRegistry { rate_limit_enabled_ = enabled; } + /// Set whether peer aggregation is enabled (controls 502/503 in OpenAPI + /// output). `aggregation.enabled` defaults false and the AggregationManager + /// is only constructed when it is set, so with aggregation off an entity can + /// never be remote and neither status is reachable. When it is on, any route + /// that resolves an entity may find that entity owned by a peer and answer + /// with the gateway's own peer-failure statuses instead of the handler's - + /// see `entity_scoped()` for which routes those are. + void set_aggregation_enabled(bool enabled) { + aggregation_enabled_ = enabled; + } + /// Escape hatch for JSON routes without typed DTOs (e.g. the fault-trigger /// CRUD): registers a raw cpp-httplib handler under an OpenAPI-style path so /// the route shows up in the generated spec, Swagger UI and the endpoint @@ -539,6 +634,15 @@ class RouteRegistry { std::deque routes_; bool auth_enabled_{false}; bool rate_limit_enabled_{false}; + bool aggregation_enabled_{false}; + + /// True when `openapi_path` carries one of the four entity-id path + /// parameters, which is exactly the condition under which a handler can call + /// `HandlerContext::validate_entity_for_route` and therefore hit the + /// peer-forwarding branch. Derived from the path rather than from a list: + /// the entity id has to come from the path for the lookup to happen at all, + /// so there is no route that forwards without one. + static bool entity_scoped(const std::string & openapi_path); // --------------------------------------------------------------------------- // Typed-handler wrapper helpers. diff --git a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp index cce495cb7..e04a99fb8 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp @@ -57,10 +57,6 @@ nlohmann::json SchemaBuilder::generic_object_schema() { return {{"type", "object"}}; } -nlohmann::json SchemaBuilder::binary_schema() { - return {{"type", "string"}, {"format", "binary"}}; -} - nlohmann::json SchemaBuilder::ref(const std::string & schema_name) { return {{"$ref", "#/components/schemas/" + schema_name}}; } diff --git a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp index f0142fd11..7deda1596 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp @@ -56,9 +56,6 @@ class SchemaBuilder { /// Generic object schema (for dynamic ROS 2 message payloads) static nlohmann::json generic_object_schema(); - /// Binary content schema (for file downloads) - static nlohmann::json binary_schema(); - /// Returns a $ref JSON object pointing to a named component schema. static nlohmann::json ref(const std::string & schema_name); diff --git a/src/ros2_medkit_gateway/test/test_lock_manager.cpp b/src/ros2_medkit_gateway/test/test_lock_manager.cpp index fbb370faa..87fbf605d 100644 --- a/src/ros2_medkit_gateway/test/test_lock_manager.cpp +++ b/src/ros2_medkit_gateway/test/test_lock_manager.cpp @@ -15,6 +15,7 @@ #include #include +#include #include #include @@ -76,6 +77,65 @@ class LockManagerTest : public ::testing::Test { } }; +// ========================================================================= +// Status surface - what the OpenAPI declarations for extend/release rest on +// ========================================================================= + +// The registrations for `extend{E}Lock` / `release{E}Lock` declare 403 on top +// of the blanket 400/404/500, and deliberately NOT 409. Both halves of that are +// claims about this class, so they are checked here rather than trusted: the +// verbs must be able to produce 403, and must never produce 409 (only `acquire` +// conflicts). Driving every reachable failure path is what makes the second +// half meaningful - an assertion that "no 409 was seen" is worthless if the +// paths that could produce one were never taken. +TEST_F(LockManagerTest, extend_and_release_answer_only_400_403_404) { + std::set seen; + auto record = [&seen](int status) { + seen.insert(status); + }; + + LockManager mgr(cache_, make_config()); + auto held = mgr.acquire("comp1", "client_a", {}, 300); + ASSERT_TRUE(held.has_value()) << held.error().message; + + // No lock on this entity at all. + auto missing_extend = mgr.extend("comp2", "client_a", 60); + ASSERT_FALSE(missing_extend.has_value()); + record(missing_extend.error().status_code); + auto missing_release = mgr.release("comp2", "client_a"); + ASSERT_FALSE(missing_release.has_value()); + record(missing_release.error().status_code); + + // Lock exists but belongs to somebody else - the 403 the routes declare. + auto wrong_extend = mgr.extend("comp1", "client_b", 60); + ASSERT_FALSE(wrong_extend.has_value()); + record(wrong_extend.error().status_code); + EXPECT_EQ(wrong_extend.error().code, "lock-not-owner"); + auto wrong_release = mgr.release("comp1", "client_b"); + ASSERT_FALSE(wrong_release.has_value()); + record(wrong_release.error().status_code); + EXPECT_EQ(wrong_release.error().code, "lock-not-owner"); + + // Invalid extension durations. + auto zero = mgr.extend("comp1", "client_a", 0); + ASSERT_FALSE(zero.has_value()); + record(zero.error().status_code); + auto too_long = mgr.extend("comp1", "client_a", 99999999); + ASSERT_FALSE(too_long.has_value()); + record(too_long.error().status_code); + + // Locking switched off. + LockManager disabled(cache_, make_config(false)); + auto off_extend = disabled.extend("comp1", "client_a", 60); + ASSERT_FALSE(off_extend.has_value()); + record(off_extend.error().status_code); + auto off_release = disabled.release("comp1", "client_a"); + ASSERT_FALSE(off_release.has_value()); + record(off_release.error().status_code); + + EXPECT_EQ(seen, (std::set{400, 403, 404})) << "extend/release status surface changed"; +} + // ========================================================================= // Acquire tests // ========================================================================= diff --git a/src/ros2_medkit_gateway/test/test_route_registry.cpp b/src/ros2_medkit_gateway/test/test_route_registry.cpp index 48be08200..af1ab4495 100644 --- a/src/ros2_medkit_gateway/test/test_route_registry.cpp +++ b/src/ros2_medkit_gateway/test/test_route_registry.cpp @@ -560,6 +560,89 @@ TEST_F(RouteRegistryTest, OptionalHeaderParamHasRequiredFalse) { EXPECT_TRUE(found_header) << "Optional header param not found"; } +// ============================================================================= +// lock_guarded() / fan_out_aware() +// ============================================================================= + +namespace { + +/// Find a parameter by name in an operation's parameter array. +const nlohmann::json * find_param(const nlohmann::json & params, const std::string & name) { + for (const auto & p : params) { + if (p.contains("name") && p["name"] == name) { + return &p; + } + } + return nullptr; +} + +} // namespace + +TEST_F(RouteRegistryTest, LockGuardedDeclaresHeaderStatusAndMarker) { + seed_post(registry_, "/apps/{app_id}/data/{data_id}").tag("Data").lock_guarded(); + + auto paths = registry_.to_openapi_paths(); + auto & op = paths["/apps/{app_id}/data/{data_id}"]["post"]; + + // contains() first: get() on an absent key throws json::type_error, + // which reports as a crashed test rather than a failed expectation. + ASSERT_TRUE(op.contains("x-medkit-lock-guarded")) << "marker not emitted"; + EXPECT_TRUE(op["x-medkit-lock-guarded"].get()); + ASSERT_TRUE(op["responses"].contains("409")) << "the 409 the marker promises is missing"; + + const auto * client_id = find_param(op["parameters"], "X-Client-Id"); + ASSERT_NE(client_id, nullptr) << "X-Client-Id not declared"; + EXPECT_EQ((*client_id)["in"], "header"); + // Optional: a header-less request succeeds while nothing is locked, so + // declaring it required would describe a gateway that rejects it outright. + EXPECT_FALSE((*client_id)["required"].get()); + EXPECT_EQ((*client_id)["schema"]["type"], "string"); +} + +TEST_F(RouteRegistryTest, UnguardedRouteCarriesNoLockMarker) { + seed_get(registry_, "/apps/{app_id}/data").tag("Data"); + + auto paths = registry_.to_openapi_paths(); + auto & op = paths["/apps/{app_id}/data"]["get"]; + + EXPECT_FALSE(op.contains("x-medkit-lock-guarded")); + EXPECT_FALSE(op["responses"].contains("409")); +} + +TEST_F(RouteRegistryTest, LockGuardedMarkerWithoutA409IsReported) { + // only_status() clears declared_errors_, so it is the one way to keep the + // marker while dropping the status it promises. The registry has to report + // that rather than publish a marker no client can act on. + seed_del(registry_, "/apps/{app_id}/bulk-data/{file_id}") + .tag("Bulk Data") + .lock_guarded() + .only_status(501, "Not implemented"); + + auto issues = registry_.validate_completeness(); + bool reported = false; + for (const auto & issue : issues) { + if (issue.severity == ValidationIssue::Severity::kError && + issue.message.find("lock_guarded() marker without a declared 409") != std::string::npos) { + reported = true; + } + } + EXPECT_TRUE(reported) << "a marker without its 409 passed validate_completeness()"; +} + +TEST_F(RouteRegistryTest, FanOutAwareDeclaresPresenceOnlyHeader) { + seed_get(registry_, "/apps/{app_id}/logs").tag("Logs").fan_out_aware(); + + auto paths = registry_.to_openapi_paths(); + const auto * param = find_param(paths["/apps/{app_id}/logs"]["get"]["parameters"], "X-Medkit-No-Fan-Out"); + + ASSERT_NE(param, nullptr) << "X-Medkit-No-Fan-Out not declared"; + EXPECT_EQ((*param)["in"], "header"); + EXPECT_FALSE((*param)["required"].get()); + // The gateway tests has_header and never reads the value, so `false` also + // suppresses fan-out. A boolean schema would promise the opposite. + EXPECT_EQ((*param)["schema"]["type"], "string"); +} + // ============================================================================= // to_endpoint_list (Fix 23) // ============================================================================= @@ -1007,6 +1090,101 @@ TEST_F(RouteRegistryTest, RateLimitedStatusAbsentWhenLimiterIsOff) { EXPECT_FALSE(paths["/items"]["get"]["responses"].contains("429")); } +TEST_F(RouteRegistryTest, PeerFailureStatusesFollowTheAggregationGate) { + // With aggregation on, an entity can be owned by a peer, and the proxy path + // inside validate_entity_for_route answers 502 (peer unknown / unreachable / + // oversized response) or 503 (this gateway is shutting down) instead of the + // handler. Only entity-scoped routes can reach it: the entity id has to come + // from the path for the lookup to happen at all. + registry_.set_aggregation_enabled(true); + seed_get(registry_, "/apps/{app_id}/data").tag("Test").summary("List data"); + seed_get(registry_, "/health").tag("Test").summary("Health"); + + auto paths = registry_.to_openapi_paths(); + auto & entity = paths["/apps/{app_id}/data"]["get"]["responses"]; + EXPECT_EQ(entity["502"]["$ref"].get(), "#/components/responses/GenericError"); + EXPECT_EQ(entity["503"]["$ref"].get(), "#/components/responses/GenericError"); + + // A route with no entity to resolve never forwards, so declaring the peer + // statuses there would document an outcome it cannot produce. + auto & health = paths["/health"]["get"]["responses"]; + ASSERT_TRUE(health.contains("200")) << "route missing; absence check would be vacuous"; + EXPECT_FALSE(health.contains("502")); + EXPECT_FALSE(health.contains("503")); +} + +TEST_F(RouteRegistryTest, PeerFailureStatusesRespectOnlyStatus) { + // `only_status` asserts the route has exactly one outcome. The forward + // happens inside the handler, not ahead of it, so a single-outcome route - + // the data-categories / data-groups stubs ignore the request entirely and + // never resolve an entity - cannot reach the peer path. Adding 502/503 there + // would publish two statuses the route can never return, on the one + // declaration whose whole meaning is that it returns one. + registry_.set_aggregation_enabled(true); + seed_get(registry_, "/apps/{app_id}/data-categories") + .tag("Test") + .summary("Data categories") + .only_status(501, "Not implemented"); + + auto paths = registry_.to_openapi_paths(); + auto & responses = paths["/apps/{app_id}/data-categories"]["get"]["responses"]; + EXPECT_TRUE(responses.contains("501")); + EXPECT_FALSE(responses.contains("502")); + EXPECT_FALSE(responses.contains("503")); + // What `only_status` constrains is the set of statuses the *handler* can + // produce, and 501 is still the whole of that set. It was previously spelled + // `responses.size() == 1`, which silently also asserted that nothing outside + // the handler contributes a status - true until cpp-httplib's pre-routing + // Range rejection was declared. 416 is answered before this route is reached + // (Server::process_request, vendored httplib.h:6616-6622), so excluding it + // here would document the stub as unable to answer a status it demonstrably + // does answer. Narrowed to the claim that was actually meant, rather than + // exempting the route. + auto handler_statuses = responses; + handler_statuses.erase("416"); + EXPECT_EQ(handler_statuses.size(), 1U) << responses.dump(); +} + +TEST_F(RouteRegistryTest, PeerFailureStatusesAbsentWhenAggregationIsOff) { + // aggregation.enabled defaults false and the AggregationManager is only + // constructed when it is set, so no entity can be remote and neither status + // is reachable. + seed_get(registry_, "/apps/{app_id}/data").tag("Test").summary("List data"); + auto paths = registry_.to_openapi_paths(); + auto & responses = paths["/apps/{app_id}/data"]["get"]["responses"]; + ASSERT_TRUE(responses.contains("200")) << "route missing; absence check would be vacuous"; + EXPECT_FALSE(responses.contains("502")); + EXPECT_FALSE(responses.contains("503")); +} + +TEST_F(RouteRegistryTest, RouteDeclaredStatusWinsOverTheMiddlewareComponent) { + // A handler can answer a status the middleware also owns: the script + // manager's concurrency 429 collides with the rate limiter's, and a + // lifecycle provider's AccessDenied 403 collides with the auth middleware's. + // OpenAPI allows exactly one response object per status, so one of the two + // descriptions has to lose. The route's own declaration wins, because + // `add_response_ref` is first-wins and the `errors()` loop runs first. + // + // What that costs is the *headers*: the operation below documents 429 as the + // route's GenericError and therefore without `Retry-After` / `X-RateLimit-*`. + // The body shape is unaffected - Unauthorized, Forbidden and RateLimited all + // point at the same GenericError schema - so the loss is the header list and + // the prose, not the payload. This test exists so that precedence is a + // decision on record rather than an accident of statement order. + registry_.set_auth_enabled(true); + registry_.set_rate_limit_enabled(true); + seed_get(registry_, "/items").tag("Test").summary("List items").errors({403, 429}); + + auto paths = registry_.to_openapi_paths(); + auto & responses = paths["/items"]["get"]["responses"]; + EXPECT_EQ(responses["429"]["$ref"].get(), "#/components/responses/GenericError"); + EXPECT_EQ(responses["403"]["$ref"].get(), "#/components/responses/GenericError"); + // 401 is not declared by the route, so the middleware component still wins + // there - which is what makes this a precedence test rather than a test that + // the middleware refs were dropped altogether. + EXPECT_EQ(responses["401"]["$ref"].get(), "#/components/responses/Unauthorized"); +} + // ============================================================================= // Request-body completeness reads the registration, not the HTTP method // ============================================================================= @@ -1107,3 +1285,198 @@ TEST_F(RouteRegistryTest, TypedPutWithABodyIsStillSatisfiedAutomatically) { EXPECT_FALSE(has_error_mentioning(registry_, "request body")); EXPECT_TRUE(registry_.to_openapi_paths()["/items"]["put"].contains("requestBody")); } + +// ============================================================================= +// Media types: a non-JSON body is declared by media type, without a schema +// ============================================================================= + +namespace { + +Result seed_blob_handler(TypedRequest /*req*/) { + ros2_medkit_gateway::http::BinaryResponse resp; + resp.content_type = "application/octet-stream"; + resp.total_size = 0; + resp.provider = [](uint64_t, uint64_t, httplib::DataSink & sink) -> bool { + sink.done(); + return false; + }; + return resp; +} + +RouteEntry & seed_blob(RouteRegistry & reg, const std::string & path, const std::vector & media_types) { + std::function(TypedRequest)> h = &seed_blob_handler; + return reg.binary_download(path, std::move(h), media_types); +} + +} // namespace + +TEST_F(RouteRegistryTest, NonJsonResponseIsDeclaredUnderItsOwnMediaTypeWithoutASchema) { + // `format: binary` under `application/json` was wrong twice over: the media + // type is not JSON, and `{"type":"string","format":"binary"}` is an OpenAPI + // 3.0 idiom that 3.1 dropped. The absence of a schema is the assertion, not + // an oversight - there is nothing truthful to put there for raw bytes. + seed_blob(registry_, "/blob", {"application/octet-stream"}).tag("Test").summary("Download"); + + auto paths = registry_.to_openapi_paths(); + auto & content = paths["/blob"]["get"]["responses"]["200"]["content"]; + ASSERT_TRUE(content.contains("application/octet-stream")); + EXPECT_FALSE(content.contains("application/json")) << "a binary body must not be advertised as JSON"; + EXPECT_FALSE(content["application/octet-stream"].contains("schema")); + EXPECT_EQ(nlohmann::json(content["application/octet-stream"]), nlohmann::json::object()); +} + +TEST_F(RouteRegistryTest, EveryDeclaredMediaTypeReachesTheDocument) { + // The open-set case: the concrete types AND the catch-all have to survive to + // the document, because the route's whole claim is that it serves the three + // it can name plus anything the store recorded. + seed_blob(registry_, "/blob", {"application/x-mcap", "application/x-sqlite3", "application/octet-stream", "*/*"}) + .tag("Test") + .summary("Download"); + + auto paths = registry_.to_openapi_paths(); + auto & content = paths["/blob"]["get"]["responses"]["200"]["content"]; + for (const char * expected : {"application/x-mcap", "application/x-sqlite3", "application/octet-stream", "*/*"}) { + EXPECT_TRUE(content.contains(expected)) << expected << " was declared but is missing from the document"; + } +} + +TEST_F(RouteRegistryTest, MultiRangeMediaTypeIsDeclaredOnThe206Only) { + // cpp-httplib answers a multi-range request by rewriting Content-Type to + // `multipart/byteranges` and generating a boundary (apply_ranges, the + // req.ranges.size() > 1 branch). The 200 can never carry it, so declaring it + // there would be the same over-declaration this document is being cleaned of. + seed_blob(registry_, "/blob", {"application/octet-stream"}).tag("Test").summary("Download"); + + auto paths = registry_.to_openapi_paths(); + auto & responses = paths["/blob"]["get"]["responses"]; + EXPECT_TRUE(responses["206"]["content"].contains("multipart/byteranges")); + EXPECT_TRUE(responses["206"]["content"].contains("application/octet-stream")); + EXPECT_FALSE(responses["200"]["content"].contains("multipart/byteranges")); +} + +TEST_F(RouteRegistryTest, BinaryDownloadDeclaresTheRangeRequestHeader) { + // The request half of the contract the 206/Content-Range/Accept-Ranges + // declarations answer. Optional, because omitting it serves the whole body. + seed_blob(registry_, "/blob", {"application/octet-stream"}).tag("Test").summary("Download"); + + auto paths = registry_.to_openapi_paths(); + const auto & params = paths["/blob"]["get"]["parameters"]; + bool found = false; + for (const auto & p : params) { + if (p.value("name", "") == "Range") { + found = true; + EXPECT_EQ(p.value("in", ""), "header"); + EXPECT_FALSE(p.value("required", true)) << "a download without Range must still serve the whole body"; + EXPECT_FALSE(p.value("description", "").empty()); + } + } + EXPECT_TRUE(found) << "Accept-Ranges is advertised but no Range parameter is declared"; +} + +TEST_F(RouteRegistryTest, NonJsonResponseGivenASchemaReportsItInsteadOfPublishingIt) { + // The overload takes a schema argument for symmetry with the JSON ones. A + // caller that passes one has misunderstood the contract, and the schema is + // dropped rather than attached to a media type it may not describe - so the + // miscall must surface through the same channel as the other route-metadata + // defects, not vanish. + registry_ + .get("/thing", + std::function(TypedRequest)>(&seed_get_handler)) + .tag("Test") + .summary("Thing") + .response(200, "Bytes", json{{"type", "string"}}, {"application/octet-stream"}); + + EXPECT_TRUE(has_error_mentioning(registry_, "dropped the schema")); + auto paths = registry_.to_openapi_paths(); + EXPECT_FALSE(paths["/thing"]["get"]["responses"]["200"]["content"]["application/octet-stream"].contains("schema")); +} + +TEST_F(RouteRegistryTest, NonJsonSuccessSatisfiesTheSchemaCompletenessCheck) { + // The completeness gate reads the declared media type rather than sniffing + // the summary for the word "stream", so an SSE or download route is complete + // because of what it declares, not because of what it is called. + seed_blob(registry_, "/blob", {"application/octet-stream"}).tag("Test").summary("Download"); + + EXPECT_FALSE(has_error_mentioning(registry_, "Missing response schema")); +} + +TEST_F(RouteRegistryTest, SchemaLessJsonSuccessIsStillReported) { + // The exemption is for a body the media type already describes, so it turns + // on the type being non-JSON - not merely on `content_types` being set. A 2xx + // declaring `application/json` with no schema is exactly what the check + // exists to catch, and accepting it would make the C++ gate looser than both + // the documented rule and the served-document gate in test_health. + registry_ + .get("/thing", + std::function(TypedRequest)>(&seed_get_handler)) + .tag("Test") + .summary("Thing") + .response(200, "A JSON body with no shape", json{}, {"application/json"}); + + EXPECT_TRUE(has_error_mentioning(registry_, "Missing response schema")); +} + +TEST_F(RouteRegistryTest, MixedMediaTypeSuccessIsExemptOnTheNonJsonEntry) { + // ...and one non-JSON entry alongside JSON is still exempt, matching how the + // served-document gate walks the content map. Without this the tightening + // above could be over-read into "any mention of application/json disqualifies + // the route", which would report a route that legitimately serves both. + seed_blob(registry_, "/blob", {"application/json", "application/octet-stream"}).tag("Test").summary("Download"); + + EXPECT_FALSE(has_error_mentioning(registry_, "Missing response schema")); +} + +TEST_F(RouteRegistryTest, AJsonRouteWithNoSchemaIsStillReported) { + // ...and the exemption must not have widened into "any 2xx is fine". A route + // declaring a 200 with neither a schema nor a media type is the case the + // check exists for. + registry_.raw("get", "/plain", [](const httplib::Request &, httplib::Response &) {}) + .tag("Test") + .summary("Plain") + .response(200, "Something"); + + EXPECT_TRUE(has_error_mentioning(registry_, "Missing response schema")); +} + +TEST_F(RouteRegistryTest, EveryDocumentedRouteDeclaresTheFrameworkAnsweredRangeRejection) { + // 416 comes from cpp-httplib's own pre-routing Range parsing + // (Server::process_request, vendored httplib.h:6616-6622), so it is reachable + // on every operation - including paths that do not exist - and no handler + // ever runs. That puts it outside what the status recorder can observe, which + // is why this framework-level constant is pinned here instead. + // + // It refs GenericError like the other error statuses. Note that the vendored + // header alone does NOT show that: cpp-httplib writes 416 with an empty body, + // and it is the gateway's own `set_error_handler` + // (RESTServer::setup_global_error_handlers) that fills every body-less error + // response with a GenericError. This registry harness installs no such + // handler, so a wire assertion made *here* would show an empty body and + // mislead; the wire half lives in the integration suite, against a real + // gateway. + seed_get(registry_, "/health").tag("Server").summary("Health"); + seed_post(registry_, "/items").tag("Test").summary("Create"); + seed_del(registry_, "/items/{item_id}").tag("Test").summary("Delete"); + + auto paths = registry_.to_openapi_paths(); + for (const auto & [path, item] : paths.items()) { + for (const auto & [method, op] : item.items()) { + ASSERT_TRUE(op["responses"].contains("416")) << method << " " << path << " does not declare 416"; + EXPECT_EQ(op["responses"]["416"].value("$ref", ""), "#/components/responses/GenericError") + << method << " " << path << ": 416 carries the same body as every other error status"; + } + } +} + +TEST_F(RouteRegistryTest, TheRangeRejectionSurvivesOnlyStatus) { + // only_status() says the *handler* has one outcome. 416 is decided before the + // handler is reached, so - like the auth middleware's 401/403 - it is outside + // that guard. Clearing it here would document the 501 stubs as unable to + // answer a status they demonstrably do answer. + seed_get(registry_, "/stub").tag("Test").summary("Stub").only_status(501, "Not implemented"); + + auto paths = registry_.to_openapi_paths(); + const auto & responses = paths["/stub"]["get"]["responses"]; + EXPECT_TRUE(responses.contains("416")); + EXPECT_TRUE(responses.contains("501")); + EXPECT_FALSE(responses.contains("400")) << "only_status must still suppress the blanket handler statuses"; +} diff --git a/src/ros2_medkit_gateway/test/test_typed_route_registry.cpp b/src/ros2_medkit_gateway/test/test_typed_route_registry.cpp index b5b4da213..f4f236feb 100644 --- a/src/ros2_medkit_gateway/test/test_typed_route_registry.cpp +++ b/src/ros2_medkit_gateway/test/test_typed_route_registry.cpp @@ -496,7 +496,11 @@ Result range_download_handler(TypedRe RouteEntry & seed_download(RouteRegistry & reg, const std::string & path) { std::function(TypedRequest)> h = &range_download_handler; - return reg.binary_download(path, std::move(h)); + // Deliberately the single type this fixture's handler serves, with no + // catch-all: the production route needs `*/*` because its served set is open, + // but a closed list here is what lets the assertions below tell an exact + // declaration from a wildcard that would match anything. + return reg.binary_download(path, std::move(h), {"application/octet-stream"}); } } // namespace diff --git a/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/gateway_test_case.py b/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/gateway_test_case.py index f20a5837e..22b9cb57c 100644 --- a/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/gateway_test_case.py +++ b/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/gateway_test_case.py @@ -1079,3 +1079,47 @@ def encode_topic_path(topic_path): # Remove leading slash and encode the rest topic_path = topic_path[1:] return quote(topic_path, safe='') + + def assert_declared_media_type( + self, served_content_type, declared_content, *, where, exact, + ): + """Assert a served ``Content-Type`` is one the document declares. + + The Tier-2 half of the media-type contract: the OpenAPI ``content`` + keys for a response are only worth anything if the bytes on the wire + actually arrive under one of them. + + Parameters + ---------- + served_content_type : str + Raw ``Content-Type`` response header. Any parameters + (``; charset=...``, ``; boundary=...``) are stripped before + matching, since OpenAPI ``content`` keys carry none. + declared_content : dict + The operation's ``responses[code]['content']`` mapping. + where : str + Human-readable location for the failure message. + exact : bool + ``True`` requires a named media-type key and explicitly rejects a + match that only succeeds via the ``*/*`` catch-all - that is what + keeps the derivable half of an open set from silently degrading + into "the wildcard covers it". ``False`` accepts the catch-all and + asserts it is present, which is the open half of the same set. + + """ + media_type = served_content_type.split(';')[0].strip().lower() + declared = {k.lower() for k in declared_content} + self.assertTrue( + declared, f'{where}: response declares no content at all') + if exact: + self.assertIn( + media_type, declared, + f'{where}: served {media_type!r}, which the document does not name ' + f'(declares {sorted(declared)}). A wildcard does not count here - ' + 'this type is derivable from the code and must be declared.', + ) + return + self.assertTrue( + media_type in declared or '*/*' in declared, + f'{where}: served {media_type!r}, not covered by {sorted(declared)}', + ) 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 bd459fbfc..53344efc9 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 @@ -193,13 +193,20 @@ def test_docs_spec_completeness(self): if '$ref' in resp: has_schema = True elif 'content' in resp: - for ct in resp['content'].values(): + for media_type, ct in resp['content'].items(): if 'schema' in ct: has_schema = True - # SSE endpoints don't have JSON schema - summary = op.get('summary', '') - if 'SSE' in summary or 'stream' in summary.lower(): - has_schema = True + # A non-JSON body is fully described by its media + # type. `format: binary` is an OpenAPI 3.0 idiom + # 3.1 dropped, and the SSE families put three + # different shapes in `data:`, so there is nothing + # truthful to put in a schema here - the absence is + # the declaration. Deliberately keyed on the media + # type and not on 'content' being present: a JSON + # response still owes a schema, which is the + # coverage this rule exists for. + elif media_type != 'application/json': + has_schema = True # An operation that declares no 2xx at all cannot return a # success body to describe - the data-categories / data-groups # stubs declare only their 501. Deliberately narrow: an diff --git a/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py b/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py index afcc33462..613b71de5 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py @@ -42,6 +42,59 @@ HTTP_METHODS = {'get', 'post', 'put', 'delete', 'patch', 'head', 'options'} +# Operations the document must mark `x-medkit-lock-guarded`. +# +# HAND-MAINTAINED, and honestly so. The list cannot be generated: the header +# read that decides the 409 sits in `HandlerContext::validate_lock_access`, +# which 12 handlers across 6 files call, and mapping one of those call sites to +# an operationId would mean evaluating the runtime string concatenation inside +# the four-entity-type registration loop in `rest_server.cpp`. So what this +# list gates is the *document* drifting away from it - dropping a +# `.lock_guarded()` from a registration turns the suite red. It does NOT catch +# the list drifting away from the handlers: a new route that calls +# `validate_lock_access` and forgets both the decorator and an entry here +# passes. Adding a lock check to a handler means editing this list by hand. +# +# Grouped by the handler whose lock check they inherit. Entity types are +# `Area` / `Component` / `App` / `Function`; bulk-data writes exist only for +# `Component` / `App` (areas and functions get hidden 405 stubs). +EXPECTED_LOCK_GUARDED = { + # DataHandlers::put_data_item -> validate_lock_access("data") + 'putAreaDataItem', 'putComponentDataItem', 'putAppDataItem', + 'putFunctionDataItem', + # OperationHandlers::create_execution -> validate_lock_access("operations") + 'executeAreaOperation', 'executeComponentOperation', 'executeAppOperation', + 'executeFunctionOperation', + # OperationHandlers::update_execution -> validate_lock_access("operations") + 'updateAreaExecution', 'updateComponentExecution', 'updateAppExecution', + 'updateFunctionExecution', + # OperationHandlers::cancel_execution -> validate_lock_access("operations") + 'cancelAreaExecution', 'cancelComponentExecution', 'cancelAppExecution', + 'cancelFunctionExecution', + # ConfigHandlers::set_configuration -> validate_lock_access("configurations") + 'setAreaConfiguration', 'setComponentConfiguration', 'setAppConfiguration', + 'setFunctionConfiguration', + # ConfigHandlers::delete_configuration -> validate_lock_access("configurations") + 'deleteAreaConfiguration', 'deleteComponentConfiguration', + 'deleteAppConfiguration', 'deleteFunctionConfiguration', + # ConfigHandlers::delete_all_configurations -> validate_lock_access("configurations") + 'deleteAllAreaConfigurations', 'deleteAllComponentConfigurations', + 'deleteAllAppConfigurations', 'deleteAllFunctionConfigurations', + # FaultHandlers::clear_fault -> validate_lock_access("faults") + 'clearAreaFault', 'clearComponentFault', 'clearAppFault', + 'clearFunctionFault', + # FaultHandlers::clear_all_faults -> validate_lock_access("faults") + 'clearAllAreaFaults', 'clearAllComponentFaults', 'clearAllAppFaults', + 'clearAllFunctionFaults', + # LogHandlers::put_logs_configuration -> validate_lock_access("logs") + 'setAreaLogConfiguration', 'setComponentLogConfiguration', + 'setAppLogConfiguration', 'setFunctionLogConfiguration', + # BulkDataHandlers::upload -> validate_lock_access("bulk-data") + 'uploadComponentBulkData', 'uploadAppBulkData', + # BulkDataHandlers::remove -> validate_lock_access("bulk-data") + 'deleteComponentBulkData', 'deleteAppBulkData', +} + _SCRIPTS_DIR = tempfile.mkdtemp(prefix='medkit-contract-scripts-') PYTHON_SCRIPT = '#!/usr/bin/env python3\nimport json\nprint(json.dumps({"result": "ok"}))\n' @@ -296,6 +349,272 @@ def test_partial_content_routes_declare_the_range_response(self): f'{method.upper()} {path}: 200 without Accept-Ranges') self.assertTrue(marked, 'No partial-content routes in the document') + def header_params(self, op): + """Return {name: parameter} for every header parameter of an operation.""" + return { + p['name']: p + for p in op.get('parameters', []) + if p.get('in') == 'header' + } + + def test_no_response_declares_a_null_schema(self): + """No response object carries ``"schema": null``. + + A guard, not a fix for a known defect. ``nlohmann::json`` default- + constructs to ``null``, so any emitter that stops checking for an empty + schema before writing it publishes ``schema: null`` - which a code + generator reads as "a body typed null", not as "no body". + """ + offenders = [] + checked = 0 + for path, method, op in self.operations(): + for code, resp in op.get('responses', {}).items(): + for media_type, media in resp.get('content', {}).items(): + checked += 1 + if 'schema' in media and media['schema'] is None: + offenders.append( + f'{op.get("operationId")}: {code} {media_type}') + self.assertGreater(checked, 0, 'No response content to check') + self.assertEqual(offenders, [], f'null schema: {offenders}') + + def test_binary_downloads_are_not_declared_as_json(self): + """A range-aware download declares byte media types, never JSON. + + The download body is raw file content. Declaring it under + ``application/json`` told every generated client to parse a rosbag as + JSON; the schema that came with it, + ``{"type": "string", "format": "binary"}``, is an OpenAPI 3.0 idiom + that 3.1 dropped, so it was wrong on both axes at once. + """ + checked = 0 + for path, method, op in self.operations(): + if not op.get('x-medkit-partial-content'): + continue + for code in ('200', '206'): + content = op.get('responses', {}).get(code, {}).get('content') + self.assertTrue( + content, f'{method.upper()} {path}: {code} declares no content') + checked += 1 + self.assertNotIn( + 'application/json', content, + f'{method.upper()} {path}: {code} advertises a binary body as JSON') + for media_type, media in content.items(): + self.assertNotIn( + 'schema', media, + f'{method.upper()} {path}: {code} {media_type} carries a schema; ' + 'a non-JSON body is declared by media type alone') + self.assertGreater(checked, 0, 'No binary downloads in the document') + + def test_sse_routes_declare_the_event_stream_media_type(self): + """Every SSE route declares ``text/event-stream`` and no frame schema. + + The media type is what cpp-httplib is handed at + ``set_chunked_content_provider``, so the document and the wire come + from one fact. No schema: the three SSE families put different shapes + in ``data:``, and one schema would be wrong for two of them. + """ + streams = [] + for path, method, op in self.operations(): + content = op.get('responses', {}).get('200', {}).get('content', {}) + if 'text/event-stream' not in content: + continue + streams.append(f'{method.upper()} {path}') + self.assertEqual( + list(content), ['text/event-stream'], + f'{method.upper()} {path}: an event stream declares one media type') + self.assertNotIn( + 'schema', content['text/event-stream'], + f'{method.upper()} {path}: SSE frames have no single schema to declare') + # Four trigger-event streams (one per entity type), three subscription + # streams (apps / components / functions) and the global fault stream. + self.assertEqual( + len(streams), 8, f'expected 8 SSE routes, found {sorted(streams)}') + + def test_partial_content_routes_declare_the_range_request(self): + """The response half of the Range contract has a request half. + + ``Accept-Ranges`` invites the client to send ``Range``; an undeclared + ``Range`` parameter leaves a generated client no way to accept. + """ + checked = 0 + for path, method, op in self.operations(): + if not op.get('x-medkit-partial-content'): + continue + checked += 1 + where = f'{method.upper()} {path}' + headers = self.header_params(op) + self.assertIn('Range', headers, f'{where}: 206 declared without a Range parameter') + self.assertFalse( + headers['Range'].get('required'), + f'{where}: a download without Range must still serve the whole body') + self.assertGreater(checked, 0, 'No partial-content routes in the document') + + def test_every_operation_declares_the_range_rejection(self): + """416 is declared on every operation, because httplib answers it there. + + cpp-httplib rejects an unparseable ``Range`` header in + ``Server::process_request``, before routing and before any handler, so + the status is reachable on every operation rather than only on the six + downloads where a ``Range`` is *useful*. + """ + missing = [] + wrong_shape = [] + for path, method, op in self.operations(): + resp = op.get('responses', {}).get('416') + if resp is None: + missing.append(f'{method.upper()} {path}') + continue + if resp.get('$ref') != '#/components/responses/GenericError': + wrong_shape.append(f'{method.upper()} {path}') + self.assertEqual(missing, [], f'operations not declaring 416: {missing}') + self.assertEqual( + wrong_shape, [], f'416 not declared as a GenericError: {wrong_shape}') + + def test_range_rejection_is_answered_on_a_route_that_declares_it(self): + """Drive the declared 416 on the wire, on a route that is not a download. + + The status recorder cannot see this one - no handler runs - so without + a wire check the registry-wide declaration would rest on reading + cpp-httplib rather than on observing it. ``/health`` is deliberately + not a download: if 416 only ever appeared on the six binary routes, + declaring it on all of them would be an over-declaration. + + Reading the vendored header alone gets the body wrong. cpp-httplib + writes 416 with no body at all; the gateway's own + ``set_error_handler`` then fills any body-less error response with a + ``GenericError``, which is why the declaration is a ``$ref`` and not a + body-less response object. That is only observable against a real + gateway, so it is asserted here rather than in the registry unit tests. + """ + declared = self.spec()['paths']['/health']['get']['responses'] + self.assertIn('416', declared, 'the assertion below would prove nothing') + + r = requests.get( + f'{self.BASE_URL}/health', headers={'Range': 'furlongs=1-2'}, timeout=10) + self.assertEqual(r.status_code, 416, r.text) + body = r.json() + for field in ('error_code', 'message', 'parameters'): + self.assertIn( + field, body, f'416 body is not the declared GenericError shape: {body}') + + # And the same route answers normally without the header, so the 416 + # above is the Range rejection and not a broken endpoint. + ok = requests.get(f'{self.BASE_URL}/health', timeout=10) + self.assertEqual(ok.status_code, 200) + + def test_lock_guarded_set_matches_the_handlers(self): + """Every route whose handler checks a lock declares the contract. + + Read the failure message before editing either side: the expected set + is hand-maintained (see EXPECTED_LOCK_GUARDED), so a mismatch means + either a registration lost its ``.lock_guarded()`` or a handler gained + a lock check nobody recorded here. + """ + marked = {op.get('operationId') for _, _, op in self.operations() + if op.get('x-medkit-lock-guarded')} + self.assertEqual(marked, EXPECTED_LOCK_GUARDED) + + def test_lock_guarded_routes_declare_the_contract(self): + """The marker is not a bare label: it carries a header and a 409. + + A route that says it takes part in locking without publishing the + ``X-Client-Id`` it reads, or without the 409 it answers to the wrong + client, hands a generated client a marker it cannot act on. + """ + marked = 0 + for path, method, op in self.operations(): + if not op.get('x-medkit-lock-guarded'): + continue + marked += 1 + where = f'{method.upper()} {path}' + headers = self.header_params(op) + self.assertIn( + 'X-Client-Id', headers, f'{where}: lock-guarded without X-Client-Id') + self.assertFalse( + headers['X-Client-Id'].get('required'), + f'{where}: X-Client-Id declared required, but a header-less ' + f'request succeeds while nothing is locked') + self.assertIn( + '409', op.get('responses', {}), f'{where}: lock-guarded without 409') + self.assertTrue(marked, 'No lock-guarded routes in the document') + + def test_lock_guarded_route_answers_the_409_it_declares(self): + """A real write by the wrong client answers the documented 409. + + The marker and the 409 are hand-declared, so on their own they prove + only that somebody typed them. This drives the lock through the real + gateway: client A takes the lock, client B writes, and the status the + wire returns has to be the one the document declares. + """ + op = self.spec()['paths']['/apps/{app_id}/data/{data_id}']['put'] + self.assertTrue(op.get('x-medkit-lock-guarded'), 'putAppDataItem lost the marker') + self.assertIn('409', op.get('responses', {})) + + acquired = requests.post( + f'{self.BASE_URL}/apps/temp_sensor/locks', + json={'lock_expiration': 300}, + headers={'X-Client-Id': 'lock_contract_a'}, + timeout=10, + ) + self.assertEqual(acquired.status_code, 201, acquired.text) + self.addCleanup( + requests.delete, + f'{self.BASE_URL}/apps/temp_sensor/locks/{acquired.json()["id"]}', + headers={'X-Client-Id': 'lock_contract_a'}, + timeout=10, + ) + + blocked = requests.put( + f'{self.BASE_URL}/apps/temp_sensor/data/engine_temperature', + json={'type': 'std_msgs/msg/Float64', 'data': {'data': 42.0}}, + headers={'X-Client-Id': 'lock_contract_b'}, + timeout=10, + ) + self.assertEqual(blocked.status_code, 409, blocked.text) + + def test_global_fault_clear_reads_the_client_id_without_declaring_409(self): + """``DELETE /faults`` publishes the header but not the lock marker. + + It reads ``X-Client-Id`` like the lock-guarded writes but never answers + 409: locked entities are silently skipped and the request still answers + 204, with nothing on the response naming what was skipped. Marking it + lock-guarded would publish a status it cannot return. + + The 204 also carries ``X-Medkit-Local-Only``, which is asserted here + only to keep the two from being confused: it reports that aggregated + peers were not cleared, and says nothing about locks. + """ + op = self.spec()['paths']['/faults']['delete'] + self.assertIn('X-Client-Id', self.header_params(op)) + self.assertNotIn('x-medkit-lock-guarded', op) + local_only = op['responses']['204'].get('headers', {}).get( + 'X-Medkit-Local-Only') + self.assertIsNotNone(local_only) + self.assertNotIn( + 'lock', (local_only.get('description') or '').lower(), + 'X-Medkit-Local-Only is about peers, not locks') + + def test_no_fan_out_header_is_declared_as_a_string(self): + """``X-Medkit-No-Fan-Out`` is presence-only, so it is not a boolean. + + The gateway tests ``has_header`` and never reads the value, so + ``X-Medkit-No-Fan-Out: false`` still suppresses fan-out. A boolean + schema would promise a generated client the opposite. + """ + declared = 0 + for path, method, op in self.operations(): + param = self.header_params(op).get('X-Medkit-No-Fan-Out') + if param is None: + continue + declared += 1 + where = f'{method.upper()} {path}' + self.assertEqual( + param.get('schema', {}).get('type'), 'string', + f'{where}: X-Medkit-No-Fan-Out is presence-only, not typed') + self.assertFalse( + param.get('required'), f'{where}: opting out cannot be mandatory') + self.assertTrue(declared, 'No fan-out-aware routes in the document') + def test_every_created_or_accepted_declares_location(self): """Every 201/202 publishes the `Location` header the handler sets. diff --git a/src/ros2_medkit_integration_tests/test/features/test_openapi_error_coverage.test.py b/src/ros2_medkit_integration_tests/test/features/test_openapi_error_coverage.test.py new file mode 100644 index 000000000..28a476a37 --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/features/test_openapi_error_coverage.test.py @@ -0,0 +1,413 @@ +#!/usr/bin/env python3 +# Copyright 2026 bburda +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""The document must declare every status the gateway actually serves. + +Every other OpenAPI assertion in this suite compares the document against a +list somebody typed. This one compares it against a *run*: the gateway is +compiled with an emitted-status recorder (test builds only), the test drives +the whole documented route surface into its error branches, and then asserts +``declared`` is a superset of ``observed``. Nothing here enumerates statuses, +so nothing here can rot when a handler gains a new one. + +The exercise is derived from the served document itself: every documented +operation whose path has at least one ``{param}`` is called twice - once with +a syntactically valid but nonexistent entity id (the not-found branch) and +once with a syntactically invalid one (the validation branch). Operations +without a path parameter are deliberately skipped: with no id to poison they +would act on real gateway state. + +What the recorder can see, and what it cannot, is documented on +``StatusRecordingScope`` in +``include/ros2_medkit_gateway/http/detail/status_recorder.hpp``. The short +version: it observes the wire status of everything the route registry mounts, +and is blind to what answers ahead of routing (the rate limiter's 429, the +auth middleware's 401/403, the CORS reject) and to anything cpp-httplib +answers by itself. Those are declared by hand. +""" + +import json +import tempfile +import unittest + +import launch_testing +import launch_testing.actions +import requests + +from ros2_medkit_test_utils.constants import ALLOWED_EXIT_CODES +from ros2_medkit_test_utils.gateway_test_case import GatewayTestCase +from ros2_medkit_test_utils.launch_helpers import ( + create_test_launch, + full_feature_gateway_params, +) + +HTTP_METHODS = {'get', 'post', 'put', 'delete', 'patch'} + +# Statuses every operation declares anyway (the blanket 400/404/500, plus the +# success codes). Observing only these would mean the sweep never reached a +# branch the document had to be told about, so a run that sees nothing outside +# this set is treated as a broken sweep rather than a pass. +UNINTERESTING_STATUSES = {200, 201, 202, 204, 400, 404, 500} + +# A well-formed entity id (alphanumeric + underscore) that no fixture creates, +# so the request survives id validation and dies in the entity lookup. +ABSENT_ID = 'zzz_no_such_entity_zzz' + +# An id that fails the gateway's own id validation, so the request dies before +# the lookup. The two together cover both sides of every parameterised route. +INVALID_ID = 'bad id!*' + +# The operations the sweep refuses to call, pinned rather than derived. Each is +# a state-changing verb on a path with no id to poison, so calling it would act +# on real gateway state instead of exercising an error path: DELETE /faults +# clears the store, POST /updates registers an update, and the three /auth +# posts mint or revoke tokens. +# +# Pinned as a literal on purpose. Deriving this set by shape - "parameterless +# and not a GET" - would excuse a *new* parameterless write route forever, so +# the rule that is supposed to catch an unreachable operation would grow a +# silent hole every time one is added. The shape rule is still checked below, +# as a cross-check that nothing reachable was smuggled into this list. +SKIPPED_BY_DESIGN = { + ('delete', '/faults'), + ('post', '/auth/authorize'), + ('post', '/auth/revoke'), + ('post', '/auth/token'), + ('post', '/updates'), +} + +# Floor on the number of `make_error` sites a run must reach. The fixture +# reaches 39 of the 281 sites in the tree; the floor sits below that so +# ordinary churn in the handlers does not need this number edited, while a +# collapse - a broken sweep, a recorder that stopped recording, a substitution +# that stopped producing bad ids - fails instead of quietly reporting "1 site". +MIN_ERROR_SITES = 30 + +_SCRIPTS_DIR = tempfile.mkdtemp(prefix='medkit-coverage-scripts-') + + +def generate_test_description(): + return create_test_launch( + demo_nodes=['calibration', 'temp_sensor'], + fault_manager=True, + gateway_params=full_feature_gateway_params(_SCRIPTS_DIR), + ) + + +def path_params(template): + """Return the ``{param}`` names of an OpenAPI path, in order.""" + names = [] + i = 0 + while i < len(template): + if template[i] == '{': + close = template.find('}', i) + if close < 0: + break + names.append(template[i + 1:close]) + i = close + 1 + else: + i += 1 + return names + + +def concrete_path(template, ids): + """Substitute the ``{param}`` placeholders of *template* from *ids*.""" + out = [] + used = 0 + i = 0 + while i < len(template): + if template[i] == '{': + close = template.find('}', i) + if close < 0: + out.append(template[i:]) + break + out.append(ids[min(used, len(ids) - 1)]) + used += 1 + i = close + 1 + else: + out.append(template[i]) + i += 1 + return ''.join(out) + + +class TestOpenApiErrorCoverage(GatewayTestCase): + """``declared`` must be a superset of ``observed``.""" + + MIN_EXPECTED_APPS = 2 + REQUIRED_APPS = {'calibration', 'temp_sensor'} + + _spec = None + _coverage = None + _swept = 0 + + @classmethod + def setUpClass(cls): + """Wait for the gateway, then drive the error paths before asserting. + + The sweep runs here rather than in a test so that ordering is not left + to unittest's alphabetical method order: every assertion below reads a + recorder that has already been filled. + + The fixture state is stored on ``TestOpenApiErrorCoverage`` by name, + not on ``cls``: launch_testing runs a generated subclass, so ``cls`` is + not the class the assertions read through. + """ + super().setUpClass() + owner = TestOpenApiErrorCoverage + owner._spec = cls._fetch_spec() + owner._swept = cls._sweep_error_paths() + cls._drive_lock_conflict() + owner._coverage = cls._fetch_coverage() + + # ------------------------------------------------------------------ + # Fixture helpers + # ------------------------------------------------------------------ + + @classmethod + def _fetch_spec(cls): + resp = requests.get(f'{cls.BASE_URL}/docs', timeout=15) + resp.raise_for_status() + return resp.json() + + @classmethod + def _fetch_coverage(cls): + resp = requests.get( + f'{cls.BASE_URL}/x-medkit-status-coverage', timeout=15) + resp.raise_for_status() + return resp.json() + + @classmethod + def _real_entity_ids(cls): + """Return {collection: first discovered id} for the four entity types. + + Read from the running gateway rather than hard-coded, so the deep + sweep below cannot silently degrade into the shallow one by naming an + entity the fixture stopped creating. + """ + found = {} + for collection in ('areas', 'components', 'apps', 'functions'): + resp = requests.get(f'{cls.BASE_URL}/{collection}', timeout=10) + items = resp.json().get('items', []) if resp.status_code == 200 else [] + ids = sorted(item['id'] for item in items if item.get('id')) + if ids: + found[collection] = ids[0] + return found + + @classmethod + def _sweep_error_paths(cls): + """Drive every parameterised operation into its error branches. + + Three substitutions per operation: + + 1. every id absent-but-well-formed - the entity-lookup branch; + 2. every id malformed - the id-validation branch, which answers before + the lookup; + 3. for paths with two or more parameters, a *real* leading entity with + the rest absent - the per-resource branches (unknown data item, + unknown execution, unknown fault) that (1) never reaches because it + dies at the entity gate. + + The real-entity pass is restricted to two-or-more-parameter paths on + purpose: a trailing absent id is what guarantees the request cannot + change state. ``DELETE /apps/{app_id}/configurations`` has one + parameter, and pointing it at a real app would wipe that app's + configurations rather than exercise an error path. + """ + real_ids = cls._real_entity_ids() + swept = 0 + for path, item in TestOpenApiErrorCoverage._spec['paths'].items(): + params = path_params(path) + if not params: + # Nothing to poison here, so only the read-only verb is safe: + # a POST or DELETE on a parameterless path acts on real state + # (`DELETE /faults` clears them, `POST /updates` starts one). + if 'get' in item: + try: + resp = requests.get( + f'{cls.BASE_URL}{path}', timeout=10, stream=True) + resp.close() + swept += 1 + except requests.exceptions.RequestException as exc: + print(f'sweep: GET {path} raised {exc}') + continue + substitutions = [[ABSENT_ID], [INVALID_ID]] + collection = path.strip('/').split('/')[0] + if len(params) >= 2 and collection in real_ids: + substitutions.append([real_ids[collection], ABSENT_ID]) + for method in item: + if method not in HTTP_METHODS: + continue + for ids in substitutions: + url = f'{cls.BASE_URL}{concrete_path(path, ids)}' + kwargs = {'timeout': 10, 'stream': True} + if method in ('post', 'put', 'patch'): + # An empty object is a valid JSON document and an + # invalid body for every route that takes one, so the + # body-validation branch is reached too. + kwargs['json'] = {} + try: + resp = requests.request(method, url, **kwargs) + resp.close() + swept += 1 + except requests.exceptions.RequestException as exc: + print(f'sweep: {method.upper()} {url} raised {exc}') + print(f'sweep: issued {swept} requests over {len(real_ids)} real entities') + return swept + + @classmethod + def _drive_lock_conflict(cls): + """Make one write answer 409, a status no blanket rule adds. + + Raises rather than reports: this is the only 409 the run produces, and + an acquire that quietly returned 501 (locking off) or 404 (the entity + gone) would leave the interesting-status assertion passing on the + sweep's 501s alone - a green run that proved less than it claims. + """ + acquired = requests.post( + f'{cls.BASE_URL}/apps/temp_sensor/locks', + json={'lock_expiration': 300}, + headers={'X-Client-Id': 'coverage_client_a'}, + timeout=10, + ) + if acquired.status_code != 201: + raise AssertionError( + f'lock acquire returned {acquired.status_code}, expected 201: ' + f'{acquired.text}') + try: + blocked = requests.put( + f'{cls.BASE_URL}/apps/temp_sensor/data/engine_temperature', + json={'type': 'std_msgs/msg/Float64', 'data': {'data': 42.0}}, + headers={'X-Client-Id': 'coverage_client_b'}, + timeout=10, + ) + if blocked.status_code != 409: + raise AssertionError( + f'write by the wrong client returned {blocked.status_code}, ' + f'expected 409: {blocked.text}') + finally: + requests.delete( + f'{cls.BASE_URL}/apps/temp_sensor/locks/{acquired.json()["id"]}', + headers={'X-Client-Id': 'coverage_client_a'}, + timeout=10, + ) + + def spec(self): + return TestOpenApiErrorCoverage._spec + + def coverage(self): + return TestOpenApiErrorCoverage._coverage + + def operations(self): + """Yield (path, method, operation) for every documented operation.""" + for path, item in self.spec()['paths'].items(): + for method, operation in item.items(): + if method in HTTP_METHODS: + yield path, method, operation + + # ------------------------------------------------------------------ + # Assertions + # ------------------------------------------------------------------ + + def test_every_emitted_status_is_declared(self): + """No handler emits a status its operation does not declare.""" + spec = self.spec() + observed = self.coverage() + undeclared = [] + for entry in observed['emitted']: + op = spec['paths'].get(entry['path'], {}).get(entry['method'].lower()) + if op is None: + continue + if str(entry['status']) not in op.get('responses', {}): + undeclared.append( + f"{entry['method'].upper()} {entry['path']} -> {entry['status']}") + self.assertEqual( + sorted(undeclared), [], f'emitted but undeclared: {sorted(undeclared)}') + + def test_the_sweep_reached_the_documented_surface(self): + """A recorder that saw nothing would make the rule above vacuous. + + The expectation is exact, not a percentage: the only operations the + sweep is allowed to miss are the ones it deliberately skips - a + state-changing verb on a path with no id to poison. Anything else + going unreached means the substitution, the spec fetch or the recorder + itself has quietly stopped working, and the superset rule above would + then pass with an empty left-hand side. + """ + documented = {(method, path) for path, method, _ in self.operations()} + observed = {(e['method'].lower(), e['path']) + for e in self.coverage()['emitted']} + reached = observed & documented + print(f'coverage: {len(reached)}/{len(documented)} documented operations ' + f'reached, {self._swept} requests issued; ' + f'unreached: {sorted(documented - observed)}') + # Cross-check first: every pinned entry must be one the sweep genuinely + # cannot call. Without this the literal could be padded with a route + # that is reachable, turning the pin into an exemption list. + for method, path in sorted(SKIPPED_BY_DESIGN): + self.assertIn((method, path), documented, f'{method} {path} is not documented') + self.assertFalse( + path_params(path), + f'{method} {path} has a path parameter, so the sweep can reach it') + self.assertNotEqual( + method, 'get', f'{method} {path} is read-only, so the sweep can reach it') + self.assertEqual( + sorted(documented - observed), sorted(SKIPPED_BY_DESIGN), + 'unreached operations differ from the pinned set') + + def test_the_sweep_reached_a_status_no_blanket_rule_declares(self): + """The run must contain at least one interesting status. + + 400, 404, 500 and the 2xx codes are declared on every operation by + construction, so a sweep that only ever saw those would satisfy the + superset rule no matter how wrong the document was. + """ + interesting = sorted({ + (e['method'].upper(), e['path'], e['status']) + for e in self.coverage()['emitted'] + if e['status'] not in UNINTERESTING_STATUSES + }) + print(f'coverage: interesting statuses observed: ' + f'{json.dumps(interesting)}') + self.assertTrue( + interesting, 'sweep observed only blanket-declared statuses') + + def test_the_recorder_reports_the_error_sites_it_reached(self): + """The recorder names the ``make_error`` sites that fired. + + This is the honest measure of the mechanism: it is what lets a reader + say how much of the ~281-site error surface a run actually exercised, + instead of assuming the recorder saw everything because it saw + something. + """ + sites = self.coverage()['error_sites'] + print(f'coverage: {len(sites)} make_error sites fired: {json.dumps(sites)}') + for site in sites: + self.assertRegex(site, r'^.+:\d+ -> \d{3}$', f'malformed site {site}') + self.assertGreaterEqual( + len(sites), MIN_ERROR_SITES, + f'only {len(sites)} make_error sites fired; the sweep or the ' + f'recorder has collapsed (see MIN_ERROR_SITES)') + + +@launch_testing.post_shutdown_test() +class TestShutdown(unittest.TestCase): + + def test_exit_codes(self, proc_info): + """Check all processes exited cleanly (SIGTERM allowed).""" + for info in proc_info: + self.assertIn( + info.returncode, ALLOWED_EXIT_CODES, + f'{info.process_name} exited with code {info.returncode}' + ) diff --git a/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_bulk_data_download.test.py b/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_bulk_data_download.test.py index 17fb80942..07781b891 100644 --- a/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_bulk_data_download.test.py +++ b/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_bulk_data_download.test.py @@ -151,6 +151,41 @@ def test_03_verify_complete_rosbag_content(self): # Other formats — just verify we have content self.assertGreater(len(content), 0) + def test_04_rosbag_media_type_is_named_in_the_document(self): + """The served rosbag ``Content-Type`` is a named ``content`` key. + + Tier 2 for the derivable half of the download's media-type set. A + rosbag type comes from ``BulkDataHandlers::get_rosbag_mimetype``, whose + range is finite, so the document must name it rather than fall back on + the ``*/*`` catch-all that exists for client-supplied uploads - passing + via the wildcard is treated as a failure here on purpose. + + @verifies REQ_INTEROP_073 + """ + rosbag_id = self.wait_for_fault_with_rosbag( + self.LIDAR_ENDPOINT, max_wait=30.0, + ) + if rosbag_id is None: + self.fail('No rosbag available for media-type test') + + spec = requests.get(f'{self.BASE_URL}/docs', timeout=10).json() + path = '/apps/{app_id}/bulk-data/{category_id}/{file_id}' + declared = ( + spec['paths'][path]['get'] + .get('responses', {}).get('200', {}).get('content', {}) + ) + + response = self.get_raw( + f'{self.LIDAR_ENDPOINT}/bulk-data/rosbags/{rosbag_id}', + timeout=30, + stream=True, + ) + self.assert_declared_media_type( + response.headers.get('Content-Type', ''), declared, + where='rosbag download', exact=True, + ) + response.close() + @launch_testing.post_shutdown_test() class TestShutdown(unittest.TestCase): diff --git a/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_bulk_data_upload.test.py b/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_bulk_data_upload.test.py index b07f9a318..4ac24a0d7 100644 --- a/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_bulk_data_upload.test.py +++ b/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_bulk_data_upload.test.py @@ -423,6 +423,108 @@ def test_20_full_crud_cycle(self): item_ids2 = [i['id'] for i in list_r2.json().get('items', [])] self.assertNotIn(item_id, item_ids2) + def _download_content_declaration(self, code='200'): + """Return the declared ``content`` map of the app bulk-data download.""" + spec = requests.get(f'{self.BASE_URL}/docs', timeout=10).json() + path = '/apps/{app_id}/bulk-data/{category_id}/{file_id}' + operation = spec['paths'][path]['get'] + return operation.get('responses', {}).get(code, {}).get('content', {}) + + def test_21_download_serves_a_media_type_the_document_declares(self): + """The served ``Content-Type`` is one the document declares. + + Tier 2 for media types: the ``content`` keys are only worth something + if the bytes arrive under one of them. Both halves of the declared set + are exercised deliberately, because they fail in opposite directions - + dropping the concrete types leaves the route claiming only ``*/*``, and + dropping ``*/*`` leaves it claiming a closed set it does not honour. + + @verifies REQ_INTEROP_073 + """ + declared = self._download_content_declaration() + self.assertIn( + '*/*', declared, + 'a store-backed category echoes the uploader mime type, so the ' + 'declared set has to stay open', + ) + + base_url = f'{self.BASE_URL}/apps/{self.test_app_id}/bulk-data/calibration' + + # Derivable half: the store's own default, which the document names. + octet = requests.post( + base_url, + files={'file': ('mt_octet.bin', b'bytes', 'application/octet-stream')}, + timeout=10, + ) + self.assertEqual(octet.status_code, 201) + octet_dl = requests.get(f'{base_url}/{octet.json()["id"]}', timeout=10) + self.assertEqual(octet_dl.status_code, 200) + self.assert_declared_media_type( + octet_dl.headers.get('Content-Type', ''), declared, + where='download of an octet-stream upload', exact=True, + ) + + # Open half: an uploader-chosen type nothing in the gateway enumerates. + # This is the concrete reason the catch-all is in the declaration - the + # served type is genuinely outside any list the code could produce. + text = requests.post( + base_url, + files={'file': ('mt_text.csv', b'a,b\n1,2\n', 'text/csv')}, + timeout=10, + ) + self.assertEqual(text.status_code, 201) + text_dl = requests.get(f'{base_url}/{text.json()["id"]}', timeout=10) + self.assertEqual(text_dl.status_code, 200) + self.assertNotIn( + 'text/csv', {k.lower() for k in declared}, + 'if text/csv were ever enumerable this test would stop proving ' + 'that the catch-all is load-bearing', + ) + self.assert_declared_media_type( + text_dl.headers.get('Content-Type', ''), declared, + where='download of a text/csv upload', exact=False, + ) + + def test_22_multi_range_download_serves_the_declared_206_media_type(self): + """A multi-range request answers ``multipart/byteranges``, as declared. + + The 206 declares a media type the 200 cannot carry, and it comes from + the HTTP layer rewriting ``Content-Type`` rather than from the handler, + so nothing else in the suite would notice it drifting. + + @verifies REQ_INTEROP_073 + """ + base_url = f'{self.BASE_URL}/apps/{self.test_app_id}/bulk-data/calibration' + upload = requests.post( + base_url, + files={'file': ('ranges.bin', b'0123456789abcdef', 'application/octet-stream')}, + timeout=10, + ) + self.assertEqual(upload.status_code, 201) + url = f'{base_url}/{upload.json()["id"]}' + + declared_206 = self._download_content_declaration('206') + + single = requests.get(url, headers={'Range': 'bytes=4-7'}, timeout=10) + self.assertEqual(single.status_code, 206) + self.assertEqual(single.content, b'4567') + self.assertEqual(single.headers.get('Content-Range'), 'bytes 4-7/16') + self.assert_declared_media_type( + single.headers.get('Content-Type', ''), declared_206, + where='single-range download', exact=True, + ) + + multi = requests.get(url, headers={'Range': 'bytes=0-3,8-11'}, timeout=10) + self.assertEqual(multi.status_code, 206) + self.assert_declared_media_type( + multi.headers.get('Content-Type', ''), declared_206, + where='multi-range download', exact=True, + ) + self.assertTrue( + multi.headers.get('Content-Type', '').startswith('multipart/byteranges'), + f'expected a byteranges body, got {multi.headers.get("Content-Type")!r}', + ) + @launch_testing.post_shutdown_test() class TestShutdown(unittest.TestCase): From e7a65a98c1556cf867ea7c7b2827ecd7d4ec06c0 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Wed, 5 Aug 2026 08:43:22 +0200 Subject: [PATCH 06/17] fix(gateway): answer with the URI, status and code the caller can act on A created resource is answered with its Location, a fault-trigger route that cannot serve says 501 rather than a bare error, and a parameter conversion failure answers 400 instead of a status that told the caller nothing. --- docs/api/rest.rst | 26 ++++-- docs/config/server.rst | 5 +- .../config/gateway_params.yaml | 6 +- .../src/http/handlers/operation_handlers.cpp | 20 +++-- .../src/http/rest_server.cpp | 45 ++++++++-- .../transports/ros2_parameter_transport.cpp | 30 ++++++- .../features/test_configuration_api.test.py | 33 +++++++ .../features/test_fault_triggers_api.test.py | 90 ++++++++++++++++++- .../test/features/test_operations_api.test.py | 39 +++++++- 9 files changed, 265 insertions(+), 29 deletions(-) diff --git a/docs/api/rest.rst b/docs/api/rest.rst index 85406293e..5f453c22f 100644 --- a/docs/api/rest.rst +++ b/docs/api/rest.rst @@ -720,6 +720,12 @@ Execute Operations "status": "running" } + The ``202`` carries a ``Location`` header naming the new execution. It is + the request path plus the execution id, so it stays inside the collection + the caller addressed: a POST to ``/api/v1/functions/powertrain/...`` is + answered with a ``/api/v1/functions/powertrain/...`` execution URI, never a + ``/components/`` one. + ``GET /api/v1/components/{id}/operations/{operation_id}/executions`` List all executions for an operation. @@ -846,7 +852,9 @@ Manage ROS 2 node parameters. - **Content-Type:** application/json - **200:** Parameter updated - - **400:** Invalid value + - **400:** Invalid value - the node rejected it, or it cannot be converted to + the parameter's type at all (e.g. a mixed-type array). Both are the + caller's body, so both are ``400``, never ``500``. - **404:** Parameter not found **Example:** @@ -2207,6 +2215,12 @@ The routes are part of the generated OpenAPI spec (``/api/v1/docs``, tag ``FaultTriggers``), so generated clients and Swagger UI discover them the same way as every other endpoint. +The engine runs only when ``fault_triggers.enabled`` is true *and* at least one +plugin is loaded. Without it the routes stay mounted and answer ``501`` +(``not-implemented``) - the same shape the ``/updates`` and ``/triggers`` gates +use, so a client can tell "this build has no threshold engine" apart from "no +such app or rule". + ``GET /api/v1/apps/{app_id}/fault-triggers`` List the app's rules. @@ -2234,10 +2248,12 @@ way as every other endpoint. (default ``true``). Returns ``201`` with the created rule and a ``Location`` header pointing to it. - Validation: ``400`` for missing/invalid fields or a ``data_name`` the app - does not expose (when enumerable); ``409`` when the ``fault_code`` is - already used by another rule - fault codes are global to the fault store, - so two rules sharing one would fight over the same fault. + Validation: ``400`` (``invalid-parameter``) for missing/invalid fields or a + ``data_name`` the app does not expose (when enumerable); ``404`` + (``entity-not-found``) when the app itself was never discovered; ``409`` + (``precondition-not-fulfilled``) when the ``fault_code`` is already used by + another rule - fault codes are global to the fault store, so two rules + sharing one would fight over the same fault. ``DELETE /api/v1/apps/{app_id}/fault-triggers/{trigger_id}`` Remove a rule (``204``). A fault currently asserted by the rule is cleared; diff --git a/docs/config/server.rst b/docs/config/server.rst index 027442a42..b9b8e51ae 100644 --- a/docs/config/server.rst +++ b/docs/config/server.rst @@ -719,7 +719,10 @@ the backend functionality (see `Plugin Framework`_ above). * - ``updates.enabled`` - bool - ``false`` - - Enable/disable software updates endpoints. When disabled, ``/updates`` routes are not registered. + - Enable/disable the software updates backend. The ``/updates`` routes are + always registered and always documented; when this is ``false`` - or when + no ``UpdateProvider`` plugin is loaded - every one of them answers + ``501``. Example: diff --git a/src/ros2_medkit_gateway/config/gateway_params.yaml b/src/ros2_medkit_gateway/config/gateway_params.yaml index 4b0c29609..05caf0476 100644 --- a/src/ros2_medkit_gateway/config/gateway_params.yaml +++ b/src/ros2_medkit_gateway/config/gateway_params.yaml @@ -456,8 +456,10 @@ ros2_medkit_gateway: # When enabled, a plugin implementing UpdateProvider is required to provide # the backend functionality. Without such a plugin, endpoints return 501. updates: - # Enable/disable /updates endpoints - # When false, update routes are not registered + # Enable/disable the /updates backend. + # The routes are always registered and always appear in the OpenAPI + # document; when this is false (or no UpdateProvider plugin is loaded) + # every one of them answers 501 not-implemented. # Default: false enabled: false diff --git a/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp index 4144a4ef7..557d197d5 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp @@ -26,7 +26,6 @@ #include "ros2_medkit_gateway/core/http/error_codes.hpp" #include "ros2_medkit_gateway/core/http/fan_out_helpers.hpp" -#include "ros2_medkit_gateway/core/http/http_utils.hpp" #include "ros2_medkit_gateway/core/managers/operation_manager.hpp" #include "ros2_medkit_gateway/core/plugins/plugin_manager.hpp" #include "ros2_medkit_gateway/core/providers/operation_provider.hpp" @@ -542,9 +541,13 @@ OperationHandlers::create_execution(const http::TypedRequest & req, dto::Executi async_dto.id = action_result.goal_id; async_dto.status = "running"; - const std::string base_path = (lookup->entity_type == "app") ? "/apps/" : "/components/"; - const std::string location = - api_path(base_path + entity_id + "/operations/" + operation_id + "/executions/" + action_result.goal_id); + // The execution is a child of the POST target, and `req.path()` already + // carries the API prefix, so this is the same absolute form every `href` + // uses - and it names the collection the caller actually addressed. + // Rebuilding the path from an "app or else component" choice, as this + // used to, handed an area or function caller a `/components/...` URI + // that resolves to nothing. + const std::string location = req.path() + "/" + action_result.goal_id; http::ResponseAttachments att; att.with_location(location); @@ -834,9 +837,12 @@ OperationHandlers::update_execution(const http::TypedRequest & req, const dto::E if (capability == "stop") { auto result = operation_mgr->cancel_action_goal(goal_info->action_path, execution_id); if (result.success && result.return_code == 0) { - const std::string base_path = req.path().find("/apps/") != std::string::npos ? "/apps/" : "/components/"; - const std::string location = - api_path(base_path + entity_id + "/operations/" + operation_id + "/executions/" + execution_id); + // The execution this PUT addressed *is* the resource whose status now + // tracks the request, so the request path is the `Location` - already + // API-prefixed, and already naming the collection the caller used. The + // previous "/apps/ if the path mentions it, else /components/" rebuild + // pointed area and function callers at a URI that resolves to nothing. + const std::string location = req.path(); dto::OperationExecution exec_dto; exec_dto.id = execution_id; diff --git a/src/ros2_medkit_gateway/src/http/rest_server.cpp b/src/ros2_medkit_gateway/src/http/rest_server.cpp index 632ab9ab6..4a996c60e 100644 --- a/src/ros2_medkit_gateway/src/http/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/http/rest_server.cpp @@ -366,12 +366,22 @@ void RESTServer::setup_routes() { res.set_content(err.dump(2), "application/json"); }; + // The engine only exists when the feature is on and at least one plugin is + // loaded. That is the same "route mounted, backend absent" shape as the + // `/updates` and `/triggers` gates, so it answers the same way they do: + // 501 not-implemented. The 404 it used to answer said the *rule collection* + // did not exist, which is indistinguishable from an unknown app and told a + // client to go looking for an id instead of enabling a feature. + auto ft_engine_absent = [ft_json_error](httplib::Response & res) { + ft_json_error(res, 501, "fault-trigger engine is not enabled", ERR_NOT_IMPLEMENTED); + }; + route_registry_ ->raw("get", "/apps/{app_id}/fault-triggers", - [this, ft_json_error](const httplib::Request & req, httplib::Response & res) { + [this, ft_engine_absent](const httplib::Request & req, httplib::Response & res) { auto * engine = node_->get_fault_trigger_engine(); if (!engine) { - ft_json_error(res, 404, "fault-trigger engine is not enabled"); + ft_engine_absent(res); return; } const std::string app_id = req.matches.size() > 1 ? req.matches[1].str() : std::string{}; @@ -389,16 +399,18 @@ void RESTServer::setup_routes() { "and auto-clears on recovery.") .operation_id("listFaultTriggers") .path_param("app_id", "App (entity) the rules are scoped to") + // 501 when the engine is not running (feature off, or no plugin loaded). + .errors({501}) .response(200, "Rule list", nlohmann::json{{"type", "object"}, {"properties", {{"items", {{"type", "array"}, {"items", {{"type", "object"}}}}}}}}); route_registry_ ->raw("post", "/apps/{app_id}/fault-triggers", - [this, ft_json_error](const httplib::Request & req, httplib::Response & res) { + [this, ft_json_error, ft_engine_absent](const httplib::Request & req, httplib::Response & res) { auto * engine = node_->get_fault_trigger_engine(); if (!engine) { - ft_json_error(res, 404, "fault-trigger engine is not enabled"); + ft_engine_absent(res); return; } const std::string app_id = req.matches.size() > 1 ? req.matches[1].str() : std::string{}; @@ -412,7 +424,22 @@ void RESTServer::setup_routes() { } auto created = engine->create(app_id, body); if (!created) { - ft_json_error(res, created.error().first, created.error().second); + // The engine reports three statuses and they are three + // different failures. Letting them all fall through to the + // status-shaped default told a client that an unknown app was + // a missing sub-resource and that a duplicate fault_code was a + // malformed parameter it could fix by editing the body. + const int status = created.error().first; + const char * code = ERR_INVALID_PARAMETER; + if (status == 404) { + // The rule names an app the entity registry does not know. + code = ERR_ENTITY_NOT_FOUND; + } else if (status == 409) { + // fault_code is the fault store's primary key, so uniqueness + // is a precondition on the request, not a field format. + code = ERR_PRECONDITION_NOT_FULFILLED; + } + ft_json_error(res, status, created.error().second, code); return; } // Raw route: the typed registry's automatic 201 `Location` @@ -433,6 +460,8 @@ void RESTServer::setup_routes() { .path_param("app_id", "App (entity) to scope the rule to") .request_body("Fault-trigger rule definition", nlohmann::json{{"type", "object"}, {"additionalProperties", true}}) + // 501 when the engine is not running (feature off, or no plugin loaded). + .errors({501}) .response(201, "Created rule", nlohmann::json{{"type", "object"}}) .response_header( 201, openapi::ResponseHeader{"Location", @@ -445,10 +474,10 @@ void RESTServer::setup_routes() { route_registry_ ->raw("delete", "/apps/{app_id}/fault-triggers/{trigger_id}", - [this, ft_json_error](const httplib::Request & req, httplib::Response & res) { + [this, ft_json_error, ft_engine_absent](const httplib::Request & req, httplib::Response & res) { auto * engine = node_->get_fault_trigger_engine(); if (!engine) { - ft_json_error(res, 404, "fault-trigger engine is not enabled"); + ft_engine_absent(res); return; } const std::string app_id = req.matches.size() > 1 ? req.matches[1].str() : std::string{}; @@ -467,6 +496,8 @@ void RESTServer::setup_routes() { .operation_id("deleteFaultTrigger") .path_param("app_id", "App (entity) the rule is scoped to") .path_param("trigger_id", "Rule id as returned on create") + // 501 when the engine is not running (feature off, or no plugin loaded). + .errors({501}) .response(204, "Deleted"); } diff --git a/src/ros2_medkit_gateway/src/ros2/transports/ros2_parameter_transport.cpp b/src/ros2_medkit_gateway/src/ros2/transports/ros2_parameter_transport.cpp index 7b2574405..8d7e23f2e 100644 --- a/src/ros2_medkit_gateway/src/ros2/transports/ros2_parameter_transport.cpp +++ b/src/ros2_medkit_gateway/src/ros2/transports/ros2_parameter_transport.cpp @@ -632,7 +632,21 @@ ParameterResult Ros2ParameterTransport::set_parameter(const std::string & node_n return result; } auto current_value = node_->get_parameter(param_name).get_parameter_value(); - rclcpp::ParameterValue param_value = json_to_parameter_value(value, current_value.get_type()); + // Conversion runs in its own try so a value the CLIENT sent that cannot + // become this parameter's type reports as INVALID_VALUE (400), not as the + // INTERNAL_ERROR (500) the outer catch would give it. A heterogeneous + // array is the easy way in: nlohmann throws type_error out of + // `get>()` and nothing about that is the gateway's + // fault. + rclcpp::ParameterValue param_value; + try { + param_value = json_to_parameter_value(value, current_value.get_type()); + } catch (const std::exception & e) { + result.success = false; + result.error_message = "Value cannot be converted for parameter '" + param_name + "': " + e.what(); + result.error_code = ParameterErrorCode::INVALID_VALUE; + return result; + } auto set_result = node_->set_parameter(rclcpp::Parameter(param_name, param_value)); if (!set_result.successful) { result.success = false; @@ -714,8 +728,18 @@ ParameterResult Ros2ParameterTransport::set_parameter(const std::string & node_n // json_to_parameter_value can throw on bad CLIENT input (e.g. malformed value for // the parameter's type). It runs OUTSIDE any mark scope so a bad client value never - // negative-caches a healthy node; the outer catch maps a throw to INTERNAL_ERROR. - rclcpp::ParameterValue param_value = json_to_parameter_value(value, hint_type); + // negative-caches a healthy node, and it is caught HERE rather than by the outer + // catch: the outer catch reports INTERNAL_ERROR, which the classifier turns into a + // 500 and so blames the gateway for a body the caller chose. + rclcpp::ParameterValue param_value; + try { + param_value = json_to_parameter_value(value, hint_type); + } catch (const std::exception & e) { + result.success = false; + result.error_message = "Value cannot be converted for parameter '" + param_name + "': " + e.what(); + result.error_code = ParameterErrorCode::INVALID_VALUE; + return result; + } rclcpp::Parameter param(param_name, param_value); std::vector results; diff --git a/src/ros2_medkit_integration_tests/test/features/test_configuration_api.test.py b/src/ros2_medkit_integration_tests/test/features/test_configuration_api.test.py index a835cd659..d6c9b7978 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_configuration_api.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_configuration_api.test.py @@ -287,6 +287,39 @@ def test_07_set_configuration_missing_value(self): # SOVD format expects "data" field self.assertIn('data', data['message'].lower()) + def test_07b_set_configuration_unconvertible_value(self): + """A value that cannot become the parameter's type is 400, not 500. + + A mixed-type array makes the JSON->ParameterValue conversion throw, and + the throw used to land in the transport's outer catch, which reports + INTERNAL_ERROR and so answers 500 - the gateway taking the blame for a + body the caller chose. Both write paths are covered: temp_sensor is a + remote node (parameter service round trip) and the gateway node is its + own, and the conversion sits on both. + + @verifies REQ_INTEROP_050 + """ + remote = requests.put( + f'{self.BASE_URL}/apps/temp_sensor/configurations/publish_rate', + json={'data': [1, 'not-a-number']}, + timeout=10 + ) + self.assertEqual(remote.status_code, 400, remote.text) + self.assertEqual(remote.json()['error_code'], 'invalid-parameter') + + own = requests.put( + f'{self.BASE_URL}/apps/ros2_medkit_gateway/configurations' + '/refresh_interval_ms', + json={'data': [1, 'not-a-number']}, + timeout=10 + ) + self.assertEqual(own.status_code, 400, own.text) + self.assertEqual(own.json()['error_code'], 'invalid-parameter') + + # The rejected write must not have changed anything. + after = self.get_json('/apps/temp_sensor/configurations/publish_rate') + self.assertEqual(after['x-medkit']['parameter']['value'], 2.0) + def test_08_root_endpoint_includes_configurations(self): """Root endpoint lists configurations endpoints and capability. diff --git a/src/ros2_medkit_integration_tests/test/features/test_fault_triggers_api.test.py b/src/ros2_medkit_integration_tests/test/features/test_fault_triggers_api.test.py index 8cd48a445..3c1421583 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_fault_triggers_api.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_fault_triggers_api.test.py @@ -33,12 +33,19 @@ import launch_testing.actions import requests -from ros2_medkit_test_utils.constants import ALLOWED_EXIT_CODES +from ros2_medkit_test_utils.constants import ( + ALLOWED_EXIT_CODES, + API_BASE_PATH, + get_test_port, +) from ros2_medkit_test_utils.gateway_test_case import GatewayTestCase -from ros2_medkit_test_utils.launch_helpers import create_test_launch +from ros2_medkit_test_utils.launch_helpers import create_gateway_node, create_test_launch PLUGIN_APP = 'test_route_plc_app' +PORT_ENGINE_OFF = get_test_port(1) +URL_ENGINE_OFF = f'http://localhost:{PORT_ENGINE_OFF}{API_BASE_PATH}' + def _get_plugin_path(so_name): pkg_prefix = get_package_prefix('ros2_medkit_gateway') @@ -46,7 +53,7 @@ def _get_plugin_path(so_name): def generate_test_description(): - return create_test_launch( + launch_description, context = create_test_launch( demo_nodes=['temp_sensor'], fault_manager=True, gateway_params={ @@ -56,6 +63,20 @@ def generate_test_description(): 'fault_triggers.poll_interval_ms': 200, }, ) + # Second gateway with the engine switched off, so the "backend absent" + # answer can be asserted on the same route set as the live one. + gateway_engine_off = create_gateway_node( + name='gateway_fault_triggers_off', + port=PORT_ENGINE_OFF, + extra_params={ + 'server.host': '127.0.0.1', + 'fault_triggers.enabled': False, + }, + ) + # Prepend so it comes up alongside the primary gateway, before ReadyToTest. + launch_description.entities.insert(0, gateway_engine_off) + context['gateway_fault_triggers_off'] = gateway_engine_off + return launch_description, context class TestFaultTriggersApi(GatewayTestCase): @@ -192,6 +213,69 @@ def test_07_disconnected_app_holds_rule_state(self): f"/{rule['id']}", timeout=10) self.assertEqual(resp.status_code, 204) + def test_08_engine_absent_answers_501_on_every_verb(self): + """No engine is a missing backend, not a missing rule. + + The gateway used to answer 404/``resource-not-found`` here, which is the + same answer an unknown rule id gets - it told a client to go hunting for + an id when what it had to do was turn the feature on. It now answers the + gate shape ``/updates`` and ``/triggers`` use. + """ + deadline = time.monotonic() + 30.0 + while time.monotonic() < deadline: + try: + if requests.get(f'{URL_ENGINE_OFF}/health', timeout=2).status_code == 200: + break + except requests.exceptions.RequestException: + pass + time.sleep(0.5) + else: + self.fail('gateway_fault_triggers_off never became healthy') + + base = f'{URL_ENGINE_OFF}/apps/{PLUGIN_APP}/fault-triggers' + responses = [ + requests.get(base, timeout=10), + requests.post(base, json={'data_name': 'level', 'operator': '>', + 'threshold': 1.0, 'fault_code': 'OFF_RULE', + 'severity': 'ERROR'}, timeout=10), + requests.delete(f'{base}/ftr_1', timeout=10), + ] + for resp in responses: + self.assertEqual(resp.status_code, 501, resp.text) + self.assertEqual(resp.json()['error_code'], 'not-implemented') + + def test_09_create_error_codes_name_the_failure(self): + """400, 404 and 409 each carry their own error code. + + All three used to fall through one status-shaped default, so an unknown + app read as a missing sub-resource and a duplicate ``fault_code`` read as + a malformed field the caller could fix by editing the body. + """ + bad_field = self._create({'data_name': 'level', 'operator': '~', + 'threshold': 1.0, 'fault_code': 'CODE_SHAPE_A', + 'severity': 'ERROR'}) + self.assertEqual(bad_field.status_code, 400, bad_field.text) + self.assertEqual(bad_field.json()['error_code'], 'invalid-parameter') + + ghost = requests.post( + f'{self.BASE_URL}/apps/ghost_app_for_codes/fault-triggers', + json={'data_name': 'level', 'operator': '>', 'threshold': 1.0, + 'fault_code': 'CODE_SHAPE_B', 'severity': 'ERROR'}, timeout=10) + self.assertEqual(ghost.status_code, 404, ghost.text) + self.assertEqual(ghost.json()['error_code'], 'entity-not-found') + + first = self._create({'data_name': 'level', 'operator': '>=', + 'threshold': 80.0, 'fault_code': 'CODE_SHAPE_C', + 'severity': 'ERROR'}) + self.assertEqual(first.status_code, 201, first.text) + self.addCleanup(requests.delete, self._url(f"/{first.json()['id']}"), timeout=10) + + dup = self._create({'data_name': 'level', 'operator': '>=', + 'threshold': 90.0, 'fault_code': 'CODE_SHAPE_C', + 'severity': 'ERROR'}) + self.assertEqual(dup.status_code, 409, dup.text) + self.assertEqual(dup.json()['error_code'], 'precondition-not-fulfilled') + # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ diff --git a/src/ros2_medkit_integration_tests/test/features/test_operations_api.test.py b/src/ros2_medkit_integration_tests/test/features/test_operations_api.test.py index 5140106c4..94426dfcf 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_operations_api.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_operations_api.test.py @@ -28,7 +28,7 @@ import launch_testing.actions import requests -from ros2_medkit_test_utils.constants import ALLOWED_EXIT_CODES +from ros2_medkit_test_utils.constants import ALLOWED_EXIT_CODES, API_BASE_PATH from ros2_medkit_test_utils.gateway_test_case import GatewayTestCase from ros2_medkit_test_utils.launch_helpers import create_test_launch @@ -425,6 +425,43 @@ def test_list_executions_returns_items_array(self): self.assertIn('items', data) self.assertIsInstance(data['items'], list) + def test_async_execution_location_resolves(self): + """The 202 `Location` names the execution under the addressed collection. + + The gateway used to build this header from a two-way "app or else + component" choice, so an execution started through `/functions/...` or + `/areas/...` was handed a `/components/...` URI that resolves to + nothing. The fixture discovers the `powertrain` function from the demo + nodes' namespace, and `long_calibration` is an action on it, so this + drives the branch that was wrong. + + @verifies REQ_INTEROP_035 + """ + self.wait_for_operation('/functions/powertrain', 'long_calibration') + + ops = self.get_json('/functions/powertrain/operations')['items'] + actions = [o for o in ops if o.get('asynchronous_execution')] + self.assertTrue(actions, 'no action discovered on the function') + + resp = requests.post( + f'{self.BASE_URL}/functions/powertrain/operations' + f'/{actions[0]["id"]}/executions', + json={}, timeout=15) + self.assertEqual(resp.status_code, 202, resp.text) + + location = resp.headers.get('Location') + execution_id = resp.json()['id'] + self.assertEqual( + location, + f'{API_BASE_PATH}/functions/powertrain/operations' + f'/{actions[0]["id"]}/executions/{execution_id}') + + # The header is only worth anything if it resolves. + follow_url = self.BASE_URL + location[len(API_BASE_PATH):] + self.addCleanup(requests.delete, follow_url, timeout=10) + follow = requests.get(follow_url, timeout=10) + self.assertEqual(follow.status_code, 200, follow.text) + def test_create_execution_for_service(self): """POST /{entity}/operations/{op-id}/executions calls service and returns. From 4ce5dd0589839f56879eb335c0cc9d0011f18def Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Wed, 5 Aug 2026 08:43:22 +0200 Subject: [PATCH 07/17] feat(gateway): make an execution belong to the entity it was started on get_tracked_goal was scoped to no entity, so an execution started on one app was reachable from another, and list_executions resolved only two of the four entity types. Both now resolve the entity that owns the execution. Kept as its own commit: this changes behaviour, and reverting it must not take the documentation work with it. --- docs/api/rest.rst | 13 ++- .../src/http/handlers/operation_handlers.cpp | 110 +++++++++++------- .../test/features/test_operations_api.test.py | 65 +++++++++++ 3 files changed, 143 insertions(+), 45 deletions(-) diff --git a/docs/api/rest.rst b/docs/api/rest.rst index 5f453c22f..202c06890 100644 --- a/docs/api/rest.rst +++ b/docs/api/rest.rst @@ -727,11 +727,22 @@ Execute Operations ``/components/`` one. ``GET /api/v1/components/{id}/operations/{operation_id}/executions`` - List all executions for an operation. + List all executions for an operation. Available on every entity type that + lists the operation - areas, components, apps and functions. + + An operation id that is not an action has no executions and lists empty: + only ROS 2 actions produce a tracked execution. ``GET /api/v1/components/{id}/operations/{operation_id}/executions/{execution_id}`` Get execution status and result. + Executions belong to the entity they were started on. Reading, updating or + cancelling one through a different entity's URI answers ``404`` even when + the execution id exists, and the listing above shows an entity only the + executions started through it. The same action reached through two entities + (an app and the function that aggregates it) therefore keeps two separate + execution collections. + **Example Response (completed action):** .. code-block:: json diff --git a/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp index 557d197d5..80ba7a339 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp @@ -191,6 +191,27 @@ dto::XMedkitOperationItem build_action_xmedkit(const ActionInfo & act, const std return x_medkit; } +/// Look up a tracked action goal, and refuse it through any entity other than +/// the one it was started on. +/// +/// `ActionGoalInfo` carries the owning entity, so the check costs nothing - +/// and without it a goal id is a global handle: an execution started on one +/// app can be read, stopped and cancelled through a completely unrelated +/// entity's URI, and `GET /apps/a/operations/x/executions/{id}` happily +/// answers 200 for an execution belonging to a function. A caller that +/// addresses the wrong entity gets the same "not found" it would get for a +/// wrong id, which is the answer that keeps entity boundaries meaningful. +tl::expected owned_goal(OperationManager * operation_mgr, const std::string & entity_id, + const std::string & operation_id, const std::string & execution_id) { + auto goal_info = operation_mgr->get_tracked_goal(execution_id); + if (goal_info.has_value() && goal_info->entity_id == entity_id) { + return *goal_info; + } + return tl::make_unexpected( + make_error(404, ERR_RESOURCE_NOT_FOUND, "Execution not found", + json{{"entity_id", entity_id}, {"operation_id", operation_id}, {"execution_id", execution_id}})); +} + /// Map an `OperationProviderErrorInfo` (from the typed plugin ABI) into the /// SOVD `x-medkit-plugin-error` wire shape via `make_plugin_error`. ErrorInfo make_provider_error(const OperationProviderErrorInfo & info, const std::string & entity_id, @@ -614,45 +635,52 @@ http::Result> OperationHandlers::list_executio } const std::string operation_id = *op_id_result; - if (auto vr = ctx_.validate_entity_id(entity_id); !vr) { - return tl::make_unexpected(make_error(400, ERR_INVALID_PARAMETER, "Invalid entity ID", - json{{"details", vr.error()}, {"entity_id", entity_id}})); + auto entity_result = ctx_.validate_entity_for_route(req, entity_id); + if (!entity_result) { + return tl::make_unexpected(flatten_validator_error(entity_result.error())); } + const auto entity_info = *entity_result; + // The route is mounted on all four entity types, so the lookup has to cover + // all four. It used to reach for the component cache and then the app cache + // and 404 otherwise, which made an area or a function report that the entity + // did not exist - immediately after the same entity listed the operation. + // `resolve_entity_operations` is the same lookup `list_operations` uses, so + // the two endpoints now agree on what an entity's operations are. const auto & cache = ctx_.node()->get_thread_safe_cache(); - std::string namespace_path; - bool entity_found = false; - - if (auto component = cache.get_component(entity_id)) { - namespace_path = component->namespace_path; - entity_found = true; - } - if (!entity_found) { - if (auto app = cache.get_app(entity_id)) { - for (const auto & act : app->actions) { - if (act.name == operation_id) { - namespace_path = act.full_path.substr(0, act.full_path.rfind('/')); - entity_found = true; - break; - } - } - } - } - if (!entity_found) { - return tl::make_unexpected( - make_error(404, ERR_ENTITY_NOT_FOUND, "Entity not found", json{{"entity_id", entity_id}})); + auto lookup = resolve_entity_operations(cache, entity_info.sovd_type(), entity_id); + if (!lookup) { + return tl::make_unexpected(lookup.error()); } - const std::string action_path = namespace_path + "/" + operation_id; - auto * operation_mgr = ctx_.node()->get_operation_manager(); - auto goals = operation_mgr->get_goals_for_action(action_path); - // Typed Collection - replaces the legacy ad-hoc // `{"items": [{"id": "..."}]}` JSON literal. The wire shape is identical // (per JsonWriter>::write) but the per-item schema // is now enforced by JsonReader on round-trip. dto::Collection collection; - for (const auto & goal : goals) { + + // Executions only exist for actions. Reading the goal's action path off the + // discovered ActionInfo is also what makes the aggregating entities work: + // the old `namespace_path + "/" + operation_id` guess never matched the real + // action path for a host component, whose namespace is not the action's. + std::string action_path; + for (const auto & act : lookup->ops.actions) { + if (act.name == operation_id) { + action_path = act.full_path; + break; + } + } + if (action_path.empty()) { + return collection; + } + + auto * operation_mgr = ctx_.node()->get_operation_manager(); + for (const auto & goal : operation_mgr->get_goals_for_action(action_path)) { + // An action reachable through several entities is one action with one set + // of goals; each entity lists the executions started through it. + if (goal.entity_id != entity_id) { + continue; + } dto::ExecutionId item; item.id = goal.goal_id; collection.items.push_back(std::move(item)); @@ -689,11 +717,9 @@ http::Result OperationHandlers::get_execution(const htt } auto * operation_mgr = ctx_.node()->get_operation_manager(); - auto goal_info = operation_mgr->get_tracked_goal(execution_id); - if (!goal_info.has_value()) { - return tl::make_unexpected( - make_error(404, ERR_RESOURCE_NOT_FOUND, "Execution not found", - json{{"entity_id", entity_id}, {"operation_id", operation_id}, {"execution_id", execution_id}})); + auto goal_info = owned_goal(operation_mgr, entity_id, operation_id, execution_id); + if (!goal_info) { + return tl::make_unexpected(goal_info.error()); } dto::OperationExecution exec_dto; @@ -753,11 +779,9 @@ http::Result OperationHandlers::cancel_execution(const http::Ty } auto * operation_mgr = ctx_.node()->get_operation_manager(); - auto goal_info = operation_mgr->get_tracked_goal(execution_id); - if (!goal_info.has_value()) { - return tl::make_unexpected( - make_error(404, ERR_RESOURCE_NOT_FOUND, "Execution not found", - json{{"entity_id", entity_id}, {"operation_id", operation_id}, {"execution_id", execution_id}})); + auto goal_info = owned_goal(operation_mgr, entity_id, operation_id, execution_id); + if (!goal_info) { + return tl::make_unexpected(goal_info.error()); } auto result = operation_mgr->cancel_action_goal(goal_info->action_path, execution_id); @@ -824,11 +848,9 @@ OperationHandlers::update_execution(const http::TypedRequest & req, const dto::E const std::string capability = body.capability; auto * operation_mgr = ctx_.node()->get_operation_manager(); - auto goal_info = operation_mgr->get_tracked_goal(execution_id); - if (!goal_info.has_value()) { - return tl::make_unexpected( - make_error(404, ERR_RESOURCE_NOT_FOUND, "Execution not found", - json{{"entity_id", entity_id}, {"operation_id", operation_id}, {"execution_id", execution_id}})); + auto goal_info = owned_goal(operation_mgr, entity_id, operation_id, execution_id); + if (!goal_info) { + return tl::make_unexpected(goal_info.error()); } // SOVD capabilities: execute, freeze, reset, stop. ROS 2 actions only diff --git a/src/ros2_medkit_integration_tests/test/features/test_operations_api.test.py b/src/ros2_medkit_integration_tests/test/features/test_operations_api.test.py index 94426dfcf..d16a25f82 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_operations_api.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_operations_api.test.py @@ -462,6 +462,71 @@ def test_async_execution_location_resolves(self): follow = requests.get(follow_url, timeout=10) self.assertEqual(follow.status_code, 200, follow.text) + def test_executions_list_on_a_function(self): + """The executions collection exists on every entity that lists the action. + + `/functions/{id}/operations/{op}/executions` used to 404 with + `entity-not-found` - the handler reached for the component cache and + then the app cache and gave up - immediately after the very same + function had listed that operation. + + @verifies REQ_INTEROP_036 + """ + self.wait_for_operation('/functions/powertrain', 'long_calibration') + base = (f'{self.BASE_URL}/functions/powertrain/operations' + '/long_calibration/executions') + + started = requests.post(base, json={}, timeout=15) + self.assertEqual(started.status_code, 202, started.text) + execution_id = started.json()['id'] + self.addCleanup(requests.delete, f'{base}/{execution_id}', timeout=10) + + listed = requests.get(base, timeout=10) + self.assertEqual(listed.status_code, 200, listed.text) + self.assertIn(execution_id, [i['id'] for i in listed.json()['items']]) + + def test_execution_is_scoped_to_the_entity_that_started_it(self): + """A goal id is not a global handle. + + An execution started through one entity used to be readable, stoppable + and cancellable through any other entity's URI - the handler looked the + goal up by id alone and never asked who owned it. Everything the + collection endpoint lists for an entity must be exactly what its item + endpoints resolve. + + @verifies REQ_INTEROP_036 + """ + self.wait_for_operation('/functions/powertrain', 'long_calibration') + owner = (f'{self.BASE_URL}/functions/powertrain/operations' + '/long_calibration/executions') + + started = requests.post(owner, json={'parameters': {'order': 40}}, timeout=15) + self.assertEqual(started.status_code, 202, started.text) + execution_id = started.json()['id'] + self.addCleanup(requests.delete, f'{owner}/{execution_id}', timeout=10) + + # The owning entity resolves it. + self.assertEqual( + requests.get(f'{owner}/{execution_id}', timeout=10).status_code, 200) + + # A different entity does not - not for reads, stops or cancels. + intruder = (f'{self.BASE_URL}/apps/long_calibration/operations' + '/long_calibration/executions') + self.assertEqual( + requests.get(f'{intruder}/{execution_id}', timeout=10).status_code, 404) + self.assertEqual( + requests.put(f'{intruder}/{execution_id}', + json={'capability': 'stop'}, timeout=10).status_code, 404) + self.assertEqual( + requests.delete(f'{intruder}/{execution_id}', timeout=10).status_code, 404) + self.assertNotIn( + execution_id, + [i['id'] for i in requests.get(intruder, timeout=10).json()['items']]) + + # ... and the intruding cancel did not actually stop it. + still_there = requests.get(f'{owner}/{execution_id}', timeout=10) + self.assertEqual(still_there.status_code, 200, still_there.text) + def test_create_execution_for_service(self): """POST /{entity}/operations/{op-id}/executions calls service and returns. From c06abe4ed1ab2e343b82de0e33ad33f8e33be0bd Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Wed, 5 Aug 2026 08:43:22 +0200 Subject: [PATCH 08/17] fix(gateway): read the entity collection instead of guessing it, and keep Location resolvable Script handlers guessed the collection from the request path instead of reading the segment the router matched. Separately, to_regex_path appends "/?$", so a request with a trailing slash routes successfully and req.path() + "/" + id then yields a double slash whose Location answers 404. Nine sites built a URI that way; canonical_request_path and child_resource_path are now the single place that knows about the anchor. --- .../core/http/http_utils.hpp | 41 +++++++++++++ .../ros2_medkit_gateway/http/typed_router.hpp | 2 +- .../src/http/handlers/bulkdata_handlers.cpp | 2 +- .../handlers/cyclic_subscription_handlers.cpp | 2 +- .../src/http/handlers/lock_handlers.cpp | 3 +- .../src/http/handlers/operation_handlers.cpp | 12 +++- .../src/http/handlers/script_handlers.cpp | 32 +++++++++-- .../src/http/handlers/trigger_handlers.cpp | 2 +- .../src/http/rest_server.cpp | 2 +- .../test/test_script_handlers.cpp | 26 +++++++-- .../test/features/test_locking.test.py | 40 ++++++++++++- .../test/features/test_operations_api.test.py | 50 ++++++++++++++++ .../test/features/test_scripts_api.test.py | 57 +++++++++++++++++++ 13 files changed, 251 insertions(+), 20 deletions(-) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/http_utils.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/http_utils.hpp index 96c170f77..61e367f7a 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/http_utils.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/http_utils.hpp @@ -42,6 +42,47 @@ inline std::string api_path(const std::string & endpoint) { return std::string(API_BASE_PATH) + endpoint; } +/** + * @brief A request path in the form the route regexes actually match + * + * `RouteRegistry::to_regex_path` anchors every route with `"/?$"`, so a client + * may append a trailing slash to any URI and still be routed. Nothing + * normalises the path afterwards, so `httplib::Request::path` keeps whatever + * the client sent. + * + * Any handler that echoes the request path back - in a `Location` header, an + * `href`, a `_links` entry - must run it through here first, or it publishes a + * URI that differs from the canonical one for the same resource. + * + * @param path Raw request path (e.g. "/api/v1/apps/x/scripts/") + * @return The same path without trailing slashes ("/api/v1/apps/x/scripts"); + * a path that is only slashes collapses to "/" + */ +inline std::string canonical_request_path(const std::string & path) { + size_t end = path.size(); + while (end > 1 && path[end - 1] == '/') { + --end; + } + return path.substr(0, end); +} + +/** + * @brief URI of a child of the resource this request addressed + * + * The `Location` of a resource a POST just created. Built from the request's + * own path so it names the collection the caller addressed, and canonicalised + * so a stray trailing slash cannot produce an empty path segment: `([^/]+)` in + * every route regex refuses to match one, so `/apps/x/scripts//new_id` is a + * `Location` that 404s. + * + * @param path Raw request path of the collection that was POSTed to + * @param child_id Id of the newly created child resource + * @return Absolute, API-prefixed URI of the child + */ +inline std::string child_resource_path(const std::string & path, const std::string & child_id) { + return canonical_request_path(path) + "/" + child_id; +} + /** * @brief Extract expected entity type from request path * diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/typed_router.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/typed_router.hpp index 81cfbe3b6..ec31681ab 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/typed_router.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/typed_router.hpp @@ -130,7 +130,7 @@ class TypedRequest { return req_.has_header("X-Medkit-No-Fan-Out"); } - /// Returns the request path (post-routing, post-prefix-strip). Handlers + /// Returns the request path (post-routing), API prefix included. Handlers /// occasionally need this to build a `Location` header for resources they /// just created via POST (e.g. `Location: /`). This /// is the only path-shaped read most handlers need; routes that need to 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 666b08bb8..deda05cdf 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/bulkdata_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/bulkdata_handlers.cpp @@ -572,7 +572,7 @@ BulkDataHandlers::upload(const http::TypedRequest & req, const http::MultipartBo } http::ResponseAttachments att; - att.with_location(req.path() + "/" + stored.id); + att.with_location(child_resource_path(req.path(), stored.id)); return std::make_pair(http::Created{std::move(descriptor)}, std::move(att)); } diff --git a/src/ros2_medkit_gateway/src/http/handlers/cyclic_subscription_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/cyclic_subscription_handlers.cpp index 4906d3f81..ec12f9529 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/cyclic_subscription_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/cyclic_subscription_handlers.cpp @@ -203,7 +203,7 @@ CyclicSubscriptionHandlers::post_subscription(const http::TypedRequest & req, http::ResponseAttachments att; // The subscription is a child of the POST target, and `req.path()` already // carries the API prefix, so this is the same absolute form every `href` uses. - att.with_location(req.path() + "/" + sub_dto.id); + att.with_location(child_resource_path(req.path(), sub_dto.id)); return std::make_pair(http::Created{std::move(sub_dto)}, std::move(att)); } diff --git a/src/ros2_medkit_gateway/src/http/handlers/lock_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/lock_handlers.cpp index d78532f71..f276ef45a 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/lock_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/lock_handlers.cpp @@ -22,6 +22,7 @@ #include #include "ros2_medkit_gateway/core/http/error_codes.hpp" +#include "ros2_medkit_gateway/core/http/http_utils.hpp" #include "ros2_medkit_gateway/http/handlers/handler_support.hpp" using json = nlohmann::json; @@ -235,7 +236,7 @@ LockHandlers::post_lock(const http::TypedRequest & req, dto::AcquireLockRequest auto lock_dto = lock_info_to_dto(*result, client_id); http::ResponseAttachments att; - att.with_location(std::string(req.path()) + "/" + result->lock_id); + att.with_location(child_resource_path(req.path(), result->lock_id)); return std::make_pair(http::Created{std::move(lock_dto)}, std::move(att)); } catch (const std::exception & e) { diff --git a/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp index 80ba7a339..a2e76865f 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp @@ -26,6 +26,7 @@ #include "ros2_medkit_gateway/core/http/error_codes.hpp" #include "ros2_medkit_gateway/core/http/fan_out_helpers.hpp" +#include "ros2_medkit_gateway/core/http/http_utils.hpp" #include "ros2_medkit_gateway/core/managers/operation_manager.hpp" #include "ros2_medkit_gateway/core/plugins/plugin_manager.hpp" #include "ros2_medkit_gateway/core/providers/operation_provider.hpp" @@ -517,7 +518,11 @@ OperationHandlers::create_execution(const http::TypedRequest & req, dto::Executi return tl::make_unexpected(lookup.error()); } const auto & ops = lookup->ops; - const std::string id_field = (lookup->entity_type == "app") ? "app_id" : "component_id"; + // `EntityInfo` already carries the right key for all four types; the previous + // "app_id if it is an app, else component_id" put `component_id` in the error + // params of every area and function caller whose goal was rejected, naming a + // field the caller never sent. + const std::string & id_field = entity_info.id_field; std::optional service_info; std::optional action_info; @@ -568,7 +573,7 @@ OperationHandlers::create_execution(const http::TypedRequest & req, dto::Executi // Rebuilding the path from an "app or else component" choice, as this // used to, handed an area or function caller a `/components/...` URI // that resolves to nothing. - const std::string location = req.path() + "/" + action_result.goal_id; + const std::string location = child_resource_path(req.path(), action_result.goal_id); http::ResponseAttachments att; att.with_location(location); @@ -864,7 +869,8 @@ OperationHandlers::update_execution(const http::TypedRequest & req, const dto::E // API-prefixed, and already naming the collection the caller used. The // previous "/apps/ if the path mentions it, else /components/" rebuild // pointed area and function callers at a URI that resolves to nothing. - const std::string location = req.path(); + // Canonicalised so the header does not echo a caller's trailing slash. + const std::string location = canonical_request_path(req.path()); dto::OperationExecution exec_dto; exec_dto.id = execution_id; diff --git a/src/ros2_medkit_gateway/src/http/handlers/script_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/script_handlers.cpp index 1c6d9fade..710cabe66 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/script_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/script_handlers.cpp @@ -84,7 +84,26 @@ ScriptHandlers::ScriptHandlers(HandlerContext & ctx, ScriptManager * script_mana } std::string ScriptHandlers::entity_type_from_path(const std::string & path) { - return (path.find("/components/") != std::string::npos) ? "components" : "apps"; + // The collection the caller actually addressed, read back out of the routed + // request path (`/api/v1///scripts/...`). + // + // The scripts routes are registered for apps and components only + // (`rest_server.cpp`, the `et_type_str == "apps" || == "components"` guard), + // so the previous "components if the path says so, else apps" choice happened + // to be right for every path that routes today. Reading the segment instead + // of choosing it means it stays right if that list grows - and until then it + // is one fewer place that has to be found and edited. + // + // The empty return below is a defensive fallback, not a supported answer: an + // unprefixed path cannot reach a handler, because routes are mounted under + // `API_BASE_PATH`. If it ever did, the caller would build `//` from it. + static const std::string kPrefix = std::string(API_BASE_PATH) + "/"; + if (path.compare(0, kPrefix.size(), kPrefix) != 0) { + return {}; + } + const size_t start = kPrefix.size(); + const size_t end = path.find('/', start); + return end == std::string::npos ? path.substr(start) : path.substr(start, end - start); } bool ScriptHandlers::is_valid_resource_id(const std::string & id) { @@ -241,8 +260,10 @@ ScriptHandlers::upload_script(const http::TypedRequest & req, const http::Multip return tl::unexpected(script_backend_error(result.error())); } - auto entity_type_segment = entity_type_from_path(req.path()); - auto script_path = api_path("/" + entity_type_segment + "/" + entity_id + "/scripts/" + result->id); + // The script is a child of the POST target, and `req.path()` already + // carries the API prefix, so this is the same absolute form every `href` + // uses - and it names the collection the caller addressed. + const std::string script_path = child_resource_path(req.path(), result->id); dto::ScriptUploadResponse upload_resp; upload_resp.id = result->id; @@ -422,9 +443,8 @@ ScriptHandlers::start_execution(const http::TypedRequest & req) { return tl::unexpected(script_backend_error(result.error())); } - auto entity_type_segment = entity_type_from_path(req.path()); - auto exec_path = - api_path("/" + entity_type_segment + "/" + entity_id + "/scripts/" + script_id + "/executions/" + result->id); + // Same as the upload above: the execution is a child of the POST target. + const std::string exec_path = child_resource_path(req.path(), result->id); http::ResponseAttachments att; att.with_location(exec_path); diff --git a/src/ros2_medkit_gateway/src/http/handlers/trigger_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/trigger_handlers.cpp index aa8e5a61f..5dbd40606 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/trigger_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/trigger_handlers.cpp @@ -274,7 +274,7 @@ TriggerHandlers::post_trigger(const http::TypedRequest & req, dto::TriggerCreate http::ResponseAttachments att; // The trigger is a child of the POST target, and `req.path()` already carries // the API prefix, so this is the same absolute form every `href` uses. - att.with_location(req.path() + "/" + trigger_dto.id); + att.with_location(child_resource_path(req.path(), trigger_dto.id)); return std::make_pair(http::Created{std::move(trigger_dto)}, std::move(att)); } diff --git a/src/ros2_medkit_gateway/src/http/rest_server.cpp b/src/ros2_medkit_gateway/src/http/rest_server.cpp index 4a996c60e..3db08f5a3 100644 --- a/src/ros2_medkit_gateway/src/http/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/http/rest_server.cpp @@ -446,7 +446,7 @@ void RESTServer::setup_routes() { // declaration cannot reach here, so the header is set - and // declared below - by hand. `req.path` already carries the API // prefix, matching the form every other 201 uses. - res.set_header("Location", req.path + "/" + created->id); + res.set_header("Location", child_resource_path(req.path, created->id)); res.status = 201; res.set_content(FaultTriggerEngine::rule_to_json(*created).dump(2), "application/json"); }) diff --git a/src/ros2_medkit_gateway/test/test_script_handlers.cpp b/src/ros2_medkit_gateway/test/test_script_handlers.cpp index e6ce244be..706c374ea 100644 --- a/src/ros2_medkit_gateway/test/test_script_handlers.cpp +++ b/src/ros2_medkit_gateway/test/test_script_handlers.cpp @@ -200,6 +200,22 @@ httplib::Request make_script_request(const std::string & entity_type, const std: return req; } +/// Request for `POST /{entity}/scripts/{script_id}/executions`. +/// +/// Distinct from `make_script_request` because the route really does end in +/// `/executions`, and the handler builds the 202 `Location` by appending the +/// new execution id to the request path. A fixture that stops at the script +/// resource describes a route the gateway does not serve. +httplib::Request make_start_execution_request(const std::string & entity_type, const std::string & entity_id, + const std::string & script_id) { + httplib::Request req; + req.path = "/api/v1/" + entity_type + "/" + entity_id + "/scripts/" + script_id + "/executions"; + std::string pattern = "/api/v1/" + entity_type + "/([^/]+)/scripts/([^/]+)/executions"; + std::regex re(pattern); + std::regex_match(req.path, req.matches, re); + return req; +} + httplib::Request make_execution_request(const std::string & entity_type, const std::string & entity_id, const std::string & script_id, const std::string & execution_id) { httplib::Request req; @@ -414,7 +430,7 @@ class ScriptHandlersErrorMappingTest : public ::testing::Test { mock_provider_->error_code = err; mock_provider_->error_message = "test error"; - req_storage = make_script_request("components", "ecu", "test_script"); + req_storage = make_start_execution_request("components", "ecu", "test_script"); req_storage.body = R"({"execution_type": "now"})"; http::TypedRequest typed(req_storage); return handlers_->start_execution(typed); @@ -526,7 +542,9 @@ TEST_F(ScriptHandlersErrorMappingTest, UploadReturns201WithLocation) { for (const auto & [name, value] : att.headers) { if (name == "Location") { found_location = true; - EXPECT_NE(value.find("/scripts/uploaded_001"), std::string::npos); + // Exact, not a substring: the header is the request path plus the new + // id, so the collection segment is part of what is being asserted. + EXPECT_EQ(value, "/api/v1/components/ecu/scripts/uploaded_001"); } } EXPECT_TRUE(found_location); @@ -561,7 +579,7 @@ TEST_F(ScriptHandlersErrorMappingTest, UploadRejectsWrongContentType) { TEST_F(ScriptHandlersErrorMappingTest, StartExecutionReturns202WithLocation) { mock_provider_->succeed = true; - auto req = make_script_request("components", "ecu", "test_script"); + auto req = make_start_execution_request("components", "ecu", "test_script"); req.body = R"({"execution_type": "now"})"; http::TypedRequest typed(req); @@ -578,7 +596,7 @@ TEST_F(ScriptHandlersErrorMappingTest, StartExecutionReturns202WithLocation) { for (const auto & [name, value] : att.headers) { if (name == "Location") { found_location = true; - EXPECT_NE(value.find("/executions/exec_001"), std::string::npos); + EXPECT_EQ(value, "/api/v1/components/ecu/scripts/test_script/executions/exec_001"); } } EXPECT_TRUE(found_location); diff --git a/src/ros2_medkit_integration_tests/test/features/test_locking.test.py b/src/ros2_medkit_integration_tests/test/features/test_locking.test.py index e76139df5..bb7b89e5e 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_locking.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_locking.test.py @@ -29,7 +29,7 @@ import launch_testing import requests -from ros2_medkit_test_utils.constants import ALLOWED_EXIT_CODES +from ros2_medkit_test_utils.constants import ALLOWED_EXIT_CODES, API_BASE_PATH from ros2_medkit_test_utils.gateway_test_case import GatewayTestCase from ros2_medkit_test_utils.launch_helpers import create_test_launch @@ -115,6 +115,44 @@ def test_acquire_lock_on_app(self): self.assertIn('T', data['lock_expiration']) self.assertIn('Z', data['lock_expiration']) + # The 201 must name the lock it created, and that URI has to resolve. + self.assertEqual( + resp.headers.get('Location'), + f'{API_BASE_PATH}/apps/temp_sensor/locks/{data["id"]}') + follow = requests.get( + f'{self.BASE_URL}/apps/temp_sensor/locks/{data["id"]}', + headers={'X-Client-Id': 'client_a'}, timeout=10) + self.assertEqual(follow.status_code, 200, follow.text) + + # @verifies REQ_INTEROP_100 + def test_acquire_lock_location_survives_a_trailing_slash(self): + """The 201 `Location` must resolve even when the caller adds a slash. + + Route regexes end `/?$`, so `POST .../locks/` routes and the path is + not normalised. Appending the lock id to it produced + `.../locks//lock_1`, which the item route cannot match - `([^/]+)` + refuses an empty segment - so the header handed back a dead URI. This + is on the shipped default: `locking.enabled` is true out of the box. + """ + resp = requests.post( + f'{self.BASE_URL}/apps/temp_sensor/locks/', + json={'lock_expiration': 300}, + headers={'X-Client-Id': 'client_slash'}, + timeout=10, + ) + self.assertEqual(resp.status_code, 201, resp.text) + lock_id = resp.json()['id'] + self.addCleanup( + self._delete_lock, 'apps', 'temp_sensor', lock_id, 'client_slash') + + location = resp.headers.get('Location') + self.assertEqual(location, f'{API_BASE_PATH}/apps/temp_sensor/locks/{lock_id}') + + follow = requests.get( + self.BASE_URL + location[len(API_BASE_PATH):], + headers={'X-Client-Id': 'client_slash'}, timeout=10) + self.assertEqual(follow.status_code, 200, follow.text) + # @verifies REQ_INTEROP_100 def test_acquire_lock_with_scopes(self): """Acquire a scoped lock.""" diff --git a/src/ros2_medkit_integration_tests/test/features/test_operations_api.test.py b/src/ros2_medkit_integration_tests/test/features/test_operations_api.test.py index d16a25f82..91519b97a 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_operations_api.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_operations_api.test.py @@ -425,6 +425,56 @@ def test_list_executions_returns_items_array(self): self.assertIn('items', data) self.assertIsInstance(data['items'], list) + def test_rejected_goal_names_the_entity_field_the_caller_used(self): + """The error params key the caller's own entity type. + + `id_field` was chosen the same "app or else component" way the Location + was, so a function whose goal was rejected got back + `{"component_id": "powertrain"}` - a field it never sent, on an entity + type that is not a component. The demo action rejects order > 50, which + is the cheapest way to reach the params. + + @verifies REQ_INTEROP_035 + """ + self.wait_for_operation('/functions/powertrain', 'long_calibration') + + resp = requests.post( + f'{self.BASE_URL}/functions/powertrain/operations' + '/long_calibration/executions', + json={'parameters': {'order': 99}}, timeout=15) + self.assertEqual(resp.status_code, 400, resp.text) + + params = resp.json()['parameters'] + self.assertEqual(params.get('function_id'), 'powertrain') + self.assertNotIn('component_id', params) + + def test_execution_location_survives_a_trailing_slash(self): + """The same dead-URI trap as the scripts one, on the executions route. + + `POST .../executions/` routes (every route regex ends `/?$`) and the + path is not normalised, so appending the goal id to it produced + `.../executions//` - a `Location` no route can match. + + @verifies REQ_INTEROP_035 + """ + self.wait_for_operation('/functions/powertrain', 'long_calibration') + + resp = requests.post( + f'{self.BASE_URL}/functions/powertrain/operations' + '/long_calibration/executions/', + json={}, timeout=15) + self.assertEqual(resp.status_code, 202, resp.text) + + location = resp.headers['Location'] + self.assertEqual( + location, + f'{API_BASE_PATH}/functions/powertrain/operations/long_calibration' + f'/executions/{resp.json()["id"]}') + + follow_url = self.BASE_URL + location[len(API_BASE_PATH):] + self.addCleanup(requests.delete, follow_url, timeout=10) + self.assertEqual(requests.get(follow_url, timeout=10).status_code, 200) + def test_async_execution_location_resolves(self): """The 202 `Location` names the execution under the addressed collection. diff --git a/src/ros2_medkit_integration_tests/test/features/test_scripts_api.test.py b/src/ros2_medkit_integration_tests/test/features/test_scripts_api.test.py index 6fc827199..194e9f7dd 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_scripts_api.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_scripts_api.test.py @@ -206,6 +206,63 @@ def test_01_list_scripts_empty(self): self.assertIn('items', data) self.assertIsInstance(data['items'], list) + # @verifies REQ_INTEROP_041 + def test_01b_urls_name_the_collection_the_caller_addressed(self): + """`href`, `_links` and `Location` all use the caller's own collection. + + These used to come from "components if the path says so, else apps". + The segment is now read out of the request path instead of chosen, so + this pins both reachable collections - a component caller must not + start getting `/apps/...` back from the rewritten derivation. + + Areas and functions cannot be checked here: the scripts routes are + registered for apps and components only (`rest_server.cpp`), so those + paths never route and no URL is built for them. That is why the old + guess never actually mislabelled a live response. + """ + r, script_id = self._upload_script(filename='collection_pin.py') + self.assertEqual( + r.headers['Location'], + f'{API_BASE_PATH}/apps/temp_sensor/scripts/{script_id}') + + listed = self.get_json('/apps/temp_sensor/scripts') + self.assertEqual(listed['_links']['self'], f'{API_BASE_PATH}/apps/temp_sensor/scripts') + self.assertEqual(listed['_links']['parent'], f'{API_BASE_PATH}/apps/temp_sensor') + hrefs = [i['href'] for i in listed['items'] if i['id'] == script_id] + self.assertEqual(hrefs, [f'{API_BASE_PATH}/apps/temp_sensor/scripts/{script_id}']) + + comp_id = self.get_json('/components')['items'][0]['id'] + comp_listed = self.get_json(f'/components/{comp_id}/scripts') + self.assertEqual( + comp_listed['_links']['self'], + f'{API_BASE_PATH}/components/{comp_id}/scripts') + self.assertEqual( + comp_listed['_links']['parent'], f'{API_BASE_PATH}/components/{comp_id}') + + # @verifies REQ_INTEROP_040 + def test_01c_trailing_slash_location_still_resolves(self): + """A stray trailing slash must not produce a `Location` that 404s. + + Route regexes are anchored with `/?$`, so `POST .../scripts/` routes + just as `POST .../scripts` does, and nothing normalises the path + afterwards. Appending the new id to the raw request path then yields + `.../scripts//new_id`, which no route matches - `([^/]+)` cannot match + an empty segment - so the header hands the client a dead URI. + """ + files = {'file': ('slash.py', PYTHON_SCRIPT, 'application/octet-stream')} + r = requests.post( + f'{self.BASE_URL}/apps/temp_sensor/scripts/', files=files, timeout=5) + self.assertEqual(r.status_code, 201, r.text) + + location = r.headers['Location'] + self.assertNotIn('//', location.split('://')[-1]) + self.assertEqual( + location, f'{API_BASE_PATH}/apps/temp_sensor/scripts/{r.json()["id"]}') + + follow = requests.get( + self.BASE_URL + location[len(API_BASE_PATH):], timeout=5) + self.assertEqual(follow.status_code, 200, follow.text) + # @verifies REQ_INTEROP_040 def test_02_upload_and_list(self): """Upload a script, verify it appears in the list.""" From 55060eaed8cf7104ace23235463d95462a6bb531 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Wed, 5 Aug 2026 08:43:22 +0200 Subject: [PATCH 09/17] feat(gateway): type the payloads a client cannot guess 15 schemas were unreachable from any operation and 9 operations published a body with no content at all. Responses, request bodies, error bodies, SSE frames and the update payloads SOVD already defines are now bound to the types the handlers use, with success_schema() narrowing only the declared schema where a handler must keep returning free-form JSON so peer vendor keys survive. Two wire defects surfaced doing it: the vendor-error sentinel was emitted without its vendor_code on one path, so a client could not identify the error; and the error-code table claimed completeness while its check matched any mention anywhere in the guide. The check now reads the table itself, and the emitter scan covers headers and in-tree plugins. --- docs/api/rest.rst | 349 ++++++++++++++++-- src/ros2_medkit_gateway/CMakeLists.txt | 22 ++ .../design/dto_contract.rst | 146 +++++++- .../core/http/error_codes.hpp | 5 + .../core/openapi/document_checks.hpp | 40 ++ .../include/ros2_medkit_gateway/dto/auth.hpp | 37 ++ .../include/ros2_medkit_gateway/dto/data.hpp | 51 ++- .../include/ros2_medkit_gateway/dto/enums.hpp | 9 + .../ros2_medkit_gateway/dto/errors.hpp | 76 +++- .../dto/fault_triggers.hpp | 136 +++++++ .../ros2_medkit_gateway/dto/faults.hpp | 70 +++- .../ros2_medkit_gateway/dto/operations.hpp | 11 +- .../ros2_medkit_gateway/dto/registry.hpp | 25 +- .../ros2_medkit_gateway/dto/schema_writer.hpp | 93 +++-- .../ros2_medkit_gateway/dto/scripts.hpp | 57 ++- .../ros2_medkit_gateway/dto/sse_frames.hpp | 155 ++++++++ .../ros2_medkit_gateway/dto/triggers.hpp | 57 +++ .../ros2_medkit_gateway/dto/updates.hpp | 70 +++- .../scripts/check_error_codes_documented.py | 206 +++++++++++ .../src/core/openapi/document_checks.cpp | 130 +++++++ .../src/core/openapi/route_registry.cpp | 164 +++++++- .../http/handlers/sse_transport_provider.cpp | 7 + .../src/http/rest_server.cpp | 101 ++++- .../src/openapi/capability_generator.cpp | 27 +- .../src/openapi/openapi_spec_builder.cpp | 27 +- .../src/openapi/route_registry.hpp | 104 ++++++ .../test/test_dto_contract.cpp | 44 ++- .../test/test_schema_reachability.cpp | 164 ++++++++ .../test/test_sse_transport_provider.cpp | 82 ++++ .../test/test_trigger_manager.cpp | 29 ++ .../features/test_openapi_contract.test.py | 270 +++++++++++++- 31 files changed, 2586 insertions(+), 178 deletions(-) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/openapi/document_checks.hpp create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/fault_triggers.hpp create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/sse_frames.hpp create mode 100644 src/ros2_medkit_gateway/scripts/check_error_codes_documented.py create mode 100644 src/ros2_medkit_gateway/src/core/openapi/document_checks.cpp create mode 100644 src/ros2_medkit_gateway/test/test_schema_reachability.cpp diff --git a/docs/api/rest.rst b/docs/api/rest.rst index 202c06890..dce466574 100644 --- a/docs/api/rest.rst +++ b/docs/api/rest.rst @@ -815,7 +815,12 @@ Request Transition ``configurator``. - **202:** Transition accepted (the ``Location`` header points to the status URI) - - **403:** Caller lacks the required role (``insufficient-access-rights``) + - **403:** Two different refusals share this status. The auth middleware + rejects a caller without the role above, ahead of the handler, in the + RFC 6749 shape (``{"error": "insufficient_scope", ...}``). The lifecycle + provider rejects the transition itself in the SOVD shape + (``{"error_code": "insufficient-access-rights", ...}``). Read + ``error`` vs ``error_code`` to tell them apart. - **404:** Entity not found - **409:** A precondition was not fulfilled (``precondition-not-fulfilled``) - **501:** No lifecycle provider is registered for the entity (``not-implemented``) @@ -1826,7 +1831,7 @@ Start Execution * - ``execution_type`` - string - M - - When to run: ``now``, ``on_restart``, ``now_and_on_restart``, ``once_on_restart`` + - When to run. The shipped backend accepts only ``now``; see the note below * - ``parameters`` - object - O @@ -2094,26 +2099,56 @@ Trigger Events (SSE Stream) Content-Type: text/event-stream Cache-Control: no-cache - **EventEnvelope format:** + **Frame format:** - Each event is delivered as an SSE ``data:`` frame containing a JSON - EventEnvelope: + Each event is one SSE frame carrying an ``id:`` field and a ``data:`` field + holding the JSON ``TriggerEventFrame``: .. code-block:: text + id: 1 data: {"timestamp":"2026-03-19T10:30:00.250Z","payload":{"data":{"data":85.5}}} - When an error occurs during evaluation: + The id counts events on this connection from 1, and unlike the fault stream + this route does not read ``Last-Event-ID`` - so the id is a position within + one connection, not a replay cursor, and reconnecting restarts it at 1. + + A brief disconnect does not by itself lose events. Each trigger holds a + queue of up to 100 pending events, filled as conditions fire whether or not + a client is attached, and drained on the next connection - so a multishot + trigger that is reconnected to promptly delivers what it buffered. + + The queue is in memory and belongs to the trigger's lifetime, not to the + connection, so anything that ends or resets the trigger takes the queue with + it. Known cases: overflow past 100 discards the oldest; a single-shot + trigger terminates on firing, after which its stream answers ``404``; the + ``lifetime`` expiring discards the trigger's whole state; deleting the + trigger, or restarting the gateway, does the same. Restart loses the queue + even for a ``persistent`` trigger - persistence stores the trigger and its + last observed value, never its pending events. Treat the buffer as a + convenience across a reconnect, not as a delivery guarantee; if you need + one, poll the underlying resource rather than relying on the stream. + + While no event is pending, the stream sends a comment line rather than a + frame, every 15 seconds: .. code-block:: text - data: {"timestamp":"2026-03-19T10:30:00.250Z","error":"Failed to read resource"} + :keepalive - **EventEnvelope fields:** + **TriggerEventFrame fields:** - ``timestamp`` (string) - ISO 8601 timestamp of when the event was generated - - ``payload`` (object) - The resource value that satisfied the condition (present on success) - - ``error`` (string) - Error description (present on failure, mutually exclusive with payload) + - ``payload`` (object) - The observed resource's value at the moment the + condition fired - the whole value, not the ``path`` sub-document the + condition was evaluated against + + There is no ``error`` member. A trigger frame exists only because a + condition fired, so there is no failed-evaluation case to report; a + resource that cannot be read simply produces no frame. The *cyclic + subscription* stream is the one that reports a failed sample inline - see + ``SubscriptionEventFrame`` - and the two are easy to confuse because they + are otherwise the same shape. The stream closes when: @@ -2233,7 +2268,8 @@ use, so a client can tell "this build has no threshold engine" apart from "no such app or rule". ``GET /api/v1/apps/{app_id}/fault-triggers`` - List the app's rules. + List the app's rules. The owning app is the one in the path; it is not + repeated in the item, and neither is the engine's internal cross latch. .. code-block:: json @@ -2241,7 +2277,6 @@ such app or rule". "items": [ { "id": "ftr_1", - "app_id": "tank_process", "data_name": "level", "operator": ">=", "threshold": 80.0, @@ -2310,7 +2345,7 @@ If a request exceeds the available tokens, it is rejected with an HTTP 429 statu .. code-block:: json { - "error_code": 429, + "error_code": "rate-limit-exceeded", "message": "Too many requests. Please retry after 10 seconds.", "parameters": { "retry_after": 10, @@ -2319,11 +2354,30 @@ If a request exceeds the available tokens, it is rejected with an HTTP 429 statu } } +.. _rest-authentication: + Authentication Endpoints ------------------------ JWT-based authentication with Role-Based Access Control (RBAC). +The ``/auth/*`` endpoints, and the authentication middleware guarding every +other route, answer errors in the RFC 6749 section 5.2 shape rather than the +SOVD ``GenericError`` used everywhere else: + +.. code-block:: json + + { + "error": "invalid_grant", + "error_description": "Refresh token is expired or unknown" + } + +``/auth/authorize`` and ``/auth/token`` accept the request body as either +``application/json`` or ``application/x-www-form-urlencoded``, the encoding +RFC 6749 clients default to. ``/auth/revoke`` accepts JSON only, and per +RFC 7009 section 2.2 answers ``200`` whether or not the submitted token was +valid - so it never returns ``401``. + .. seealso:: :doc:`/tutorials/authentication` for configuration details. @@ -2619,23 +2673,48 @@ the full field listing. Error Responses --------------- -All error responses follow a consistent format: +Every error carries the SOVD ``GenericError`` body - a flat object, not a +nested ``error`` envelope: .. code-block:: json { - "error": { - "code": "ERR_ENTITY_NOT_FOUND", - "message": "Entity not found", - "details": { - "entity_id": "unknown_component" - } + "error_code": "entity-not-found", + "message": "Entity not found", + "parameters": { + "entity_id": "unknown_component" } } +``error_code`` and ``message`` are always present. ``parameters`` is +cause-specific and omitted when there is nothing to add. + +A vendor-specific failure carries a **fourth** key. The gateway rewrites +``error_code`` to the sentinel ``vendor-error`` and moves the real +``x-medkit-*`` code into ``vendor_code``, so a generic SOVD client sees a code +it knows while the precise one stays available: + +.. code-block:: json + + { + "error_code": "vendor-error", + "vendor_code": "x-medkit-gateway-shutdown", + "message": "Gateway is shutting down" + } + +Match on ``error_code``, and on ``vendor_code`` when ``error_code`` is +``vendor-error``. Do not match on ``message`` - it is prose and changes. + +The ``/auth/*`` endpoints are the one exception: they answer RFC 6749 +section 5.2 ``{"error": "...", "error_description": "..."}`` instead, as does +the authentication middleware on the 401 and 403 it returns ahead of any +route. See :ref:`rest-authentication`. + Common Error Codes ~~~~~~~~~~~~~~~~~~ +These are the values that appear in ``error_code`` on the wire. + .. list-table:: :header-rows: 1 :widths: 30 15 55 @@ -2643,30 +2722,71 @@ Common Error Codes * - Error Code - HTTP Status - Description - * - ``ERR_ENTITY_NOT_FOUND`` + * - ``entity-not-found`` - 404 - The requested entity does not exist - * - ``ERR_RESOURCE_NOT_FOUND`` + * - ``resource-not-found`` - 404 - The requested resource (topic, service, parameter) does not exist - * - ``ERR_INVALID_INPUT`` + * - ``operation-not-found`` + - 404 + - The named operation does not exist on this entity + * - ``invalid-request`` - 400 - - Invalid request body or parameters - * - ``ERR_INVALID_ENTITY_ID`` + - Malformed request body (not valid JSON, or not an object). The same code + also appears below with a 409, on a lock acquire collision - read the + status, not the code alone, to tell the two apart. + * - ``invalid-parameter`` - 400 - - Entity ID contains invalid characters - * - ``ERR_OPERATION_FAILED`` - - 500 - - Operation failed during execution - * - ``ERR_TIMEOUT`` - - 504 - - Operation timed out - * - ``ERR_UNAUTHORIZED`` - - 401 - - Authentication required or token invalid - * - ``ERR_FORBIDDEN`` + - A field or query parameter failed validation. ``parameters.parameter`` + names the offending one. + * - ``collection-not-supported`` + - 400 + - This entity type does not serve the requested resource collection + * - ``precondition-not-fulfilled`` + - 409 + - The request conflicts with current state (e.g. a duplicate + ``fault_code`` on a fault-trigger rule) + * - ``lock-broken`` + - 409 + - A guarded write was refused because another client holds a lock on the + entity. ``parameters.lock_id`` names it. + * - ``invalid-request`` + - 409 + - The request conflicts with the current state of the resource. This is + **not** lock-specific - operations use it to refuse re-executing a + running operation, for instance - so do not assume lock semantics or a + lock-shaped ``parameters``. ``message`` identifies the conflict and + ``parameters`` varies with it. For the lock cases and what each one + carries, see :ref:`the lock refusal table `. The + same code also appears with 400 for a malformed body - see the row + above. + * - ``insufficient-access-rights`` + - 403 + - A lifecycle provider refused the transition (``AccessDenied``). Not a + lock error - the lock routes never emit this code. + * - ``forbidden`` - 403 - - Insufficient permissions for this operation + - The caller is not the owner of the lock it tried to release or extend + (``LockManager``'s ``lock-not-owner``). + * - ``payload-too-large`` + - 413 + - Upload exceeds the configured size limit + * - ``not-implemented`` + - 501 + - The feature is not enabled, or no backend is configured for it + * - ``service-unavailable`` + - 503 + - A backing service (fault store, subscription manager) refused + * - ``rate-limit-exceeded`` + - 429 + - Client exceeded its request quota + * - ``internal-error`` + - 500 + - Unhandled failure. ``parameters.details`` carries the cause. + * - ``vendor-error`` + - varies + - A vendor-specific failure; read ``vendor_code`` for the real code * - ``x-medkit-plugin-error`` - 400-599 - Plugin provider returned an error. Status varies by plugin. Message truncated to 512 chars. @@ -2680,12 +2800,167 @@ Common Error Codes - Could not create the underlying ROS 2 subscription (rcl error during slot creation). Transient: retry once after a short backoff. Persistent failure usually indicates a publisher type mismatch or a missing IDL package. + * - ``x-medkit-resource-sample-failed`` + - n/a + - A cyclic subscription's sampler could not read the resource on this + tick. Delivered inside the SSE frame's ``error`` object, never as an + HTTP status; the stream stays open and the next tick is retried. * - ``x-medkit-cold-wait-cap-exceeded`` - 503 - Too many concurrent /data callers are waiting on cold (publisher-but-no-data) topics. Retry with exponential backoff. ``params.cold_wait_cap`` carries the configured cap. Tune via ``data_provider.cold_wait_cap`` and ``data_provider.max_parallel_samples`` if this fires under normal load. + * - ``x-medkit-ros2-topic-unavailable`` + - 404 + - The data resource names a topic the ROS 2 graph does not currently have + * - ``x-medkit-ros2-service-unavailable`` + - 500 + - A ROS 2 service call backing an operation failed + * - ``x-medkit-ros2-action-rejected`` + - 400 + - A ROS 2 action server rejected the goal + * - ``x-medkit-ros2-action-unavailable`` + - 500 + - A ROS 2 action execution failed + * - ``x-medkit-ros2-parameter-read-only`` + - 403 + - The configuration parameter is declared read-only on the node + * - ``x-medkit-update-not-found`` + - 404 + - No update package with that id + * - ``x-medkit-update-already-exists`` + - 400 + - An update package with that id is already registered + * - ``x-medkit-update-in-progress`` + - 409 + - Another update is executing, or this one is being deleted + * - ``x-medkit-update-not-prepared`` + - 400 + - ``execute`` was called before ``prepare`` completed + * - ``x-medkit-update-not-automated`` + - 400 + - ``automated`` was requested on a package whose ``automated`` is false + * - ``x-medkit-script-already-exists`` + - 409 + - A script with that id already exists + * - ``x-medkit-managed-script`` + - 409 + - The script is manifest-managed and cannot be modified over REST + * - ``x-medkit-script-running`` + - 409 + - The script has a running execution and cannot be deleted + * - ``x-medkit-script-not-running`` + - 409 + - A control action was sent to an execution that is not running + * - ``x-medkit-concurrency-limit`` + - 429 + - The script backend's concurrent-execution limit was reached + * - ``x-medkit-script-too-large`` + - 413 + - The uploaded script exceeds the configured size limit + * - ``x-medkit-ros2-node-unavailable`` + - 503 + - The node backing a configuration read or write did not answer in time + * - ``x-medkit-invalid-resource-uri`` + - 400 + - A trigger or subscription ``resource`` URI does not parse + * - ``x-medkit-entity-mismatch`` + - 400 + - A trigger or subscription ``resource`` URI names a different entity than + the route it was posted to + * - ``x-medkit-collection-not-supported`` + - 400 + - The ``resource`` URI names a collection triggers and subscriptions + cannot observe + * - ``x-medkit-collection-not-available`` + - 400 + - The ``resource`` URI names a collection this entity does not serve + * - ``x-medkit-unsupported-protocol`` + - 400 + - The requested subscription ``protocol`` has no registered transport + +The table is kept complete by a check rather than by review: +``scripts/check_error_codes_documented.py`` (ctest +``gateway_error_codes_documented``) fails if any ``ERR_*`` declared in +``error_codes.hpp`` and named anywhere in the gateway's sources, its headers, +or an in-tree plugin is missing from **this table** - a mention elsewhere in +this guide does not count. So the claim it backs is exactly: every error code +this repository can put on the wire appears above. + +What that check does not reach, and this sentence therefore does not claim: a +third-party plugin may raise codes declared nowhere in this repository, and the +statuses and descriptions in the third column are read from the emitters by +hand. One code is excluded by name in the script, with its reason - +``x-medkit-internal-forwarded``, a framework sentinel the error writer returns +on before rendering anything. + +Several codes are reached by more than one internal cause. Locking is where +that matters most, because its outcomes are spread across five rows above; +this is every refusal the lock routes can produce: + +.. _rest-lock-refusals: + +.. list-table:: Lock refusals, by internal cause + :header-rows: 1 + :widths: 26 12 24 38 + + * - Internal cause + - Status + - ``error_code`` + - Notes + * - ``lock-conflict`` + - 409 + - ``invalid-request`` + - Entity already locked. Carries ``existing_lock_id``; retryable with + ``break_lock`` + * - ``lock-not-breakable`` + - 409 + - ``invalid-request`` + - What a ``break_lock`` retry returns when the held lock forbids it. Also + carries ``existing_lock_id`` + * - ``lock-not-owner`` + - 403 + - ``forbidden`` + - Releasing or extending a lock held by another client + * - ``lock-not-found`` + - 404 + - ``resource-not-found`` + - No lock on the entity, or it has expired + * - ``invalid-expiration`` + - 400 + - ``invalid-parameter`` + - Expiration or extension exceeding the configured maximum. The + non-positive branch never gets this far - the handler rejects it first + with its own ``invalid-parameter`` + * - ``lock-required`` + - 409 + - ``invalid-request`` + - The entity's manifest requires a lock for this collection and the caller + holds none. Carries ``details``, ``entity_id`` and ``collection``, and - + unlike the two rows above - **no** ``existing_lock_id``, because no lock + exists to name + * - (guarded write) + - 409 + - ``lock-broken`` + - A write refused because another client's lock covers the collection. + Always carries ``entity_id`` and ``collection``; carries ``lock_id`` only + when the blocking lock could be identified. Comes from the request + handler rather than from ``LockManager`` + +Three refusals the manager can construct are shadowed by an earlier handler +check and so do not reach a client in that form: ``lock-disabled`` (the lock +routes answer ``501`` ``not-implemented`` before ``LockManager`` is consulted), +unknown scope, and non-positive expiration (``LockHandlers`` validates both +against the same vocabulary first and emits its own ``400`` +``invalid-parameter``, with ``parameters.invalid_scope`` naming the offending +scope). + +On ``PUT`` / ``DELETE .../locks/{lock_id}`` the ``404`` a client normally meets +is the handler's - it checks the lock exists and belongs to the entity before +delegating, and its body carries ``lock_id`` and ``entity_id``. The manager's +own parameter-free ``404`` is still reachable in the narrow window where the +lock expires between those two lookups. .. _rest-range-rejection: diff --git a/src/ros2_medkit_gateway/CMakeLists.txt b/src/ros2_medkit_gateway/CMakeLists.txt index f1a3e3034..0e3e29a65 100644 --- a/src/ros2_medkit_gateway/CMakeLists.txt +++ b/src/ros2_medkit_gateway/CMakeLists.txt @@ -363,6 +363,22 @@ if(BUILD_TESTING) ) set_tests_properties(gateway_plugin_header_purity PROPERTIES LABELS "linter") + # ─── Error-code documentation coverage ──────────────────────────────────── + # Fails if any ERR_* with a non-test emitter is missing from the REST + # guide's error table. The table carries completeness claims that nothing + # else checks, and prose is the tier this branch found least durable. + # + # Interpreter found explicitly rather than relying on Python3_EXECUTABLE + # being set: it is a cache variable our build passes on the command line, so + # a configure without it would silently register an empty COMMAND. + find_package(Python3 COMPONENTS Interpreter REQUIRED) + add_test( + NAME gateway_error_codes_documented + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/scripts/check_error_codes_documented.py" + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + ) + set_tests_properties(gateway_error_codes_documented PROPERTIES LABELS "linter") + # ─── gateway_core link-time smoke test ──────────────────────────────────── # Compiles a translation unit including a sampling of core/ headers and # links exclusively against gateway_core + GTest. No ament_target_dependencies @@ -1012,6 +1028,12 @@ if(BUILD_TESTING) target_link_libraries(test_capability_generator gateway_ros2) medkit_set_test_domain(test_capability_generator) + # Schema reachability tests (orphan detection over a finished document). + # gateway_core, not gateway_ros2: unreachable_schemas() takes a nlohmann::json + # and touches no ROS type, so it lives in the neutral layer. + ament_add_gtest(test_schema_reachability test/test_schema_reachability.cpp) + target_link_libraries(test_schema_reachability gateway_core) + # Route registry tests (OpenAPI route registration and path conversion) ament_add_gtest(test_route_registry test/test_route_registry.cpp) target_link_libraries(test_route_registry gateway_ros2) diff --git a/src/ros2_medkit_gateway/design/dto_contract.rst b/src/ros2_medkit_gateway/design/dto_contract.rst index cb6ce8f90..aa63fa80b 100644 --- a/src/ros2_medkit_gateway/design/dto_contract.rst +++ b/src/ros2_medkit_gateway/design/dto_contract.rst @@ -401,6 +401,27 @@ any error branch via the route's configured ``ErrorRenderer`` (``kSovdGenericError`` by default; the ``/auth/*`` routes opt into ``kOAuth2Error`` to emit the RFC 6749 wire shape). +The document reads the same field. ``to_openapi_paths()`` selects between the +``GenericError`` and ``OAuth2Error`` component responses from +``route.error_renderer_``, so the declared error body and the rendered one come +from one fact rather than from a hand-written list that can go stale. Three +statuses deliberately bypass that selection, because they are not produced by +the route's renderer at all: + +- **416** is written by cpp-httplib before routing, with no body, and + ``RESTServer::setup_global_error_handlers`` fills it with a ``GenericError``. + That happens on the ``/auth/*`` routes too, so 416 is declared as a + ``GenericError`` everywhere. +- **401 / 403** come from ``AuthMiddleware``, also ahead of any handler. Both + serialise ``AuthErrorResponse::to_json()`` = ``{error, error_description}``, + which is the RFC 6749 shape - so the shared ``Unauthorized`` and ``Forbidden`` + component responses carry the ``OAuth2Error`` schema on every route, not just + the auth ones. They keep their own components because only there can their + ``WWW-Authenticate`` header live: no handler return type produces it. + +``RateLimited`` (429) stays a ``GenericError``: the limiter emits the SOVD +shape, unlike the two auth statuses beside it. + Type-System Guarantees ~~~~~~~~~~~~~~~~~~~~~~ @@ -436,6 +457,19 @@ already-derived 2xx and touches neither the status nor the schema. Without it the framework publishes a status-appropriate default ("Created", "Accepted", "No content", "Successful response"). +Its sibling ``.success_schema()`` rewrites the *schema* of the already-derived +2xx, again leaving the status and the description alone. It exists for the few +routes whose C++ return type is a pass-through envelope +(``FaultListResult`` and friends) purely so a backend's JSON survives +byte-for-byte, while the wire shape on that particular route is nonetheless +fixed. ``GET /faults`` is the shipped example: it returns ``FaultListResult`` so +the FaultManager's items and the peers' merged items are never re-parsed, and it +publishes ``FaultList`` because - unlike the per-entity list - it has no +plugin-delegation branch and can only ever answer with that shape. Using it on a +route that *does* delegate to a plugin would promise fields no plugin sends. +A call that finds no single declared 2xx is dropped and reported by +``validate_completeness()``. + Routes whose handler genuinely returns a ``std::variant`` - the ``post_alternates`` / ``del_alternates`` helpers - legitimately declare more than one 2xx. Those helpers call ``RouteEntry::mark_alternates()`` themselves, @@ -832,8 +866,45 @@ remain compile-time-checked at their boundary. Used by the fault SSE stream and by cyclic-subscription event streams. The helper declares ``text/event-stream`` on the 200 from the same string it hands cpp-httplib, and declares **no** frame schema: the three SSE families - put different shapes in ``data:``, so one schema would be wrong for two of - them. + put different shapes in ``data:``, so one schema *here* would be wrong for + two of them. Each registration names its own with + ``.success_schema()``, which replaces the schema of the + already-declared 200 and leaves its ``Cache-Control`` / + ``X-Accel-Buffering`` headers standing - a second ``response(200, ...)`` + would replace the whole response object and drop them. + + The three families and the code each schema has to agree with: + + .. list-table:: + :header-rows: 1 + :widths: 30 30 40 + + * - DTO + - Built by + - Shape + * - ``TriggerEventFrame`` + - ``TriggerManager`` + - ``{timestamp, payload}``; no error branch, because a frame exists only + when the condition fired + * - ``SubscriptionEventFrame`` + - ``SubscriptionTransportProvider::make_sse_stream`` + - ``{timestamp, payload | error}``; a failed sample reports and the + stream stays open + * - ``FaultStreamEvent`` + - ``SSEFaultHandler::format_sse_event`` + - ``{event_type, fault, timestamp, x-medkit?}``; no ``payload`` key at + all, and ``timestamp`` is epoch seconds where the other two send an + ISO 8601 string + + A schema against ``text/event-stream`` is legitimate because a ``data:`` + field *is* a JSON document. ``response()`` decides that per media type + (``media_type_carries_a_json_document``) rather than allowing it wholesale, + so a binary download still cannot acquire one - see ``binary_download`` + below. Neither schema covers the non-JSON lines a stream also emits: + ``:keepalive`` comments, and the ``id:`` / ``event:`` fields the fault stream + sets. The fault stream declares the matching ``Last-Event-ID`` request + header, without which its frame ids are a number clients can see and cannot + use. - ``reg.binary_download(path, handler, media_types)`` - registers a range-aware binary download. The handler returns a ``Result`` carrying ``provider``, ``content_type``, ``filename``, ``supports_ranges``, @@ -854,6 +925,18 @@ remain compile-time-checked at their boundary. ``Result>``. Uploads declare 201 through ``TResponse`` (``http::Created``) and use the attachments only for the ``Location`` header. Used by bulk-data POST/PUT. + + The *request* half has to be declared at the call site with + ``.multipart_body(desc, parts)``: the helper cannot see which parts a handler + looks up in ``MultipartBody.parts``, so without it the body is the + ``{"type": "object", "additionalProperties": true}`` placeholder the helper + installs - a body no generated client can build. Each ``MultipartPart`` names + the part, says whether the handler rejects the request without it, and carries + either a schema (a textual part such as ``metadata``) or an empty schema plus + a ``content_type`` (a binary part such as ``file``). The empty schema is + deliberate: OpenAPI 3.1 describes a binary part through + ``encoding..contentType``, having dropped ``format: binary`` with the + rest of the pre-JSON-Schema-2020-12 vocabulary. - ``reg.static_asset(path, handler)`` - serves bytes already in memory (Swagger UI bundles, embedded HTML/JS/CSS) as ``Result`` carrying ``bytes``, ``content_type``, and @@ -942,11 +1025,38 @@ The typed envelopes - ``FaultListResult``, ``FaultDetailResult``, ``FaultClearResult``, the corresponding ``Data*Result`` and ``Operation*Result`` shapes, and ``UpdateProvider::get_update``'s typed return - wrap an opaque ``content`` payload so the wire bytes are byte-identical to the pre-typed -ABI: ``JsonWriter`` emits the ``content`` object verbatim, ``SchemaWriter`` -publishes ``x-medkit-opaque: true``, and JsonReader accepts any JSON object -on round-trip. This keeps backend-specific shapes (UDS DTC records, OPC-UA -alarm metadata, vendor extensions) flowing through unchanged while pinning -the envelope itself to a single typed contract. +ABI: ``JsonWriter`` emits the ``content`` object verbatim and JsonReader +accepts any JSON object on round-trip. This keeps backend-specific shapes +(UDS DTC records, OPC-UA alarm metadata, vendor extensions) flowing through +unchanged while pinning the envelope itself to a single typed contract. + +What their ``SchemaWriter`` publishes is *not* uniformly +``{type: object, x-medkit-opaque: true}``. An envelope on a route that also +serves ROS 2-backed entities has a known shape for exactly those entities, and +publishing the bare object schema told a client nothing about either branch. So +those envelopes publish an ``anyOf``: the in-tree DTO first, the opaque +plugin branch last, with a schema-level ``description`` naming +``x-medkit.source`` as the way a client tells the two apart before calling. +``FaultListResult`` (``FaultList`` | ``FaultListAggregated`` | plugin), +``FaultDetailResult`` (``FaultDetail`` | plugin) and ``DataListResult`` +(``DataList`` | plugin) are the three. Envelopes with no in-tree named shape - +``FaultClearResult``, ``DataValue``, ``OperationExecutionResult`` - stay a plain +opaque object and carry a ``description`` saying who decides the shape and where +a client discovers it. Every named schema needs *some* prose for that reason; +``test_openapi_contract`` fails a content-free schema that carries none. + +``UpdateDetail`` and ``UpdateRegisterRequest`` show the third variant: their +``SchemaWriter`` publishes real ``properties`` while ``JsonWriter`` and +``JsonReader`` stay pass-through. Nothing round-trips through the descriptor, +so the vendor extensions a backend stores (Uptane TUF metadata, component +lists) survive untouched, and ``additionalProperties: true`` keeps them legal. +The two are typed from different sources, because the standard treats them +differently: ``UpdateDetail`` gets SOVD's attribute table (ISO 17978-3 +section 7.18), since SOVD fixes the response shape; ``UpdateRegisterRequest`` +declares only ``id`` - the sole field ``post_update`` validates - because SOVD +leaves the register *request* manufacturer-specific. Transplanting the response +table onto the request would document a validation the gateway does not +perform. Commercial plugins (UDS, OPC-UA, Uptane OTA, ...) implement the typed interface directly. Out-of-tree plugins that previously returned raw @@ -1033,7 +1143,12 @@ The published ``openapi.json`` is assembled mechanically from two sources: - ``components/schemas`` is exactly ``collect_component_schemas()`` - one entry per DTO listed in ``dto/registry.hpp``, with no hand-written - survivors merged in. + survivors merged in. Membership of ``AllDtos`` is therefore a publishing + decision, not a bookkeeping one: a DTO that exists only to type a plugin ABI + (``DataWriteResult``, the return type of ``DataProvider::write_data``, which + no route answers with) or that a route stopped returning + (``Collection``, superseded by ``ScriptList``) must be left + out, or every generated client materialises a type it can never receive. - ``paths`` is ``RouteRegistry::to_openapi_paths()``: every typed route contributes a path item with ``$ref`` entries auto-derived from its ``TResponse`` / ``TBody`` template parameters plus any tags, summary, @@ -1051,6 +1166,21 @@ hand-written ``paths`` items in the published spec, and no hand-written schema blocks in ``components/schemas``. Adding a route or a DTO field updates the spec on the next process start with no schema-side edit. +Because the two blocks are compiled independently, they can drift apart: a DTO +can sit in ``AllDtos`` while no route's ``$ref`` chain reaches it. +``openapi::unreachable_schemas(document)`` +(``core/openapi/document_checks.hpp``) closes that gap. It walks every ``$ref`` +an operation makes, follows the chain through ``components/responses`` and +through each reached schema, and returns the names nothing arrives at. +``CapabilityGenerator::generate_root()`` runs it over the assembled document and +logs a warning naming the orphans; +``test_openapi_contract::test_no_unreachable_schemas`` is what turns a suite red. +It is deliberately a free function over the finished document rather than a rule +in ``RouteRegistry::validate_completeness()``: the registry sees routes and has +no visibility of either component block, so only the assembled document can +answer the question. Its unit tests are ``test_schema_reachability``, which +links ``gateway_core`` - the function touches no ROS type. + Optional fields are now emitted as ``anyOf: [, {type: "null"}]`` (OpenAPI 3.1 idiom) so generated clients see ``T | null`` rather than ``T | undefined``. That matches the wire reality of the gateway: optional diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/error_codes.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/error_codes.hpp index c27edbe3a..93213bd87 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/error_codes.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/error_codes.hpp @@ -108,6 +108,11 @@ constexpr const char * ERR_X_MEDKIT_SUBSCRIBE_FAILED = "x-medkit-subscribe-faile /// Concurrent cold-wait pool saturation; retry with backoff constexpr const char * ERR_X_MEDKIT_COLD_WAIT_CAP_EXCEEDED = "x-medkit-cold-wait-cap-exceeded"; +/// A cyclic subscription's resource sampler could not produce a value this +/// tick. Delivered inside an SSE frame's `error` object, never as an HTTP +/// status: the stream stays open and the next tick is retried. +constexpr const char * ERR_X_MEDKIT_RESOURCE_SAMPLE_FAILED = "x-medkit-resource-sample-failed"; + /// Software update package not found constexpr const char * ERR_X_MEDKIT_UPDATE_NOT_FOUND = "x-medkit-update-not-found"; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/openapi/document_checks.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/openapi/document_checks.hpp new file mode 100644 index 000000000..b03a04121 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/openapi/document_checks.hpp @@ -0,0 +1,40 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include + +namespace ros2_medkit_gateway { +namespace openapi { + +/// Names in `components/schemas` that no operation can reach, transitively, +/// through `components/responses` (or through any other component section a +/// `$ref` chain passes on the way). +/// +/// A non-empty result means the document ships schemas a generated client will +/// never use: the generator emits a type for each one, and nothing in the API +/// surface ever produces or consumes it. +/// +/// Deliberately a free function over the finished document rather than a rule +/// inside `RouteRegistry::validate_completeness()`. The registry sees routes, +/// not components - `components/schemas` comes from `dto::AllDtos` and +/// `components/responses` from `OpenApiSpecBuilder`, neither of which the +/// registry can read. Only the assembled document knows both halves. +std::set unreachable_schemas(const nlohmann::json & document); + +} // namespace openapi +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/auth.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/auth.hpp index 285496c34..12a759caf 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/auth.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/auth.hpp @@ -134,5 +134,42 @@ inline constexpr auto dto_fields = std::make_tuple(field("st template <> inline constexpr std::string_view dto_name = "AuthRevokeResponse"; +// ============================================================================= +// OAuth2Error - RFC 6749 section 5.2 error body. Deliberately NOT the SOVD +// GenericError shape. +// +// Two emitters put these exact two keys on the wire, and both used to be +// documented as a GenericError: +// * the /auth/* routes, via `write_oauth2_error` +// (core/http/detail/primitives.cpp), selected by +// `.error_renderer(kOAuth2Error)` on the registration; and +// * AuthMiddleware, whose 401 (`invalid_token`) and 403 +// (`insufficient_scope`) both serialise `AuthErrorResponse::to_json()` +// = {error, error_description} ahead of any handler, on every route. +// +// So this shape is not confined to the auth endpoints: with authentication on, +// it is what any route answers when the token is missing or under-scoped. +// ============================================================================= +// `error_description` is required, not optional. RFC 6749 leaves it optional, +// but both emitters set it unconditionally - `write_oauth2_error` from +// `ErrorInfo::message`, `AuthErrorResponse::to_json()` from its own member - so +// declaring it optional would tell a client to handle an absence this gateway +// cannot produce. +struct OAuth2Error { + std::string error; + std::string error_description; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("error", &OAuth2Error::error, + "RFC 6749 error code, e.g. `invalid_grant`, `invalid_request`, `unsupported_grant_type`, " + "`invalid_token`, `insufficient_scope`, `server_error`."), + field("error_description", &OAuth2Error::error_description, + "Human-readable cause. Always present, though RFC 6749 permits its absence.")); + +template <> +inline constexpr std::string_view dto_name = "OAuth2Error"; + } // namespace dto } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/data.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/data.hpp index 7a0275e06..c8b3d4e10 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/data.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/data.hpp @@ -166,22 +166,15 @@ template <> inline constexpr std::string_view dto_name = "DataWriteRequest"; // ============================================================================= -// Collection - named "DataList" -// ============================================================================= -template <> -inline constexpr std::string_view dto_name> = "DataList"; - -// ============================================================================= -// Collection - typed list shape returned by -// list_data (PR-403 commit 28). +// Collection - named "DataList", the list shape +// list_data returns for a ROS 2-backed entity. // -// Same wire shape as the legacy `Collection` named "DataList", but -// the `x-medkit` payload carries the rich `DataListXMedkit` fields (entity_id, -// aggregated, aggregation_sources, aggregation_level, total_count, partial, -// failed_peers, peer_dropped_items) instead of the generic -// `XMedkitCollection`. The schema name is kept identical to the legacy -// `DataList` $ref so existing OpenAPI clients are not affected; the -// difference is purely server-side typing. +// The `x-medkit` payload is `DataListXMedkit` (entity_id, aggregated, +// aggregation_sources, aggregation_level, total_count, partial, failed_peers, +// peer_dropped_items), which is what the handler writes. The single-argument +// `Collection` used to carry this name too, defaulting `x-medkit` to +// the generic `XMedkitCollection` no data route has ever emitted; naming only +// the emitted shape keeps the document from advertising the other one. // ============================================================================= template <> inline constexpr std::string_view dto_name> = "DataList"; @@ -255,7 +248,22 @@ struct JsonReader { template <> struct SchemaWriter { static nlohmann::json schema() { - return nlohmann::json{{"type", "object"}, {"additionalProperties", true}, {"x-medkit-opaque", true}}; + return nlohmann::json{ + {"description", + "Data-resource list whose shape depends on who owns the entity. An entity backed by the ROS 2 " + "graph answers with `DataList`. A plugin-owned entity answers with whatever its DataProvider " + "returns and the gateway emits that verbatim. Which branch applies is discoverable ahead of the " + "call: `x-medkit.source` on the entity's own document (`GET /{entity_type}/{entity_id}`) reads " + "`plugin` for a plugin-owned entity."}, + {"anyOf", nlohmann::json::array( + {nlohmann::json{{"$ref", "#/components/schemas/DataList"}}, + nlohmann::json{{"title", "PluginDataList"}, + {"type", "object"}, + {"additionalProperties", true}, + {"x-medkit-opaque", true}, + {"description", + "Plugin-defined list. The in-tree OPC-UA plugin adds value / unit / " + "data_type / writable per item. Read the plugin's own documentation."}}})}}; } }; @@ -317,7 +325,16 @@ struct JsonReader { template <> struct SchemaWriter { static nlohmann::json schema() { - return nlohmann::json{{"type", "object"}, {"additionalProperties", true}, {"x-medkit-opaque", true}}; + return nlohmann::json{ + {"type", "object"}, + {"additionalProperties", true}, + {"x-medkit-opaque", true}, + {"description", + "One data resource's value. No fixed shape exists: on the ROS 2 path the body carries the live " + "message, whose fields come from the topic's IDL type, and on a plugin-owned entity it carries " + "whatever the DataProvider returns (an OPC-UA node adds unit / data_type / writable, a UDS DID " + "its own record layout). Discover the concrete shape from the item's entry in `GET " + "/{entity_type}/{entity_id}/data`, whose `x-medkit` names the ROS 2 message type."}}; } }; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/enums.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/enums.hpp index 885eacc2d..446462835 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/enums.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/enums.hpp @@ -65,5 +65,14 @@ inline constexpr std::string_view kExecutionCapabilityValues[] = {"stop", "execu /// Lifecycle status (LifecycleStatusResponse.status). inline constexpr std::string_view kLifecycleStatusValues[] = {"ready", "notReady"}; +/// Fault-trigger comparison operator (FaultTriggerEngine::valid_operator). +inline constexpr std::string_view kFaultTriggerOperatorValues[] = {">", "<", ">=", "<=", "=="}; + +/// Fault-trigger severity (FaultTriggerEngine::valid_severity). Deliberately +/// NOT kFaultSeverityLabelValues: the engine spells the middle level `WARNING` +/// where a reported fault's label spells it `WARN`, so one table for both would +/// document a value one of the two rejects. +inline constexpr std::string_view kFaultTriggerSeverityValues[] = {"INFO", "WARNING", "ERROR", "CRITICAL"}; + } // namespace dto } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/errors.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/errors.hpp index 6508759b6..49af19589 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/errors.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/errors.hpp @@ -21,23 +21,89 @@ #include #include "ros2_medkit_gateway/dto/contract.hpp" +#include "ros2_medkit_gateway/dto/schema_writer.hpp" namespace ros2_medkit_gateway { namespace dto { -// GenericError mirrors SchemaBuilder::generic_error(): -// required: error_code, message -// optional: parameters (free-form JSON object - schema says {"type":"object"}) +// GenericError is the SOVD error body, and `SchemaBuilder::generic_error()` +// delegates here so the sub-page specs cannot describe a different shape. +// +// required: error_code, message +// optional: parameters, vendor_code +// +// `vendor_code` is a real fourth key, not a reserve: `write_generic_error` +// (core/http/detail/primitives.cpp) rewrites `error_code` to the `vendor-error` +// sentinel and moves the original into `vendor_code` whenever the code is an +// `x-medkit-*` one. A client matching on `error_code` alone therefore sees +// every vendor failure collapse into one value, and the schema said nothing +// about where the real one went. struct GenericError { std::string error_code; std::string message; std::optional parameters; + /// NSDMI, not a bare member: the aggregate initialisations of this struct + /// predate the field and must keep compiling under + /// -Wmissing-field-initializers. + std::optional vendor_code{}; }; template <> inline constexpr auto dto_fields = - std::make_tuple(field("error_code", &GenericError::error_code), field("message", &GenericError::message), - field("parameters", &GenericError::parameters)); + std::make_tuple(field("error_code", &GenericError::error_code, + "SOVD error code. The literal `vendor-error` means the specific code is in `vendor_code`."), + field("message", &GenericError::message, "Human-readable cause. Not stable enough to match on."), + // No description: `SchemaWriter` below replaces this whole + // property, so anything written here is dead text that never reaches the + // document. The published prose lives with the replacement. + field("parameters", &GenericError::parameters), + field("vendor_code", &GenericError::vendor_code, + "The `x-medkit-*` code the gateway actually raised, present exactly when `error_code` is " + "`vendor-error`.")); + +// SchemaWriter specialization: declare the `parameters` keys a client is +// expected to branch on. +// +// The generic walk types `parameters` from its C++ member, `optional`, +// which publishes `anyOf: [{}, {"type": "null"}]` - not even constrained to an +// object. Prose in the field description cannot be checked by anything and is +// the weakest tier we have, so the two recovery-bearing keys get real +// properties. `additionalProperties: true` keeps every other key legal, which +// matters because the set is open: 30-odd diagnostic keys across the handlers, +// and a plugin can add its own. +// +// Starts from the derived schema rather than hand-writing all four properties, +// so adding a field to `dto_fields` still reaches the document. +template <> +struct SchemaWriter { + static nlohmann::json schema() { + nlohmann::json schema = derived_object_schema(); + schema["properties"]["parameters"] = nlohmann::json{ + {"type", "object"}, + {"additionalProperties", true}, + {"description", + "Cause-specific detail. The declared keys are the ones a client can act on; the rest " + "(`entity_id`, `parameter` - the field at fault on a 400 - `details`, `collection`, " + "`invalid_scope`, `supported_capabilities`, ...) are diagnostic."}, + {"properties", + nlohmann::json{{"existing_lock_id", + {{"type", "string"}, + {"description", + "The lock already held on the entity, returned with the 409 from " + "`POST /{entity_type}/{entity_id}/locks`. Pass it to " + "`DELETE /{entity_type}/{entity_id}/locks/{lock_id}` to break the lock, or retry the " + "acquire with `break_lock`. Note this 409 carries `error_code: invalid-request`, not " + "`lock-broken` - see `lock_id` below."}}}, + {"lock_id", + {{"type", "string"}, + {"description", + "The lock that blocked a guarded write, returned with the `lock-broken` 409 from a data / " + "operations / configurations / faults / logs / bulk-data write. A different 409 from " + "`existing_lock_id` above: this one says an existing lock stopped this request, that one " + "says an acquire collided."}}}}}}; + return schema; + } +}; template <> inline constexpr std::string_view dto_name = "GenericError"; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/fault_triggers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/fault_triggers.hpp new file mode 100644 index 000000000..aa2cbde5f --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/fault_triggers.hpp @@ -0,0 +1,136 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include +#include +#include + +#include "ros2_medkit_gateway/dto/contract.hpp" +#include "ros2_medkit_gateway/dto/enums.hpp" + +namespace ros2_medkit_gateway { +namespace dto { + +// ============================================================================= +// FaultTriggerRule - one threshold rule on an app's discovered data points. +// +// Wire keys are exactly FaultTriggerEngine::rule_to_json(): +// id, data_name, operator, threshold, fault_code, severity, active +// +// Named FaultTriggerRule in the `dto` namespace; the engine's own +// `ros2_medkit_gateway::FaultTriggerRule` is a different type in a different +// namespace and carries two fields this one deliberately does not: `app_id` +// (the route path already says which app) and `crossed` (a runtime latch +// persisted to the store, never part of the REST response). +// +// The three routes are `raw()` registrations, so nothing parses a request or +// writes a response through these descriptors - the engine does both by hand. +// They exist so the published schema and the engine's field names are one edit +// apart. `field_enum` is therefore documentation here rather than enforcement, +// and both vocabularies are copied from the engine's own validators +// (`valid_operator`, `valid_severity`). +// ============================================================================= +struct FaultTriggerRule { + std::string id; + std::string data_name; + std::string comparison; // wire key: "operator" (a C++ keyword) + double threshold{0.0}; + std::string fault_code; + std::string severity; + bool active{true}; +}; + +template <> +inline constexpr auto dto_fields = std::make_tuple( + field("id", &FaultTriggerRule::id, "Server-assigned rule id, as returned on create."), + field("data_name", &FaultTriggerRule::data_name, + "Data resource on the app whose value the rule watches. Must be one the app currently exposes."), + field_enum("operator", &FaultTriggerRule::comparison, kFaultTriggerOperatorValues, + "Comparison applied to the sampled value against `threshold`."), + field("threshold", &FaultTriggerRule::threshold, "Value the comparison is made against."), + field("fault_code", &FaultTriggerRule::fault_code, + "Fault raised on a threshold cross. Unique across every rule on every app - fault codes are the " + "fault store's primary key, so a duplicate is refused with 409."), + field_enum("severity", &FaultTriggerRule::severity, kFaultTriggerSeverityValues, + "Severity of the raised fault. Note `WARNING`, not the `WARN` a reported fault's " + "`severity_label` uses."), + // Required, unlike its counterpart on the create request: `rule_to_json` + // always emits it, so a response never omits it. "Defaults to true" is a + // property of the request, where the field really can be left out. + field("active", &FaultTriggerRule::active, "Whether the rule is currently being evaluated.")); + +template <> +inline constexpr std::string_view dto_name = "FaultTriggerRule"; + +// ============================================================================= +// FaultTriggerRuleCreateRequest - POST /{entity}/fault-triggers body. +// +// Fields and their validation come from FaultTriggerEngine::create(): every +// member below except `active` is rejected with 400 when missing or malformed. +// `id` is server-assigned and is not read from the request. +// ============================================================================= +struct FaultTriggerRuleCreateRequest { + std::string data_name; + std::string comparison; // wire key: "operator" + double threshold{0.0}; + std::string fault_code; + std::string severity; + std::optional active; +}; + +template <> +inline constexpr auto dto_fields = std::make_tuple( + field("data_name", &FaultTriggerRuleCreateRequest::data_name, + "Data resource on the app to watch. A name the app does not expose is refused with 400, so a rule " + "cannot be created that could never fire."), + field_enum("operator", &FaultTriggerRuleCreateRequest::comparison, kFaultTriggerOperatorValues, + "Comparison applied to the sampled value against `threshold`."), + field("threshold", &FaultTriggerRuleCreateRequest::threshold, "Value the comparison is made against."), + field("fault_code", &FaultTriggerRuleCreateRequest::fault_code, + "Fault to raise on a threshold cross. Must not already be used by another rule (409)."), + field_enum("severity", &FaultTriggerRuleCreateRequest::severity, kFaultTriggerSeverityValues, + "Severity of the raised fault."), + field("active", &FaultTriggerRuleCreateRequest::active, + "Whether to evaluate the rule immediately. Defaults to true.")); + +template <> +inline constexpr std::string_view dto_name = "FaultTriggerRuleCreateRequest"; + +// ============================================================================= +// FaultTriggerRuleList - GET /{entity}/fault-triggers response. +// +// A struct of its own rather than `Collection`: the list +// route emits a bare `{"items": [...]}` and nothing else, whereas the generic +// wrapper would publish optional `x-medkit` (typed `XMedkitCollection`) and +// `_links` members this route has never sent. That is the same defect +// `FaultList` carried - a documented vendor extension the gateway does not +// emit - and the fix is to describe only what is sent. +// ============================================================================= +struct FaultTriggerRuleList { + std::vector items; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("items", &FaultTriggerRuleList::items, "Rules scoped to this app, in creation order.")); + +template <> +inline constexpr std::string_view dto_name = "FaultTriggerRuleList"; + +} // namespace dto +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/faults.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/faults.hpp index f6ec7f9b6..8ca5b61d7 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/faults.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/faults.hpp @@ -273,10 +273,27 @@ template <> inline constexpr std::string_view dto_name = "FaultDetail"; // ============================================================================= -// Collection - named "FaultList" +// Collection - named "FaultList" +// +// The x-medkit member is FaultListXMedkit, not the generic XMedkitCollection: +// every fault-list emitter writes a FaultListXMedkit (count / muted_count / +// cluster_count / ...) and none of them writes XMedkitCollection's +// total_count + contributors. Naming the generic one here published a +// vendor-extension object the gateway has never sent. +// ============================================================================= +template <> +inline constexpr std::string_view dto_name> = "FaultList"; + +// ============================================================================= +// Collection - named "FaultListAggregated" +// +// The per-entity list emits this instead of FaultList when the entity spans +// several reporting sources (Function / Component / Area branches of +// handle_list_faults): same items, a different vendor extension carrying the +// aggregation level and the source FQNs. // ============================================================================= template <> -inline constexpr std::string_view dto_name> = "FaultList"; +inline constexpr std::string_view dto_name> = "FaultListAggregated"; // ============================================================================= // FaultListResult - typed envelope around the plugin-defined fault list @@ -327,7 +344,25 @@ struct JsonReader { template <> struct SchemaWriter { static nlohmann::json schema() { - return nlohmann::json{{"type", "object"}, {"additionalProperties", true}, {"x-medkit-opaque", true}}; + return nlohmann::json{ + {"description", + "Fault list whose shape depends on who owns the entity. An entity backed by the ROS 2 graph " + "answers with `FaultList`, or with `FaultListAggregated` when the entity spans several reporting " + "sources. A plugin-owned entity answers with whatever its FaultProvider returns and the gateway " + "emits that verbatim. Which branch applies is discoverable ahead of the call: `x-medkit.source` on " + "the entity's own document (`GET /{entity_type}/{entity_id}`) reads `plugin` for a plugin-owned " + "entity."}, + {"anyOf", + nlohmann::json::array({nlohmann::json{{"$ref", "#/components/schemas/FaultList"}}, + nlohmann::json{{"$ref", "#/components/schemas/FaultListAggregated"}}, + nlohmann::json{{"title", "PluginFaultList"}, + {"type", "object"}, + {"additionalProperties", true}, + {"x-medkit-opaque", true}, + {"description", + "Plugin-defined list. A UDS backend adds DTC status bytes and " + "snapshot record references; an OPC-UA backend adds node references " + "and severity metadata. Read the plugin's own documentation."}}})}}; } }; @@ -387,7 +422,23 @@ struct JsonReader { template <> struct SchemaWriter { static nlohmann::json schema() { - return nlohmann::json{{"type", "object"}, {"additionalProperties", true}, {"x-medkit-opaque", true}}; + return nlohmann::json{ + {"description", + "One fault whose shape depends on who owns the entity. An entity backed by the ROS 2 graph answers " + "with `FaultDetail` (SOVD `item` + `environment_data` + `x-medkit`). A plugin-owned entity answers " + "with whatever its FaultProvider returns and the gateway emits that verbatim. Which branch applies " + "is discoverable ahead of the call: `x-medkit.source` on the entity's own document " + "(`GET /{entity_type}/{entity_id}`) reads `plugin` for a plugin-owned entity."}, + {"anyOf", + nlohmann::json::array({nlohmann::json{{"$ref", "#/components/schemas/FaultDetail"}}, + nlohmann::json{{"title", "PluginFaultDetail"}, + {"type", "object"}, + {"additionalProperties", true}, + {"x-medkit-opaque", true}, + {"description", + "Plugin-defined fault. Backends carry DTC environment records, " + "snapshot blobs or vendor extended status here. Read the plugin's " + "own documentation."}}})}}; } }; @@ -447,7 +498,16 @@ struct JsonReader { template <> struct SchemaWriter { static nlohmann::json schema() { - return nlohmann::json{{"type", "object"}, {"additionalProperties", true}, {"x-medkit-opaque", true}}; + return nlohmann::json{ + {"type", "object"}, + {"additionalProperties", true}, + {"x-medkit-opaque", true}, + {"description", + "Acknowledgement of a clear, shaped by whoever owns the entity. The ROS 2 path answers " + "`{\"code\": , \"cleared\": true}`; a plugin answers with its backend's own " + "acknowledgement - UDS clear response codes, vendor warnings, residual fault state - and the " + "gateway emits it verbatim. Treat the 2xx status, not a body field, as the signal that the clear " + "succeeded."}}; } }; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/operations.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/operations.hpp index fe6d8fe93..cf72f0a6f 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/operations.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/operations.hpp @@ -248,7 +248,16 @@ struct JsonReader { template <> struct SchemaWriter { static nlohmann::json schema() { - return nlohmann::json{{"type", "object"}, {"additionalProperties", true}, {"x-medkit-opaque", true}}; + return nlohmann::json{ + {"type", "object"}, + {"additionalProperties", true}, + {"x-medkit-opaque", true}, + {"description", + "Result of a synchronous operation. The shape belongs to the operation, not to this endpoint: on " + "the ROS 2 path it is the service response or action result converted field-for-field from its IDL " + "type, and on a plugin-owned entity it is whatever the OperationProvider returns (an OPC-UA method " + "result, a UDS service response). Discover the concrete shape from the operation's own document, " + "`GET /{entity_type}/{entity_id}/operations/{operation_id}`."}}; } }; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp index 3825173f4..59a77e1ba 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp @@ -28,6 +28,7 @@ #include "ros2_medkit_gateway/dto/data.hpp" #include "ros2_medkit_gateway/dto/entities.hpp" #include "ros2_medkit_gateway/dto/errors.hpp" +#include "ros2_medkit_gateway/dto/fault_triggers.hpp" #include "ros2_medkit_gateway/dto/faults.hpp" #include "ros2_medkit_gateway/dto/health.hpp" #include "ros2_medkit_gateway/dto/lifecycle.hpp" @@ -36,6 +37,7 @@ #include "ros2_medkit_gateway/dto/operations.hpp" #include "ros2_medkit_gateway/dto/schema_writer.hpp" #include "ros2_medkit_gateway/dto/scripts.hpp" +#include "ros2_medkit_gateway/dto/sse_frames.hpp" #include "ros2_medkit_gateway/dto/triggers.hpp" #include "ros2_medkit_gateway/dto/updates.hpp" #include "ros2_medkit_gateway/dto/x_medkit.hpp" @@ -49,9 +51,12 @@ using AllDtos = std::tuple, Collection, - Collection, Collection, FaultListItem, Collection, + Collection, Collection, FaultListItem, + Collection, Collection, FaultListXMedkit, FaultListAggXMedkit, FaultStatus, FaultItem, FaultEnvironmentData, FaultXMedkit, - FaultDetail, FaultListResult, FaultDetailResult, FaultClearResult, XMedkitOperationItem, + FaultDetail, FaultListResult, FaultDetailResult, FaultClearResult, + // Fault-trigger (threshold-rule) domain DTOs + FaultTriggerRule, FaultTriggerRuleCreateRequest, FaultTriggerRuleList, XMedkitOperationItem, XMedkitOperationExecution, OperationItem, Collection, OperationDetail, OperationExecution, ExecutionId, Collection, ExecutionCreateRequest, ExecutionCreateAsync, ExecutionUpdateRequest, OperationExecutionResult, @@ -59,9 +64,13 @@ using AllDtos = ConfigXMedkitItem, ConfigurationMetaData, ConfigListXMedkit, Collection, ConfigValueXMedkit, ConfigurationReadValue, ConfigurationWriteRequest, ConfigurationDeleteResultItem, ConfigurationDeleteMultiStatus, - // Data domain DTOs + // Data domain DTOs. DataWriteResult is deliberately absent: it is the + // return type of the plugin-facing DataProvider::write_data ABI, and no + // route publishes it (PUT .../data/{id} answers with DataValue), so a + // schema for it would be a type every generated client carries and none + // can ever receive. XMedkitDataItem, DataItem, Collection, DataListXMedkit, DataWriteRequest, - DataListResult, DataValue, DataWriteResult, + DataListResult, DataValue, // Lock domain DTOs Lock, Collection, AcquireLockRequest, ExtendLockRequest, // Trigger domain DTOs @@ -69,18 +78,20 @@ using AllDtos = // Cyclic subscription domain DTOs CyclicSubscription, Collection, CyclicSubscriptionCreateRequest, CyclicSubscriptionUpdateRequest, + // SSE frame DTOs - the JSON document inside a frame's `data:` field + SubscriptionEventFrame, TriggerEventFrame, FaultStreamXMedkit, FaultStreamEvent, // Bulk-data domain DTOs BulkDataCategoryList, BulkDataDescriptor, Collection, // Log domain DTOs LogContext, LogEntry, LogListXMedkit, Collection, LogConfiguration, // Script domain DTOs - ScriptMetadata, Collection, HateoasLinks, ScriptList, ScriptExecution, - ScriptUploadResponse, ScriptControlRequest, + ScriptMetadata, HateoasLinks, ScriptList, ScriptExecution, ScriptUploadResponse, ScriptControlRequest, + ScriptExecutionRequest, // Software update domain DTOs UpdateList, UpdateDetail, UpdateSubProgress, XMedkitUpdate, UpdateStatus, UpdateRegisterRequest, UpdateRegisterResponse, // Auth domain DTOs - AuthCredentials, AuthTokenResponse, AuthRevokeRequest, AuthRevokeResponse, + AuthCredentials, AuthTokenResponse, AuthRevokeRequest, AuthRevokeResponse, OAuth2Error, // Health / Root domain DTOs HealthDiscoveryLinking, HealthDiscovery, HealthAggregationWarning, Health, VersionInfoVendor, VersionInfoEntry, XMedkitVersionInfo, VersionInfo, RootCapabilities, RootAuth, RootTls, RootOverview, diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/schema_writer.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/schema_writer.hpp index 31f9fbdbe..4bc00a03b 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/schema_writer.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/schema_writer.hpp @@ -67,52 +67,63 @@ nlohmann::json schema_of() { } } -/// Generates the components/schemas object entry for a DTO type T. +/// The object schema derived from `dto_fields`, with no type-specific +/// overrides applied. +/// +/// Exposed separately from `SchemaWriter` so a specialisation can start from +/// the derived schema and replace one property, instead of hand-writing the +/// whole thing and drifting from the descriptor the writer and reader use. template -struct SchemaWriter { - static nlohmann::json schema() { - nlohmann::json props = nlohmann::json::object(); - nlohmann::json required = nlohmann::json::array(); - for_each_field([&](const auto & f) { - using FieldT = std::decay_t; - if constexpr (is_opaque_object_field_v) { - // Opaque any-object: fixed schema fragment, always required. - props[std::string(f.key)] = - nlohmann::json{{"type", "object"}, {"additionalProperties", true}, {"x-medkit-opaque", true}}; - required.push_back(std::string(f.key)); - } else { - using MemberT = std::decay_t().*(f.ptr))>; - nlohmann::json prop = schema_of(); - if (!f.description.empty()) { - prop["description"] = std::string(f.description); - } - if (f.enum_count > 0) { - nlohmann::json values = nlohmann::json::array(); - for (std::size_t i = 0; i < f.enum_count; ++i) { - values.push_back(std::string(f.enum_values[i])); - } - // For optional members schema_of() yields {anyOf:[, {null}]}; - // attach the enum to the non-null branch ([0]) so the nullable claim - // and the enum constraint agree (a top-level enum lacking "null" would - // reject the null that anyOf advertises). Required members get the - // enum at the top level. - if (prop.contains("anyOf") && prop["anyOf"].is_array() && !prop["anyOf"].empty()) { - prop["anyOf"][0]["enum"] = values; - } else { - prop["enum"] = values; - } +nlohmann::json derived_object_schema() { + nlohmann::json props = nlohmann::json::object(); + nlohmann::json required = nlohmann::json::array(); + for_each_field([&](const auto & f) { + using FieldT = std::decay_t; + if constexpr (is_opaque_object_field_v) { + // Opaque any-object: fixed schema fragment, always required. + props[std::string(f.key)] = + nlohmann::json{{"type", "object"}, {"additionalProperties", true}, {"x-medkit-opaque", true}}; + required.push_back(std::string(f.key)); + } else { + using MemberT = std::decay_t().*(f.ptr))>; + nlohmann::json prop = schema_of(); + if (!f.description.empty()) { + prop["description"] = std::string(f.description); + } + if (f.enum_count > 0) { + nlohmann::json values = nlohmann::json::array(); + for (std::size_t i = 0; i < f.enum_count; ++i) { + values.push_back(std::string(f.enum_values[i])); } - props[std::string(f.key)] = prop; - if (f.presence == Presence::kRequired) { - required.push_back(std::string(f.key)); + // For optional members schema_of() yields {anyOf:[, {null}]}; + // attach the enum to the non-null branch ([0]) so the nullable claim + // and the enum constraint agree (a top-level enum lacking "null" would + // reject the null that anyOf advertises). Required members get the + // enum at the top level. + if (prop.contains("anyOf") && prop["anyOf"].is_array() && !prop["anyOf"].empty()) { + prop["anyOf"][0]["enum"] = values; + } else { + prop["enum"] = values; } } - }); - nlohmann::json schema = {{"type", "object"}, {"properties", props}}; - if (!required.empty()) { - schema["required"] = required; + props[std::string(f.key)] = prop; + if (f.presence == Presence::kRequired) { + required.push_back(std::string(f.key)); + } } - return schema; + }); + nlohmann::json schema = {{"type", "object"}, {"properties", props}}; + if (!required.empty()) { + schema["required"] = required; + } + return schema; +} + +/// Generates the components/schemas object entry for a DTO type T. +template +struct SchemaWriter { + static nlohmann::json schema() { + return derived_object_schema(); } }; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/scripts.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/scripts.hpp index ac6dc5f2c..4fd4ed4a0 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/scripts.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/scripts.hpp @@ -87,18 +87,10 @@ inline constexpr auto dto_fields = template <> inline constexpr std::string_view dto_name = "ScriptMetadata"; -// ============================================================================= -// Collection - named "ScriptMetadataList". -// -// Wire shape: {"items": [, ...]} -// -// Retained in the registry because the generic Collection visitor stamps it -// onto the OpenAPI schema. The wire-facing list endpoint actually emits the -// `ScriptList` wrapper below so the `_links` envelope is typed instead of -// free-form JSON. -// ============================================================================= -template <> -inline constexpr std::string_view dto_name> = "ScriptMetadataList"; +// `Collection` deliberately has no dto_name and no AllDtos +// entry. It used to publish a `ScriptMetadataList` schema no operation could +// return: the list endpoint emits the `ScriptList` wrapper below, whose +// `_links` envelope is typed rather than free-form. // ============================================================================= // ScriptList - GET /{entity}/scripts response with typed HATEOAS envelope. @@ -213,5 +205,46 @@ inline constexpr auto dto_fields = template <> inline constexpr std::string_view dto_name = "ScriptControlRequest"; +// ============================================================================= +// ScriptExecutionRequest - POST request body for +// /{entity}/scripts/{script_id}/executions. +// +// Wire keys (from ScriptHandlers::start_execution, which parses the body by +// hand through the framework escape hatch so it can keep its own 400 messages): +// execution_type - required, non-empty string +// parameters - optional, forwarded to the provider untouched +// proximity_response - optional string, read only when it is a string +// +// Uses plain field() (NOT field_enum) on execution_type. Nothing here rejects +// anything: this route is the body-less typed `post` and start_execution parses +// by hand, so no JsonReader ever walks this descriptor and the read-time +// enforcement field_enum performs elsewhere cannot fire. The descriptor is +// documentation, and that is exactly the reason - a closed enum would *publish* +// a vocabulary as complete when it is not. The value goes to the ScriptProvider +// verbatim and each backend decides its own; DefaultScriptProvider checks it +// against `ScriptConfig::supported_execution_types` (shipped default: {"now"}) +// and a plugin answers for its own set. Same reasoning as the fault-trigger +// DTOs, whose raw() routes are documentation-only for the same reason. +// ============================================================================= +struct ScriptExecutionRequest { + std::string execution_type; + std::optional parameters; + std::optional proximity_response; +}; + +template <> +inline constexpr auto dto_fields = std::make_tuple( + field("execution_type", &ScriptExecutionRequest::execution_type, + "When to run. The shipped backend accepts only `now`; a ScriptProvider plugin defines its own " + "vocabulary and answers 400 for a value it does not support."), + field("parameters", &ScriptExecutionRequest::parameters, + "Input parameters, forwarded to the provider untouched. The accepted shape is the script's own - " + "read `parameters_schema` on `GET /{entity_type}/{entity_id}/scripts/{script_id}`."), + field("proximity_response", &ScriptExecutionRequest::proximity_response, + "Operator's answer to a proximity prompt, for scripts that require physical presence.")); + +template <> +inline constexpr std::string_view dto_name = "ScriptExecutionRequest"; + } // namespace dto } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/sse_frames.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/sse_frames.hpp new file mode 100644 index 000000000..2d827783d --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/sse_frames.hpp @@ -0,0 +1,155 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include +#include +#include + +#include "ros2_medkit_gateway/dto/contract.hpp" +#include "ros2_medkit_gateway/dto/errors.hpp" + +namespace ros2_medkit_gateway { +namespace dto { + +// ============================================================================= +// The JSON document carried in the `data:` field of an SSE frame. +// +// The eight SSE routes emit three different shapes, which is why they cannot +// share one schema and why the document used to declare none at all. Nothing +// serialises through these descriptors - each stream builds its frame by hand +// at the emission site - so they are the published schema, kept next to a +// comment naming the code that has to agree with them. +// +// A stream also sends non-JSON lines a schema cannot describe and a client +// must tolerate: `:keepalive` comments on an idle trigger stream, and the +// `id:` / `event:` fields the fault stream sets for reconnection. +// ============================================================================= + +// ----------------------------------------------------------------------------- +// SubscriptionEventFrame - cyclic-subscription streams. +// Built by SubscriptionTransportProvider::make_sse_stream +// (http/handlers/sse_transport_provider.cpp). +// +// Exactly one of `payload` / `error` is present per frame: the sampler either +// returned a value or it did not. +// ----------------------------------------------------------------------------- +struct SubscriptionEventFrame { + std::string timestamp; + std::optional payload; + std::optional error; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("timestamp", &SubscriptionEventFrame::timestamp, + "Sample time, ISO 8601 UTC with millisecond precision (e.g. `2026-07-29T10:15:30.123Z`)."), + field("payload", &SubscriptionEventFrame::payload, + "The sampled resource, in the shape that resource's own GET returns. Absent when `error` is " + "present."), + field("error", &SubscriptionEventFrame::error, + "Why this tick produced no sample. Absent when `payload` is present. A sampler that " + "returned a failure sets `error_code`, `vendor_code` and `message`; a sampler that threw " + "sets `error_code` (`internal-error`) and `message` only. `parameters` is never set on " + "either. The stream stays open and the next tick is retried.")); + +template <> +inline constexpr std::string_view dto_name = "SubscriptionEventFrame"; + +// ----------------------------------------------------------------------------- +// TriggerEventFrame - trigger event streams. +// Built by TriggerManager (core/managers/trigger_manager.cpp), drained by +// TriggerHandlers::sse_trigger_events. +// +// No `error` member, unlike the subscription frame: a trigger frame is only +// produced when the condition fired, so there is no failed-sample case to +// report. An idle stream sends `:keepalive` comment lines instead. +// ----------------------------------------------------------------------------- +struct TriggerEventFrame { + std::string timestamp; + nlohmann::json payload; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("timestamp", &TriggerEventFrame::timestamp, "Fire time, ISO 8601 UTC."), + field("payload", &TriggerEventFrame::payload, + "The observed resource's value at the moment the condition fired - the whole value, not the " + "`path` sub-document the condition was evaluated against.")); + +template <> +inline constexpr std::string_view dto_name = "TriggerEventFrame"; + +// ----------------------------------------------------------------------------- +// FaultStreamXMedkit - the `x-medkit` object on a fault-stream frame. +// +// Present only when the fault's reporting source resolves to a known entity. +// It is a hint for addressing the fault's bulk-data, not an ownership claim: +// a debounced fault can have several co-reporters and this names the +// lexicographically first. +// ----------------------------------------------------------------------------- +struct FaultStreamXMedkit { + std::string entity_type; + std::string entity_id; +}; + +template <> +inline constexpr auto dto_fields = std::make_tuple( + field("entity_type", &FaultStreamXMedkit::entity_type, "`areas`, `components`, `apps` or `functions`."), + field("entity_id", &FaultStreamXMedkit::entity_id, + "Entity to address for this fault's rosbag: " + "`GET /{entity_type}/{entity_id}/bulk-data/rosbags/{fault_code}`.")); + +template <> +inline constexpr std::string_view dto_name = "FaultStreamXMedkit"; + +// ----------------------------------------------------------------------------- +// FaultStreamEvent - the global fault stream, GET /faults/stream. +// Built by SSEFaultHandler::format_sse_event +// (http/handlers/sse_fault_handler.cpp). +// +// A different shape from the two above, with no `payload` key at all. Its +// frames additionally carry `id:` and `event:` SSE fields, which is what makes +// the `Last-Event-ID` replay protocol on this route work. +// ----------------------------------------------------------------------------- +struct FaultStreamEvent { + std::string event_type; + nlohmann::json fault; + double timestamp{0.0}; + std::optional x_medkit; // wire key: "x-medkit" +}; + +template <> +inline constexpr auto dto_fields = std::make_tuple( + field("event_type", &FaultStreamEvent::event_type, + "What happened to the fault. Also sent as the frame's SSE `event:` field, so a client can " + "subscribe per type."), + field("fault", &FaultStreamEvent::fault, + "The fault, in the flat `FaultListItem` shape the fault list uses (fault_code, severity, status, " + "reporting_sources, ...)."), + field("timestamp", &FaultStreamEvent::timestamp, + "Event time as seconds since the Unix epoch, with nanosecond precision in the fraction. Note this " + "is a number, where the subscription and trigger frames send an ISO 8601 string."), + field("x-medkit", &FaultStreamEvent::x_medkit, + "Where to fetch this fault's bulk-data. Absent when the reporting source resolves to no known " + "entity.")); + +template <> +inline constexpr std::string_view dto_name = "FaultStreamEvent"; + +} // namespace dto +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/triggers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/triggers.hpp index df2c167e4..ed510cbc1 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/triggers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/triggers.hpp @@ -24,6 +24,7 @@ #include "ros2_medkit_gateway/dto/entities.hpp" #include "ros2_medkit_gateway/dto/enums.hpp" #include "ros2_medkit_gateway/dto/sample.hpp" +#include "ros2_medkit_gateway/dto/schema_writer.hpp" namespace ros2_medkit_gateway { namespace dto { @@ -135,6 +136,62 @@ inline constexpr std::string_view dto_name = "TriggerUpdat template <> inline constexpr std::string_view dto_name> = "TriggerList"; +// ============================================================================= +// trigger_condition: opaque, and saying why. +// +// The member is a bare `nlohmann::json`, so the derived schema publishes `{}` - +// "any JSON at all" - on a *required* field. That is the same content-free +// declaration the fault and data envelopes were cured of, and it earns the same +// treatment: mark it opaque and name who decides the shape. +// +// Opaque rather than typed because the vocabulary is open. `ConditionRegistry` +// takes plugin-registered evaluators through `PluginContext` +// (`get_condition_registry()`), each with its own `condition_type` and its own +// operands, and the handler forwards every key except `condition_type` to the +// evaluator untouched. A closed schema would both misdescribe a plugin +// condition and, if the member were typed, silently drop its operands on read. +// +// Schema-only: `JsonWriter` / `JsonReader` keep walking the plain `field()` +// descriptor, so the reader stays as lenient as it is today and the handler +// keeps issuing its own 400 for a non-object. Typing the member properly is a +// separate, larger change - it needs a descriptor that publishes a $ref while +// leaving the member raw. +// ============================================================================= +namespace detail { +inline nlohmann::json trigger_condition_schema() { + return nlohmann::json{ + {"type", "object"}, + {"additionalProperties", true}, + {"x-medkit-opaque", true}, + {"description", + "The condition that fires the trigger: a flat object of `condition_type` plus that type's own " + "operands. The built-in types are `OnChange` (no operands), `OnChangeTo` (`target_value`), " + "`EnterRange` and `LeaveRange` (`lower_bound`, `upper_bound`). The set is open - a plugin can " + "register further evaluators through `ConditionRegistry`, conventionally under an `x-` prefixed " + "name, with operands of its own - which is why no closed schema is published here."}}; +} +} // namespace detail + +// SchemaWriter specializations: start from the derived schema so every other +// member still comes from `dto_fields`, and replace only `trigger_condition`. +template <> +struct SchemaWriter { + static nlohmann::json schema() { + nlohmann::json schema = derived_object_schema(); + schema["properties"]["trigger_condition"] = detail::trigger_condition_schema(); + return schema; + } +}; + +template <> +struct SchemaWriter { + static nlohmann::json schema() { + nlohmann::json schema = derived_object_schema(); + schema["properties"]["trigger_condition"] = detail::trigger_condition_schema(); + return schema; + } +}; + // ============================================================================= // dto_sample specializations for DTOs with bare nlohmann::json members. // diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/updates.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/updates.hpp index ee5ecc372..f046014cb 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/updates.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/updates.hpp @@ -104,12 +104,53 @@ struct JsonReader { } }; -// SchemaWriter specialization: free-form object schema, matching the -// existing escape-hatch convention (see dto/contract.hpp). +// SchemaWriter specialization. Unlike JsonWriter / JsonReader above, this is +// NOT a pass-through: SOVD (ISO 17978-3 section 7.18) defines the attribute +// table, so publishing `{type: object}` withheld a shape the standard already +// fixes. The keys below are that table; `additionalProperties: true` is what +// keeps the vendor extensions the reader passes through legal, so typing the +// schema costs a plugin nothing. +// +// Deliberately no `required` list. The four mandatory attributes are mandatory +// on the *SOVD server*, and the gateway is a pass-through here - it stores +// whatever `register_update` was given and returns it. Declaring them required +// would make the document promise something the gateway does not enforce. template <> struct SchemaWriter { static nlohmann::json schema() { - return nlohmann::json{{"type", "object"}, {"additionalProperties", true}, {"x-medkit-opaque", true}}; + using nlohmann::json; + const json string_array{{"type", "array"}, {"items", {{"type", "string"}}}}; + return json{ + {"type", "object"}, + {"additionalProperties", true}, + {"description", + "Update package metadata, as stored by the UpdateProvider. The attributes below are SOVD's " + "(ISO 17978-3 section 7.18); a backend may add its own - Uptane TUF metadata, vendor component " + "lists - and the gateway returns those unchanged."}, + {"properties", json{{"id", {{"type", "string"}, {"description", "Update package identifier."}}}, + {"update_name", {{"type", "string"}, {"description", "Display name."}}}, + {"update_translation_id", + {{"type", "string"}, {"description", "Translation identifier for the display name."}}}, + {"automated", + {{"type", "boolean"}, + {"description", + "Whether the update can run unattended. `PUT /updates/{id}/automated` " + "answers 400 when this is false."}}}, + {"origins", + {{"type", "array"}, + {"items", {{"type", "string"}, {"enum", json::array({"remote", "proximity"})}}}, + {"description", "Where the package can be fetched from."}}}, + {"notes", {{"type", "string"}, {"description", "Additional descriptive notes."}}}, + {"user_activity", {{"type", "string"}, {"description", "Actions the user has to take."}}}, + {"preconditions", {{"type", "string"}, {"description", "Preconditions for the update."}}}, + {"execution_conditions", + {{"type", "string"}, {"description", "Conditions that must hold during execution."}}}, + {"duration", {{"type", "integer"}, {"description", "Estimated duration in seconds."}}}, + {"size", {{"type", "integer"}, {"description", "Package size in kilobytes."}}}, + {"added_components", string_array}, + {"removed_components", string_array}, + {"updated_components", string_array}, + {"affected_components", string_array}}}}; } }; @@ -252,10 +293,31 @@ struct JsonReader { } }; +// SchemaWriter specialization. Only `id` is declared, and only `id` is +// required, because that is the entire contract the gateway enforces: +// post_update checks its presence, type and format and forwards every other +// key verbatim to `register_update`. SOVD makes the register *request* body +// manufacturer-specific, so transplanting UpdateDetail's attribute table here +// would document a validation the gateway does not perform and a shape a +// backend need not accept. template <> struct SchemaWriter { static nlohmann::json schema() { - return nlohmann::json{{"type", "object"}, {"additionalProperties", true}, {"x-medkit-opaque", true}}; + return nlohmann::json{ + {"type", "object"}, + {"additionalProperties", true}, + {"required", nlohmann::json::array({"id"})}, + {"description", + "Update package metadata to store. Only `id` is validated by the gateway; every other key is " + "forwarded to the UpdateProvider unchanged, so what else belongs here is the backend's contract. " + "A backend that follows SOVD will want the `UpdateDetail` attributes, since that is what " + "`GET /updates/{update_id}` returns."}, + {"properties", + nlohmann::json{{"id", + {{"type", "string"}, + {"description", + "Update package identifier, also the path segment of the `Location` header returned on " + "201. Must be non-empty and free of characters that would not survive a URI path."}}}}}}; } }; diff --git a/src/ros2_medkit_gateway/scripts/check_error_codes_documented.py b/src/ros2_medkit_gateway/scripts/check_error_codes_documented.py new file mode 100644 index 000000000..226737c0f --- /dev/null +++ b/src/ros2_medkit_gateway/scripts/check_error_codes_documented.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +# Copyright 2026 bburda +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Every error code the gateway can emit must appear in the REST guide. + +Why this exists rather than a review habit: the error-code table is prose, and +prose carries completeness claims ("the full set", "every refusal") that +nothing checks. Four review rounds on this branch produced zero logic defects +and a run of statement defects, every one of them a universal claim written +where no test could reach it. This is the reach. + +The rule is one-directional on purpose. A code with a non-test emitter must be +documented, because a client can receive it. A documented code with no emitter +is not an error here - a plugin backend can raise codes this repository never +mentions, and the guide is allowed to describe them. + +Exclusions are declared in ``UNREACHABLE`` below, each with the reason it +cannot reach a client. Adding a code there is a claim about the code path, so +it needs the same evidence any other claim does. +""" + +import pathlib +import re +import sys + +REPO = pathlib.Path(__file__).resolve().parents[3] +HEADER = ( + REPO / 'src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/error_codes.hpp' +) +GUIDE = REPO / 'docs/api/rest.rst' + +# Heading whose list-table this check reads. Only the table's first column +# counts as "documented": a passing mention of a code elsewhere in the guide +# does not put it in the table, and an earlier version of this script matched +# any inline literal anywhere in the file - which let six emitted codes pass +# while absent from the table, and made the table's completeness sentence true +# by accident rather than by check. +TABLE_HEADING = 'Common Error Codes' + +# Every tree that can put an error code on the wire. Headers included because +# `typed_router.hpp`, `handler_context.hpp` and several dto headers name codes +# directly; in-tree plugins included because they serve our own routes - the +# guide's "a plugin backend may raise codes this list does not mention" caveat +# is about third-party plugins, not ours. +SOURCE_ROOTS = ( + REPO / 'src/ros2_medkit_gateway/src', + REPO / 'src/ros2_medkit_gateway/include', + REPO / 'src/ros2_medkit_plugins', +) + +# Codes with an emitter that a client can nonetheless never receive. Each entry +# states why, and the reason has to be a property of the code path - not +# "nobody got round to documenting it". +# Only ``ERR_*`` values belong here. ``LockManager``'s internal strings +# ("lock-conflict", "lock-disabled", ...) are not declared in the header at +# all - they are mapped to SOVD codes by ``to_sovd_error_code`` before they +# reach a response - so this check never sees them and must not name them. +UNREACHABLE = { + # write_typed_error returns before rendering when it sees this sentinel: + # the peer-forwarding path has already committed the wire response. The + # declaration says "Never appears on the wire" and the framework enforces + # it (route_registry.cpp, the ERR_X_INTERNAL_FORWARDED early return). + 'x-medkit-internal-forwarded', +} + + +def declared_codes(): + """Return {constant name: wire value} for every ERR_* in the header.""" + text = HEADER.read_text(encoding='utf-8') + return dict( + re.findall(r'constexpr\s+const\s+char\s*\*\s*(ERR_\w+)\s*=\s*"([^"]+)"', text) + ) + + +def source_files(): + """Yield every non-test C++ file that could name an error code.""" + for root in SOURCE_ROOTS: + for pattern in ('*.cpp', '*.hpp'): + for path in sorted(root.rglob(pattern)): + if 'test' in path.parts or path.name.startswith('test_'): + continue + if path == HEADER: + continue # the declarations themselves, not an emitter + yield path + + +def emitted_codes(codes): + """Return {wire value: first source file} for codes named outside tests.""" + found = {} + for path in source_files(): + text = path.read_text(encoding='utf-8') + for name, value in codes.items(): + if value in found: + continue + if re.search(r'\b' + re.escape(name) + r'\b', text): + found[value] = path.relative_to(REPO) + return found + + +def documented_codes(): + """Return the first-column values of the Common Error Codes table.""" + lines = GUIDE.read_text(encoding='utf-8').splitlines() + try: + start = next(i for i, ln in enumerate(lines) if ln.strip() == TABLE_HEADING) + except StopIteration: + return set() + + # Scan from the `.. list-table::` that follows the heading, not from the + # heading itself - the line directly under it is the RST underline, which + # is unindented and would end the scan immediately. + try: + table = next( + i for i, ln in enumerate(lines[start:], start) if ln.startswith('.. list-table::') + ) + except StopIteration: + return set() + + # The table ends at the first line that is neither blank nor indented - + # the next top-level block. Only `* - ``code``` lines count: those are + # list-table row keys, i.e. the table's first column. + documented = set() + for line in lines[table + 1:]: + if line and not line.startswith(' '): + break + match = re.match(r'\s*\* - ``([a-z0-9][a-z0-9-]*)``\s*$', line) + if match: + documented.add(match.group(1)) + return documented + + +def main(): + codes = declared_codes() + if not codes: + print('FAIL: parsed no ERR_* constants - has error_codes.hpp moved?', file=sys.stderr) + return 1 + + emitted = emitted_codes(codes) + documented = documented_codes() + + # Positive control. A parser that silently matches nothing reports every + # code as missing, which reads like a real failure and is just as useless + # as one that matches everything. Both of those bugs shipped in earlier + # drafts of this script, so the parse is checked before its result is used. + if len(documented) < 10: + print( + f'FAIL: parsed only {len(documented)} row(s) from the ' + f'"{TABLE_HEADING}" table - the parser, not the table, is broken.', + file=sys.stderr, + ) + return 1 + + stale = sorted(UNREACHABLE - set(emitted)) + missing = sorted( + (value, path) + for value, path in emitted.items() + if value not in documented and value not in UNREACHABLE + ) + + if missing: + print( + f'FAIL: {len(missing)} error code(s) can reach a client but are absent ' + f'from {GUIDE.relative_to(REPO)}:', + file=sys.stderr, + ) + for value, path in missing: + print(f' {value:38s} emitted by {path}', file=sys.stderr) + print( + '\nAdd a row to the "Common Error Codes" table, or - if the code ' + 'genuinely cannot reach a client - add it to UNREACHABLE in this ' + 'script with the reason.', + file=sys.stderr, + ) + return 1 + + if stale: + print( + 'FAIL: UNREACHABLE names code(s) nothing emits any more, so the ' + 'stated reason can no longer be checked: ' + ', '.join(stale), + file=sys.stderr, + ) + return 1 + + checked = len(emitted) - len(UNREACHABLE & set(emitted)) + print( + f'OK: {checked} of {len(codes)} declared error code(s) reach a client ' + f'and are documented ({len(codes) - len(emitted)} have no emitter, ' + f'{len(UNREACHABLE & set(emitted))} excluded as unreachable)' + ) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/src/ros2_medkit_gateway/src/core/openapi/document_checks.cpp b/src/ros2_medkit_gateway/src/core/openapi/document_checks.cpp new file mode 100644 index 000000000..986ddcbbe --- /dev/null +++ b/src/ros2_medkit_gateway/src/core/openapi/document_checks.cpp @@ -0,0 +1,130 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ros2_medkit_gateway/core/openapi/document_checks.hpp" + +#include +#include + +namespace ros2_medkit_gateway { +namespace openapi { + +namespace { + +constexpr const char * kRefPrefix = "#/components/"; + +/// Collect every `#/components/
/` reference found anywhere in a +/// subtree, at any depth and under any key. Walking the parsed tree rather +/// than regex-matching a dump keeps a `$ref`-looking *string value* elsewhere +/// in the document (a description, an example) from counting as a reference. +void collect_refs(const nlohmann::json & node, std::vector & out) { + if (node.is_object()) { + for (const auto & [key, value] : node.items()) { + if (key == "$ref" && value.is_string()) { + const auto & ref = value.get_ref(); + if (ref.rfind(kRefPrefix, 0) == 0) { + out.push_back(ref); + } + continue; + } + collect_refs(value, out); + } + return; + } + if (node.is_array()) { + for (const auto & element : node) { + collect_refs(element, out); + } + } +} + +/// Split `#/components/
/` into its two halves. Returns false for +/// a reference that is not two segments deep (a pointer into a sub-property, +/// which no emitter here produces). +bool split_ref(const std::string & ref, std::string & section, std::string & name) { + const std::string tail = ref.substr(std::string(kRefPrefix).size()); + const auto slash = tail.find('/'); + if (slash == std::string::npos || slash == 0 || slash + 1 >= tail.size()) { + return false; + } + section = tail.substr(0, slash); + name = tail.substr(slash + 1); + return true; +} + +} // namespace + +std::set unreachable_schemas(const nlohmann::json & document) { + if (!document.is_object() || !document.contains("components")) { + return {}; + } + // Bound to const references rather than walked through iterators: GCC's + // -Wnull-dereference fires on nlohmann's inlined `end()` when a json is + // reached via `find()`, and the warning is not worth carrying for a lookup + // `contains()` has already proven safe. + const nlohmann::json & components = document["components"]; + if (!components.is_object() || !components.contains("schemas")) { + return {}; + } + const nlohmann::json & schemas = components["schemas"]; + if (!schemas.is_object()) { + return {}; + } + + // Frontier starts at every reference an operation makes; a schema is reached + // when some chain of $refs from there arrives at it. Components referenced + // only by other unreached components stay unreached, which is the point: + // a client generator walks the same graph. + std::vector frontier; + if (document.contains("paths")) { + collect_refs(document["paths"], frontier); + } + + std::set reached_schemas; + std::set seen; + while (!frontier.empty()) { + const std::string ref = std::move(frontier.back()); + frontier.pop_back(); + if (!seen.insert(ref).second) { + continue; + } + std::string section; + std::string name; + if (!split_ref(ref, section, name)) { + continue; + } + if (section == "schemas") { + reached_schemas.insert(name); + } + if (!components.contains(section)) { + continue; // dangling $ref - a different defect, reported by its own test + } + const nlohmann::json & section_obj = components[section]; + if (!section_obj.is_object() || !section_obj.contains(name)) { + continue; + } + collect_refs(section_obj[name], frontier); + } + + std::set unreachable; + for (const auto & entry : schemas.items()) { + if (reached_schemas.count(entry.key()) == 0) { + unreachable.insert(entry.key()); + } + } + return unreachable; +} + +} // namespace openapi +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp b/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp index 18d4aef8f..aebdcd716 100644 --- a/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp +++ b/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp @@ -144,15 +144,32 @@ RouteEntry & RouteEntry::response(int status_code, const std::string & desc, con return *this; } +namespace { + +/// Whether a JSON Schema can describe a body served under this media type. +bool media_type_carries_a_json_document(const std::string & media_type) { + // An SSE frame's `data:` field is a JSON document, so a schema describes it + // exactly - it just describes the frame rather than the whole response body. + // `application/octet-stream` and `multipart/byteranges` carry bytes, where a + // JSON Schema would describe nothing. + return media_type == "text/event-stream"; +} + +} // namespace + RouteEntry & RouteEntry::response(int status_code, const std::string & desc, const nlohmann::json & schema, const std::vector & content_types) { - if (!schema.empty()) { + const bool schema_is_publishable = + !content_types.empty() && std::all_of(content_types.begin(), content_types.end(), [](const std::string & mt) { + return media_type_carries_a_json_document(mt); + }); + if (!schema.empty() && !schema_is_publishable) { // Reported, not published, and not asserted - this is a Release build. A - // JSON Schema attached to `application/octet-stream` or - // `text/event-stream` would describe a body shape nothing validates. + // JSON Schema attached to `application/octet-stream` would describe a body + // shape nothing validates. schema_on_non_json_statuses_.push_back(status_code); } - responses_[status_code] = {desc, {}, {}, content_types}; + responses_[status_code] = {desc, schema_is_publishable ? schema : nlohmann::json{}, {}, content_types}; return *this; } @@ -162,6 +179,41 @@ RouteEntry & RouteEntry::request_body(const std::string & desc, const nlohmann:: return *this; } +RouteEntry & RouteEntry::multipart_body(const std::string & desc, const std::vector & parts) { + nlohmann::json properties = nlohmann::json::object(); + nlohmann::json required = nlohmann::json::array(); + nlohmann::json encoding = nlohmann::json::object(); + for (const auto & part : parts) { + // A binary part is a schema-less property plus an encoding entry; anything + // else carries the schema the call site gave it. + properties[part.name] = part.schema.empty() ? nlohmann::json::object() : part.schema; + if (!part.description.empty()) { + properties[part.name]["description"] = part.description; + } + if (part.required) { + required.push_back(part.name); + } + if (!part.content_type.empty()) { + encoding[part.name]["contentType"] = part.content_type; + } + } + nlohmann::json schema{{"type", "object"}, {"properties", properties}}; + if (!required.empty()) { + schema["required"] = required; + } + request_body_ = RequestBodyInfo{desc, schema, "multipart/form-data"}; + multipart_encoding_ = std::move(encoding); + return *this; +} + +RouteEntry & RouteEntry::accepts(const std::string & content_type, const nlohmann::json & schema) { + // No description: it is the primary body's, since this is the same payload in + // another encoding. Read from request_body_ at emission time rather than + // restated here, so the two cannot disagree. + extra_bodies_.push_back(RequestBodyInfo{std::string{}, schema, content_type}); + return *this; +} + RouteEntry & RouteEntry::path_param(const std::string & name, const std::string & desc) { nlohmann::json param; param["name"] = name; @@ -237,6 +289,41 @@ RouteEntry & RouteEntry::success_description(const std::string & desc) { return *this; } +RouteEntry & RouteEntry::success_schema(const nlohmann::json & schema) { + ResponseInfo * target = nullptr; + for (auto & [code, info] : responses_) { + if (code >= 200 && code < 300) { + if (target != nullptr) { + // Two success responses and no way to know which one this describes. + // No assert - Release build - so record it and let the startup check + // report it rather than picking one at random. + success_schema_without_single_2xx_ = true; + return *this; + } + target = &info; + } + } + if (target == nullptr) { + success_schema_without_single_2xx_ = true; + return *this; + } + // A schema can only describe a body a JSON Schema can describe. The JSON + // default (no declared media types) always qualifies; a declared set does + // only when every entry carries a JSON document, which is true of an SSE + // frame and false of a binary download. + const bool describable = + target->content_types.empty() || + std::all_of(target->content_types.begin(), target->content_types.end(), [](const std::string & mt) { + return media_type_carries_a_json_document(mt); + }); + if (!describable) { + success_schema_without_single_2xx_ = true; + return *this; + } + target->schema = schema; + return *this; +} + RouteEntry & RouteEntry::response_header(int status_code, ResponseHeader header) { auto it = responses_.find(status_code); if (it == responses_.end()) { @@ -498,9 +585,14 @@ RouteEntry & RouteRegistry::sse(const std::string & openapi_path, entry.gate_ = gate; // Declared with the media type cpp-httplib is handed two lines above, so the // document and the wire come from one fact rather than from a summary string - // containing the word "stream". No frame schema: the three SSE families - // (trigger events, subscription data, fault notifications) put different - // shapes in `data:`, so a single schema here would be wrong for two of them. + // containing the word "stream". + // + // No frame schema here, because the three SSE families (trigger events, + // subscription data, fault notifications) put different shapes in `data:` and + // one schema would be wrong for two of them. Each family attaches its own on + // the returned RouteEntry with `.success_schema()`, which replaces this + // response's schema and leaves the status, description and the two headers + // below untouched; this helper cannot know which family it is registering. entry.response(200, "Server-Sent Events stream", nlohmann::json{}, {"text/event-stream"}); // Declared here, next to the `set_header` calls above, because that is what // stops the two from drifting: the framework owns these headers, so no SSE @@ -884,6 +976,17 @@ nlohmann::json RouteRegistry::to_openapi_paths() const { if (!route.request_body_->schema.empty()) { operation["requestBody"]["content"][ct]["schema"] = route.request_body_->schema; } + if (!route.multipart_encoding_.empty()) { + operation["requestBody"]["content"][ct]["encoding"] = route.multipart_encoding_; + } + // Further encodings of the same payload (the auth endpoints' RFC 6749 + // form encoding). Merged into the same content object, because they are + // alternative representations of one body rather than separate bodies. + for (const auto & extra : route.extra_bodies_) { + if (!extra.schema.empty()) { + operation["requestBody"]["content"][extra.content_type]["schema"] = extra.schema; + } + } operation["requestBody"]["required"] = true; } @@ -900,11 +1003,19 @@ nlohmann::json RouteRegistry::to_openapi_paths() const { operation["responses"][code_str]["content"]["application/json"]["schema"] = info.schema; } } else { - // Non-JSON body: one `content` entry per media type, each an empty - // Media Type Object. The absent schema is the point, not an omission - // - see RouteEntry::response(status, desc, schema, content_types). + // Non-JSON body: one `content` entry per media type. Normally an + // empty Media Type Object - the absent schema is the point, not an + // omission, see RouteEntry::response(status, desc, schema, + // content_types). The exception is a media type whose body IS a JSON + // document: an SSE frame's `data:` field has a shape worth + // publishing, and `response()` only kept a schema here after + // checking that. for (const auto & media_type : info.content_types) { - operation["responses"][code_str]["content"][media_type] = nlohmann::json::object(); + auto & media = operation["responses"][code_str]["content"][media_type]; + media = nlohmann::json::object(); + if (!info.schema.empty()) { + media["schema"] = info.schema; + } } } for (const auto & header : info.headers) { @@ -926,8 +1037,15 @@ nlohmann::json RouteRegistry::to_openapi_paths() const { operation["responses"][code] = {{"$ref", "#/components/responses/" + component}}; } }; - auto add_error_ref = [&add_response_ref](const std::string & code) { - add_response_ref(code, "GenericError"); + // Which error body this route puts on the wire is already a per-route fact: + // `error_renderer_` is what `write_typed_error` dispatches on. Reading it + // here is what stops the document claiming a SOVD GenericError on the three + // routes that answer RFC 6749 instead. Everything the middleware owns + // (401/403/429) keeps its own component below - those never reach a handler, + // so the route's renderer has no say in them. + const bool oauth2 = route.error_renderer_ && *route.error_renderer_ == ErrorRenderer::kOAuth2Error; + auto add_error_ref = [&add_response_ref, oauth2](const std::string & code) { + add_response_ref(code, oauth2 ? "OAuth2Error" : "GenericError"); }; for (int code : route.declared_errors_) { @@ -996,7 +1114,15 @@ nlohmann::json RouteRegistry::to_openapi_paths() const { // unlike the statuses derived from recorded runs, this declaration is a // framework-level constant, pinned by // RouteRegistryTest.EveryDocumentedRouteDeclaresTheFrameworkAnsweredRangeRejection. - add_error_ref("416"); + // + // add_response_ref, not add_error_ref: this body does not come from the + // route's own renderer. cpp-httplib writes 416 with no body, and + // `RESTServer::setup_global_error_handlers` fills every body-less error + // response with a GenericError - including on the `/auth/*` routes, whose + // handler-returned errors are RFC 6749. Routing it through the renderer + // would document the one status those routes answer in the SOVD shape as + // an OAuth2Error. + add_response_ref("416", "GenericError"); // Peer aggregation. When an entity turns out to belong to a peer, the // request is proxied inside `validate_entity_for_route`, and the statuses @@ -1161,6 +1287,16 @@ std::vector RouteRegistry::validate_completeness() const { "; declare the status first (success statuses come from the return type)"}); } + // success_schema() replaces the body shape of the one declared 2xx. With no + // 2xx, or with several, there is nothing unambiguous to replace, so the call + // was dropped and the route still publishes the schema derived from its + // return type - the opposite of what the call site asked for. + if (route.success_schema_without_single_2xx_) { + issues.push_back({ValidationIssue::Severity::kError, route_id, + "success_schema() was dropped: the route declares no single 2xx to attach to, or " + "that 2xx carries a media type no JSON Schema can describe"}); + } + // Check response schemas for non-DELETE methods if (route.method_ != "delete") { bool has_success_response_with_schema = false; diff --git a/src/ros2_medkit_gateway/src/http/handlers/sse_transport_provider.cpp b/src/ros2_medkit_gateway/src/http/handlers/sse_transport_provider.cpp index ceeee89c3..0fbcd94f8 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/sse_transport_provider.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/sse_transport_provider.cpp @@ -159,7 +159,14 @@ tl::expected SseTransportProvider::make_sse_stream(c envelope["payload"] = *sample_result; } else { json error; + // The vendor-error sentinel is only usable with the code it stands in + // for: on its own it tells a client a vendor failure happened and not + // which one. Every other emitter of the sentinel pairs the two + // (primitives.cpp, peer_client.cpp, aggregation_manager.cpp); this one + // did not, and `SubscriptionEventFrame.error` now publishes the pair as + // its declared shape. error["error_code"] = ERR_VENDOR_ERROR; + error["vendor_code"] = ERR_X_MEDKIT_RESOURCE_SAMPLE_FAILED; error["message"] = sample_result.error(); envelope["error"] = error; } diff --git a/src/ros2_medkit_gateway/src/http/rest_server.cpp b/src/ros2_medkit_gateway/src/http/rest_server.cpp index 3db08f5a3..291a3c3a7 100644 --- a/src/ros2_medkit_gateway/src/http/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/http/rest_server.cpp @@ -24,6 +24,8 @@ #include "ros2_medkit_gateway/core/http/http_utils.hpp" #include "ros2_medkit_gateway/core/http/parameter_error_classification.hpp" #include "ros2_medkit_gateway/core/thread_pool_config.hpp" +#include "ros2_medkit_gateway/dto/fault_triggers.hpp" +#include "ros2_medkit_gateway/dto/sse_frames.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" #include "ros2_medkit_gateway/http/detail/status_recorder.hpp" #include "ros2_medkit_gateway/ros2/status/ros2_lifecycle_state_reader.hpp" @@ -401,9 +403,10 @@ void RESTServer::setup_routes() { .path_param("app_id", "App (entity) the rules are scoped to") // 501 when the engine is not running (feature off, or no plugin loaded). .errors({501}) - .response(200, "Rule list", - nlohmann::json{{"type", "object"}, - {"properties", {{"items", {{"type", "array"}, {"items", {{"type", "object"}}}}}}}}); + // `array of object` said nothing about a rule. The schema now comes + // from the same descriptor the engine's rule_to_json() field names are + // documented against. + .response(200, "Rule list"); route_registry_ ->raw("post", "/apps/{app_id}/fault-triggers", @@ -458,11 +461,10 @@ void RESTServer::setup_routes() { "all rules (409 on duplicates).") .operation_id("createFaultTrigger") .path_param("app_id", "App (entity) to scope the rule to") - .request_body("Fault-trigger rule definition", - nlohmann::json{{"type", "object"}, {"additionalProperties", true}}) + .request_body("Fault-trigger rule definition") // 501 when the engine is not running (feature off, or no plugin loaded). .errors({501}) - .response(201, "Created rule", nlohmann::json{{"type", "object"}}) + .response(201, "Created rule") .response_header( 201, openapi::ResponseHeader{"Location", "Absolute path of the created rule, API prefix included (`/api/v1/...`).", @@ -1064,6 +1066,19 @@ void RESTServer::setup_routes() { .tag("Bulk Data") .summary(std::string("Upload bulk-data for ") + et.singular) .description(std::string("Uploads a file to a bulk-data category for this ") + et.singular + ".") + // Part names read off BulkDataHandlers::upload. `file` is the only + // one whose absence is a 400; `description` is stored as-is and + // `metadata` must parse as a JSON object or the request is rejected. + .multipart_body("File to store in the category, with optional description and metadata", + {openapi::MultipartPart{"file", "File content. The part's filename becomes the stored name.", + nlohmann::json{}, true, "application/octet-stream"}, + openapi::MultipartPart{"description", "Free-text description stored alongside the file.", + nlohmann::json{{"type", "string"}}, false, ""}, + openapi::MultipartPart{"metadata", + "JSON object stored alongside the file. Must be an object; anything " + "else is rejected with 400.", + nlohmann::json{{"type", "object"}, {"additionalProperties", true}}, + false, "application/json"}}) .success_description("File uploaded") // BulkDataHandlers::upload -> validate_lock_access("bulk-data"). .lock_guarded() @@ -1148,7 +1163,12 @@ void RESTServer::setup_routes() { }) .tag("Triggers") .summary(std::string("SSE events stream for trigger on ") + et.singular) - .description(std::string("Server-Sent Events stream for trigger notifications on this ") + et.singular + ".") + .description(std::string("Server-Sent Events stream for trigger notifications on this ") + et.singular + + ". Each frame's `data:` field is a TriggerEventFrame. An idle stream sends " + "`:keepalive` comment lines every 15s, which carry no JSON.") + // The frame TriggerManager builds - {timestamp, payload}, no error + // branch, unlike the subscription stream beside it. + .success_schema() .gated_on(triggers_available, triggers_unavailable) // TriggerHandlers::sse_trigger_events answers 503 once the SSE // client limit is reached. The recorder cannot reach it: the fixture @@ -1235,7 +1255,12 @@ void RESTServer::setup_routes() { }) .tag("Subscriptions") .summary(std::string("SSE events stream for cyclic subscription on ") + et.singular) - .description(std::string("Server-Sent Events stream for subscription data on this ") + et.singular + ".") + .description(std::string("Server-Sent Events stream for subscription data on this ") + et.singular + + ". Each frame's `data:` field is a SubscriptionEventFrame carrying either the sample " + "or the reason there was none; a failed sample does not close the stream.") + // SubscriptionTransportProvider::make_sse_stream's envelope - + // {timestamp, payload | error}. + .success_schema() // Non-HTTP transports (MQTT, WebSocket, Zenoh) cannot produce an HTTP // stream: SubscriptionTransportProvider::make_sse_stream answers 501. .errors({501}) @@ -1436,6 +1461,19 @@ void RESTServer::setup_routes() { .tag("Scripts") .summary(std::string("Upload diagnostic script for ") + et.singular) .description(std::string("Uploads a diagnostic script for this ") + et.singular + ".") + // Part names read off ScriptHandlers::upload_script. `file` is the + // only one whose absence is a 400; `metadata` must parse as JSON or + // the request is rejected. + .multipart_body("Script file, with optional metadata", + {openapi::MultipartPart{"file", + "Script content. The part's filename becomes the script name and " + "decides the interpreter.", + nlohmann::json{}, true, "application/octet-stream"}, + openapi::MultipartPart{"metadata", + "JSON document stored with the script. Malformed JSON is rejected " + "with 400.", + nlohmann::json{{"type", "object"}, {"additionalProperties", true}}, + false, "application/json"}}) .success_description("Script uploaded") // DefaultScriptProvider::upload_script -> FileTooLarge .errors({413, 501}) @@ -1483,7 +1521,12 @@ void RESTServer::setup_routes() { .tag("Scripts") .summary(std::string("Start script execution for ") + et.singular) .description(std::string("Starts execution of a diagnostic script on this ") + et.singular + ".") - .request_body("Execution parameters", SB::generic_object_schema()) + // The handler parses this body by hand (framework escape hatch) so it + // can keep its own 400 messages, which is why the schema is declared + // here rather than derived from a TBody template parameter. It still + // comes from a DTO descriptor, so the declaration and the fields the + // handler reads are one edit apart, not two files apart. + .request_body("Execution parameters") .success_description("Execution started") // DefaultScriptProvider::start_execution -> ConcurrencyLimit .errors({429, 501}) @@ -1770,7 +1813,21 @@ void RESTServer::setup_routes() { }) .tag("Faults") .summary("Stream fault events (SSE)") - .description("Server-Sent Events stream for real-time fault notifications.") + .description( + "Server-Sent Events stream for real-time fault notifications. Each frame's `data:` field is a " + "FaultStreamEvent, and the frame also carries `id:` (a monotonic event id) and `event:` (the " + "event type), so a client can resume after a drop rather than restart.") + // SSEFaultHandler::format_sse_event's shape - a third one, with no + // `payload` key at all. + .success_schema() + // The other half of the replay protocol. The handler parses this header + // (sse_fault_handler.cpp) and replays only events newer than the id, so + // without it in the document a client has no way to reconnect without + // gaps or duplicates - it is the only reason the `id:` field is there. + .header_param("Last-Event-ID", + "Id of the last frame the client received. The stream resumes after it, replaying " + "whatever is still buffered. Omit to start from the next new event.", + /*required=*/false) .operation_id("streamFaults"); reg.get("/faults", @@ -1780,6 +1837,15 @@ void RESTServer::setup_routes() { .tag("Faults") .summary("List all faults globally") .description("Retrieve all faults across the system.") + // The handler's return type is the opaque `FaultListResult` so the fault + // store's items and the peers' merged items pass through byte-for-byte + // (`JsonReader` would drop any vendor key a newer peer + // adds). The wire shape here is nonetheless fixed: unlike the per-entity + // list this route has no plugin-delegation branch, so it is always the + // FaultManager's own list plus other gateways' merged items - never a + // plugin's own JSON. Publishing the concrete schema is what lets a + // generated client type this body at all. + .success_schema>() // 503 when the fault store cannot be read - same branch as the // per-entity list, and equally out of the recorder's reach. .errors({503}) @@ -1976,7 +2042,15 @@ void RESTServer::setup_routes() { .summary("Authorize client") .description("Authenticate and obtain authorization tokens.") .request_body("Client credentials", SB::ref("AuthCredentials")) + // AuthorizeRequest::parse_request accepts either encoding of the same + // payload; declaring only JSON hid the one RFC 6749 clients default to. + .accepts("application/x-www-form-urlencoded", SB::ref("AuthCredentials")) .operation_id("authorize") + // Bad client credentials answer 401 independently of `auth.enabled` + // (auth_handlers.cpp: authenticate() failure). The middleware's own 401 is + // gated on the auth flag, so without this the document declares no 401 at + // all on the one route whose whole purpose is to reject bad credentials. + .errors({401}) .error_renderer(openapi::ErrorRenderer::kOAuth2Error); reg.post("/auth/token", @@ -1987,7 +2061,11 @@ void RESTServer::setup_routes() { .summary("Obtain access token") .description("Exchange credentials or refresh token for a JWT access token.") .request_body("Token request credentials", SB::ref("AuthCredentials")) + .accepts("application/x-www-form-urlencoded", SB::ref("AuthCredentials")) .operation_id("getToken") + // A refresh token that is expired, revoked or unknown answers 401, again + // regardless of `auth.enabled`. + .errors({401}) .error_renderer(openapi::ErrorRenderer::kOAuth2Error); reg.post("/auth/revoke", @@ -1997,6 +2075,9 @@ void RESTServer::setup_routes() { .tag("Authentication") .summary("Revoke token") .description("Revoke an access or refresh token.") + // No `.accepts(...)` and no `.errors({401})`, unlike the two above: + // post_revoke parses JSON only, and per RFC 7009 §2.2 it must not reveal + // whether the submitted token was valid, so it answers 200 either way. .request_body("Token to revoke", SB::ref("AuthRevokeRequest")) .operation_id("revokeToken") .error_renderer(openapi::ErrorRenderer::kOAuth2Error); diff --git a/src/ros2_medkit_gateway/src/openapi/capability_generator.cpp b/src/ros2_medkit_gateway/src/openapi/capability_generator.cpp index 8c94a7175..21bccabc7 100644 --- a/src/ros2_medkit_gateway/src/openapi/capability_generator.cpp +++ b/src/ros2_medkit_gateway/src/openapi/capability_generator.cpp @@ -24,6 +24,7 @@ #include "ros2_medkit_gateway/core/http/http_utils.hpp" #include "ros2_medkit_gateway/core/models/entity_capabilities.hpp" #include "ros2_medkit_gateway/core/models/entity_types.hpp" +#include "ros2_medkit_gateway/core/openapi/document_checks.hpp" #include "ros2_medkit_gateway/core/plugins/plugin_manager.hpp" #include "ros2_medkit_gateway/core/version.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" @@ -144,7 +145,31 @@ nlohmann::json CapabilityGenerator::generate_root() const { } builder.add_schemas(named_schemas); - return builder.build(); + auto document = builder.build(); + + // Every named schema has to be reachable from some operation, or a generated + // client ships a type it can never receive. Only the assembled document knows + // both halves of that question, which is why the check runs here and not in + // `RouteRegistry::validate_completeness()`. + // + // Warned, not asserted: this is a Release build, and a document that ships a + // few dead types is still a usable document - refusing to serve it would turn + // a documentation defect into an outage. + // `test_openapi_contract::test_no_unreachable_schemas` is what turns the + // suite red; this line is what a developer sees without running it. + const auto orphans = unreachable_schemas(document); + if (!orphans.empty()) { + std::string names; + for (const auto & name : orphans) { + names += (names.empty() ? "" : ", ") + name; + } + RCLCPP_WARN(handlers::HandlerContext::logger(), + "OpenAPI document ships %zu schema(s) no operation can reach: %s. Bind each to the route " + "that returns it, or drop it from dto::AllDtos.", + orphans.size(), names.c_str()); + } + + return document; } // ----------------------------------------------------------------------------- diff --git a/src/ros2_medkit_gateway/src/openapi/openapi_spec_builder.cpp b/src/ros2_medkit_gateway/src/openapi/openapi_spec_builder.cpp index 67d47c00c..99215694b 100644 --- a/src/ros2_medkit_gateway/src/openapi/openapi_spec_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/openapi_spec_builder.cpp @@ -16,6 +16,9 @@ #include "schema_builder.hpp" +#include "ros2_medkit_gateway/dto/auth.hpp" +#include "ros2_medkit_gateway/dto/schema_writer.hpp" + namespace ros2_medkit_gateway { namespace openapi { @@ -129,6 +132,12 @@ nlohmann::json OpenApiSpecBuilder::build() const { if (!spec["components"].contains("schemas") || !spec["components"]["schemas"].contains("GenericError")) { spec["components"]["schemas"]["GenericError"] = SchemaBuilder::generic_error(); } + // Same for OAuth2Error: the Unauthorized / Forbidden component responses + // below reference it unconditionally, and a sub-page spec that never calls + // add_schemas() would otherwise ship a $ref pointing at nothing. + if (!spec["components"]["schemas"].contains("OAuth2Error")) { + spec["components"]["schemas"]["OAuth2Error"] = dto::SchemaWriter::schema(); + } // Uses $ref to components/schemas/GenericError so the schema is defined once spec["components"]["responses"]["GenericError"]["description"] = "SOVD GenericError response"; spec["components"]["responses"]["GenericError"]["content"]["application/json"]["schema"] = @@ -141,18 +150,24 @@ nlohmann::json OpenApiSpecBuilder::build() const { // guards. auto & responses = spec["components"]["responses"]; - // AuthMiddleware puts the RFC 6749 `{error, error_description}` shape on the - // wire for 401/403, which no component schema describes yet; these two point - // at GenericError until that schema exists, because referencing an undefined - // component would break the document's ref-resolution contract outright. + // The RFC 6749 body the `/auth/*` handlers return. Referenced per-route by + // `add_error_ref` when the route's ErrorRenderer is kOAuth2Error, so the + // three OAuth2 endpoints stop claiming a SOVD GenericError they never emit. + responses["OAuth2Error"]["description"] = "RFC 6749 section 5.2 error response."; + responses["OAuth2Error"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("OAuth2Error"); + + // AuthMiddleware answers 401 and 403 ahead of routing, on every route, and + // both serialise `AuthErrorResponse::to_json()` = {error, error_description}. + // That is the RFC 6749 shape, not the SOVD one - these pointed at + // GenericError only because no OAuth2Error schema existed to point at. responses["Unauthorized"]["description"] = "Authentication is missing or the bearer token is invalid."; - responses["Unauthorized"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("GenericError"); + responses["Unauthorized"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("OAuth2Error"); responses["Unauthorized"]["headers"]["WWW-Authenticate"] = { {"description", "Bearer challenge, e.g. `Bearer realm=\"ros2_medkit_gateway\", error=\"invalid_token\"`."}, {"schema", {{"type", "string"}}}}; responses["Forbidden"]["description"] = "The token is valid but lacks the scope this operation requires."; - responses["Forbidden"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("GenericError"); + responses["Forbidden"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("OAuth2Error"); // The rate limiter emits the SOVD GenericError shape, so unlike the two // above this schema already matches the wire. diff --git a/src/ros2_medkit_gateway/src/openapi/route_registry.hpp b/src/ros2_medkit_gateway/src/openapi/route_registry.hpp index 56c182916..f889b5a55 100644 --- a/src/ros2_medkit_gateway/src/openapi/route_registry.hpp +++ b/src/ros2_medkit_gateway/src/openapi/route_registry.hpp @@ -92,6 +92,26 @@ struct ResponseHeader { nlohmann::json schema{{"type", "string"}}; }; +/// One named part of a `multipart/form-data` request body. +/// +/// A part with an empty `schema` is a binary part: OpenAPI 3.1 describes those +/// by media type in `encoding..contentType`, not by a schema - `format: +/// binary` is an OpenAPI 3.0 idiom that 3.1 dropped along with the rest of the +/// pre-JSON-Schema-2020-12 vocabulary. +struct MultipartPart { + /// Part name exactly as the handler looks it up in `MultipartBody.parts`. + std::string name; + /// Prose a generated client shows for the part. + std::string description; + /// Schema for a textual part. Empty means binary - see above. + nlohmann::json schema{}; + /// Whether the handler rejects the request when the part is absent. + bool required{false}; + /// Media type published in `encoding..contentType`. Empty omits the + /// encoding entry entirely, which is what a plain text part wants. + std::string content_type{}; +}; + /// Fluent builder for a single route entry. class RouteEntry { public: @@ -121,6 +141,29 @@ class RouteEntry { RouteEntry & request_body(const std::string & desc, const nlohmann::json & schema, const std::string & content_type = "application/json"); + /// Declare the parts of this route's `multipart/form-data` body. + /// + /// Replaces the `{"type":"object","additionalProperties":true}` placeholder + /// `multipart_upload` installs, which said only "some form fields" - a + /// generated client could not learn that the part is called `file`, that it + /// is mandatory, or that `metadata` has to be a JSON document. + /// + /// Reads as one call because the three facts are one contract: the property, + /// whether it is required, and the `encoding` entry that says how the part is + /// serialised. Declaring them separately is what lets a body publish two + /// thirds of itself. + RouteEntry & multipart_body(const std::string & desc, const std::vector & parts); + + /// Declare a second media type this route's request body is also accepted in. + /// + /// `/auth/authorize` and `/auth/token` parse both `application/json` and + /// RFC 6749's `application/x-www-form-urlencoded` from one handler + /// (`AuthorizeRequest::parse_request`), so what they take is one payload in + /// two encodings - two `content` entries on one body, not two routes. Without + /// this the document names only JSON, and the form encoding every OAuth2 + /// client library reaches for by default looks unsupported. + RouteEntry & accepts(const std::string & content_type, const nlohmann::json & schema); + /// Typed response: the schema is a $ref to the DTO's component schema. template RouteEntry & response(int status_code, const std::string & desc) { @@ -183,6 +226,54 @@ class RouteEntry { /// one `TResponse` produced. RouteEntry & success_description(const std::string & desc); + /// Publish `schema` as the wire shape of this route's success body, leaving + /// the status derived from the handler's return type. + /// + /// For the handful of routes whose C++ return type is an opaque envelope + /// (`FaultListResult` and friends) purely so the handler can pass a backend's + /// JSON through byte-for-byte, while the *wire* shape on the route is + /// nonetheless known. Publishing `{"type":"object"}` there tells a generated + /// client nothing about a body whose shape is in fact fixed. + /// + /// Only legitimate when the route has no plugin-delegation branch: a route + /// that can hand back a plugin's own JSON has no single wire shape to + /// declare, and naming one would promise fields a plugin never sends. + /// + /// How far that is checked, exactly - and it is narrower than it looks. + /// `test_openapi_response_drift` validates a live body against the declared + /// 200 schema only for a GET that is **not** SSE-classified and that declares + /// `application/json` (it skips `_is_sse_endpoint` operations and reads no + /// other media type). So `GET /faults` is mechanically pinned, and every + /// other use of this call is not: + /// * the eight SSE frame declarations below are GETs serving + /// `text/event-stream`, which drift skips on both counts - that is why + /// the subscription frame's `vendor_code` needed a hand-written gtest; + /// * a POST or PUT is out of drift's scope entirely. + /// Anything but a plain JSON GET therefore needs a wire assertion written + /// alongside it, or the declaration rests on nothing. + /// + /// Also how an `sse()` route names its frame shape. That helper declares the + /// 200 and its `text/event-stream` media type but cannot know which of the + /// three frame families the caller is registering - and it attaches the + /// stream's response headers, which a second `response(200, ...)` would wipe. + /// Attaching a schema to `text/event-stream` is sound because an SSE `data:` + /// field *is* a JSON document; attaching one to a binary download is not, and + /// is refused on that ground. + /// + /// Not a `response(2xx, ...)` in disguise: this never mints a response, and + /// never touches the status, the description, or the declared headers. A call + /// on a route with no declared 2xx - or with more than one, or whose 2xx + /// carries a media type no JSON Schema can describe - is dropped and reported + /// by `validate_completeness()` rather than silently inventing a response. + RouteEntry & success_schema(const nlohmann::json & schema); + + /// Typed form of `success_schema`: the schema is a $ref to the DTO's + /// component schema, so the published shape and the DTO cannot drift. + template + RouteEntry & success_schema() { + return success_schema(nlohmann::json{{"$ref", "#/components/schemas/" + std::string(dto::dto_name)}}); + } + /// Declare a response header this route sets on `status_code`. /// /// The status must already be declared - it comes from the handler's return @@ -339,6 +430,11 @@ class RouteEntry { /// silently losing a header the handler sets. std::vector undeclared_header_statuses_; + /// Set when success_schema() found no single declared 2xx to attach to. Kept + /// so validate_completeness() reports the miscall instead of the route + /// quietly keeping the schema derived from its return type. + bool success_schema_without_single_2xx_{false}; + /// Error statuses declared via errors(), rendered as GenericError $refs /// alongside the blanket 400/404/500 set. std::vector declared_errors_; @@ -354,6 +450,14 @@ class RouteEntry { }; std::optional request_body_; + /// Additional encodings the primary request body is also accepted in, + /// appended by accepts() and merged into the same `content` object. + std::vector extra_bodies_; + + /// `encoding` object for a multipart body: `{: {contentType: ...}}`. + /// Emitted beside the request body's schema. Empty for every other body. + nlohmann::json multipart_encoding_{}; + std::vector parameters_; }; diff --git a/src/ros2_medkit_gateway/test/test_dto_contract.cpp b/src/ros2_medkit_gateway/test/test_dto_contract.cpp index e3a1fe964..1c58a24dd 100644 --- a/src/ros2_medkit_gateway/test/test_dto_contract.cpp +++ b/src/ros2_medkit_gateway/test/test_dto_contract.cpp @@ -17,7 +17,9 @@ #include #include #include +#include +#include "ros2_medkit_gateway/core/http/error_codes.hpp" #include "ros2_medkit_gateway/dto/config.hpp" #include "ros2_medkit_gateway/dto/contract.hpp" #include "ros2_medkit_gateway/dto/data.hpp" @@ -310,6 +312,25 @@ TEST(DtoErrors, GenericErrorRoundTrips) { EXPECT_EQ(j.at("error_code"), "x-medkit-entity-not-found"); EXPECT_EQ(j.at("message"), "Entity not found"); EXPECT_FALSE(j.contains("parameters")); + // Unset vendor_code stays off the wire, so a non-vendor error is unchanged + // by the field's existence. + EXPECT_FALSE(j.contains("vendor_code")); +} + +TEST(DtoErrors, GenericErrorCarriesTheVendorCode) { + // The shape `write_generic_error` puts on the wire for an x-medkit-* code: + // the sentinel in error_code, the real code alongside it. The schema has to + // describe both keys or a client sees every vendor failure as one error. + dto::GenericError e{ros2_medkit_gateway::ERR_VENDOR_ERROR, "Entity not found", std::nullopt}; + e.vendor_code = "x-medkit-entity-not-found"; + const auto j = dto::JsonWriter::write(e); + EXPECT_EQ(j.at("error_code"), "vendor-error"); + EXPECT_EQ(j.at("vendor_code"), "x-medkit-entity-not-found"); + + const auto schema = dto::SchemaWriter::schema(); + EXPECT_TRUE(schema.at("properties").contains("vendor_code")); + const auto & required = schema.at("required"); + EXPECT_EQ(std::find(required.begin(), required.end(), "vendor_code"), required.end()); } TEST(DtoEnums, EntityTypeVocabularyHasFourValues) { @@ -470,12 +491,33 @@ TEST(XMedkitDtos, AllXMedkitSchemasAreObjects) { // All-DTO registry round-trip test // ============================================================================= +/// Every DTO schema describes a JSON object body, one of two ways: a plain +/// `type: object`, or an `anyOf` whose every branch is one. The union form is +/// what the pass-through envelopes publish - a route that answers with an +/// in-tree DTO for a ROS 2-backed entity and with a plugin's own JSON for a +/// plugin-owned one has two shapes, and collapsing them to `{type: object}` +/// (which is what those schemas used to say) told a client nothing about +/// either. The assertion still rejects a scalar or array body. +void expect_object_shaped(const nlohmann::json & schema, std::string_view name) { + if (schema.contains("anyOf")) { + ASSERT_TRUE(schema.at("anyOf").is_array()) << name; + ASSERT_FALSE(schema.at("anyOf").empty()) << name; + for (const auto & branch : schema.at("anyOf")) { + // A $ref branch names a schema this same check covers on its own turn. + const bool object_branch = branch.contains("$ref") || (branch.contains("type") && branch.at("type") == "object"); + EXPECT_TRUE(object_branch) << name << " anyOf branch is not an object: " << branch.dump(); + } + return; + } + EXPECT_EQ(schema.at("type"), "object") << name; +} + template void check_one() { using D = std::tuple_element_t; EXPECT_FALSE(dto::dto_name.empty()) << "DTO at index " << I; const auto schema = dto::SchemaWriter::schema(); - EXPECT_EQ(schema.at("type"), "object") << dto::dto_name; + expect_object_shaped(schema, dto::dto_name); // Value-equality round-trip via double-write compare. // diff --git a/src/ros2_medkit_gateway/test/test_schema_reachability.cpp b/src/ros2_medkit_gateway/test/test_schema_reachability.cpp new file mode 100644 index 000000000..489688b73 --- /dev/null +++ b/src/ros2_medkit_gateway/test/test_schema_reachability.cpp @@ -0,0 +1,164 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Unit tests for `openapi::unreachable_schemas`. +/// +/// The rule it implements is asserted end-to-end against the served document by +/// `test_openapi_contract::test_no_unreachable_schemas`. These tests pin the +/// walk itself on documents small enough to reason about: multi-hop +/// reachability through `components/responses`, a cycle, and the cases where a +/// naive "does the name appear in the JSON text" check gives the wrong answer. + +#include + +#include +#include +#include + +#include "ros2_medkit_gateway/core/openapi/document_checks.hpp" + +using nlohmann::json; +using ros2_medkit_gateway::openapi::unreachable_schemas; + +namespace { + +json ref(const std::string & name) { + return json{{"$ref", "#/components/schemas/" + name}}; +} + +/// A one-operation `paths` object whose 200 response body is `schema`. +json paths_returning(const json & schema) { + json media = json::object(); + media["application/json"]["schema"] = schema; + + json response = json::object(); + response["description"] = "ok"; + response["content"] = media; + + json document = json::object(); + document["/things"]["get"]["responses"]["200"] = response; + return document; +} + +/// An object schema with one property pointing at `target`. +json object_with_property(const std::string & key, const json & target) { + json schema = json::object(); + schema["type"] = "object"; + schema["properties"][key] = target; + return schema; +} + +const json kPlainObject = json{{"type", "object"}}; + +} // namespace + +TEST(SchemaReachability, ReportsASchemaNoOperationCanReach) { + json document = json::object(); + document["paths"] = paths_returning(ref("Thing")); + document["components"]["schemas"]["Thing"] = kPlainObject; + document["components"]["schemas"]["Orphan"] = kPlainObject; + + EXPECT_EQ(unreachable_schemas(document), (std::set{"Orphan"})); +} + +TEST(SchemaReachability, FollowsRefsThroughComponentResponses) { + // The blanket error responses are attached as a response-level $ref, so the + // error body schema is two hops from any operation. A walk that only looked + // at `paths` would call GenericError unreachable on every route in the real + // document. + json document = json::object(); + document["paths"]["/things"]["get"]["responses"]["400"] = json{{"$ref", "#/components/responses/GenericError"}}; + document["components"]["responses"]["GenericError"]["content"]["application/json"]["schema"] = ref("GenericError"); + document["components"]["schemas"]["GenericError"] = kPlainObject; + + EXPECT_TRUE(unreachable_schemas(document).empty()); +} + +TEST(SchemaReachability, FollowsRefsNestedInsideAReachedSchema) { + json document = json::object(); + document["paths"] = paths_returning(ref("Outer")); + document["components"]["schemas"]["Outer"] = object_with_property("inner", ref("Inner")); + document["components"]["schemas"]["Inner"] = object_with_property("leaf", ref("Leaf")); + document["components"]["schemas"]["Leaf"] = kPlainObject; + + EXPECT_TRUE(unreachable_schemas(document).empty()); +} + +TEST(SchemaReachability, ReachesEveryBranchOfAnAnyOf) { + // The opaque envelopes publish `anyOf: [, ]`, + // so a walk that stopped at the first `$ref` under a schema would report the + // in-tree DTOs of every other branch as orphans. + json envelope = json::object(); + envelope["anyOf"] = json::array({ref("First"), ref("Second"), kPlainObject}); + + json document = json::object(); + document["paths"] = paths_returning(ref("Envelope")); + document["components"]["schemas"]["Envelope"] = envelope; + document["components"]["schemas"]["First"] = kPlainObject; + document["components"]["schemas"]["Second"] = kPlainObject; + + EXPECT_TRUE(unreachable_schemas(document).empty()); +} + +TEST(SchemaReachability, TerminatesOnACycle) { + json document = json::object(); + document["paths"] = paths_returning(ref("A")); + document["components"]["schemas"]["A"] = object_with_property("b", ref("B")); + document["components"]["schemas"]["B"] = object_with_property("a", ref("A")); + + EXPECT_TRUE(unreachable_schemas(document).empty()); +} + +TEST(SchemaReachability, DoesNotCountASchemaNamedOnlyInProse) { + // A description that mentions `#/components/schemas/Orphan` is text, not a + // reference. Matching the document's serialised text would wave this + // through; walking the parsed tree for `$ref` keys does not. + json document = json::object(); + document["paths"] = paths_returning(ref("Thing")); + document["paths"]["/things"]["get"]["responses"]["200"]["description"] = "See #/components/schemas/Orphan"; + document["components"]["schemas"]["Thing"] = kPlainObject; + document["components"]["schemas"]["Orphan"] = kPlainObject; + + EXPECT_EQ(unreachable_schemas(document), (std::set{"Orphan"})); +} + +TEST(SchemaReachability, ReportsASchemaReferencedOnlyByAnotherOrphan) { + // Two dead schemas that reference each other are still dead. A "is this name + // referenced anywhere" check would call both reachable. + json document = json::object(); + document["paths"] = paths_returning(ref("Thing")); + document["components"]["schemas"]["Thing"] = kPlainObject; + document["components"]["schemas"]["OrphanA"] = object_with_property("b", ref("OrphanB")); + document["components"]["schemas"]["OrphanB"] = kPlainObject; + + EXPECT_EQ(unreachable_schemas(document), (std::set{"OrphanA", "OrphanB"})); +} + +TEST(SchemaReachability, IsEmptyForADocumentWithoutComponents) { + json document = json::object(); + document["paths"] = json::object(); + + EXPECT_TRUE(unreachable_schemas(document).empty()); +} + +TEST(SchemaReachability, IgnoresADanglingRef) { + // A $ref to a schema the document does not define is a different defect with + // its own test (`test_every_ref_resolves`); this walk must not crash on it, + // and must not let it mask a real orphan. + json document = json::object(); + document["paths"] = paths_returning(ref("Missing")); + document["components"]["schemas"]["Orphan"] = kPlainObject; + + EXPECT_EQ(unreachable_schemas(document), (std::set{"Orphan"})); +} diff --git a/src/ros2_medkit_gateway/test/test_sse_transport_provider.cpp b/src/ros2_medkit_gateway/test/test_sse_transport_provider.cpp index af4b05cb0..ff1899d1e 100644 --- a/src/ros2_medkit_gateway/test/test_sse_transport_provider.cpp +++ b/src/ros2_medkit_gateway/test/test_sse_transport_provider.cpp @@ -14,8 +14,10 @@ #include +#include #include +#include "ros2_medkit_gateway/core/http/error_codes.hpp" #include "ros2_medkit_gateway/core/http/handlers/sse_transport_provider.hpp" #include "ros2_medkit_gateway/core/managers/subscription_manager.hpp" @@ -75,3 +77,83 @@ TEST_F(SseTransportProviderTest, NotifyUpdateIsNoOp) { // Just verify it doesn't crash provider_.notify_update("nonexistent"); } + +namespace { + +/// Drive one `next_event` tick and return the JSON in the frame's `data:` +/// field. Writing `false` back from the sink stops the loop before its +/// interval wait, so a tick costs no wall time. +nlohmann::json first_frame_payload(http::SseStream & stream) { + std::string written; + httplib::DataSink sink; + sink.write = [&written](const char * data, size_t len) { + written.assign(data, len); + return false; // one frame is enough; also skips the interval wait + }; + sink.is_writable = [] { + return true; + }; + sink.done = [] {}; + stream.next_event(sink); + + const auto data_pos = written.find("data: "); + EXPECT_NE(data_pos, std::string::npos) << "frame carried no data: field: " << written; + if (data_pos == std::string::npos) { + return nlohmann::json::object(); + } + return nlohmann::json::parse(written.substr(data_pos + 6)); +} + +} // namespace + +// A sampler failure is reported as a vendor error, and the vendor-error +// sentinel is only usable together with the code it stands in for: on its own +// `"error_code": "vendor-error"` tells a client that something vendor-specific +// went wrong and not what. Every other emitter of the sentinel pairs the two; +// this one shipped the sentinel alone, and `SubscriptionEventFrame.error` +// declares the pair. +TEST_F(SseTransportProviderTest, SamplerFailureFrameNamesTheVendorCode) { + auto created = + mgr_.create("node1", "apps", "/api/v1/apps/node1/data", "data", "/temperature", "sse", CyclicInterval::FAST, 60); + ASSERT_TRUE(created.has_value()) << created.error(); + + ResourceSamplerFn failing = [](const std::string &, + const std::string &) -> tl::expected { + return tl::make_unexpected(std::string("topic has no publisher")); + }; + ASSERT_TRUE(provider_.start(*created, failing, nullptr).has_value()); + + auto stream = provider_.make_sse_stream(created->id); + ASSERT_TRUE(stream.has_value()); + + const auto payload = first_frame_payload(*stream); + ASSERT_TRUE(payload.contains("error")) << payload.dump(); + EXPECT_FALSE(payload.contains("payload")) << "a failed sample must not also claim a value"; + const auto & error = payload.at("error"); + EXPECT_EQ(error.at("error_code"), ERR_VENDOR_ERROR); + EXPECT_EQ(error.at("vendor_code"), ERR_X_MEDKIT_RESOURCE_SAMPLE_FAILED); + EXPECT_EQ(error.at("message"), "topic has no publisher"); + EXPECT_TRUE(payload.contains("timestamp")); +} + +// The success side of the same frame, so the test above cannot pass by the +// stream simply always erroring. +TEST_F(SseTransportProviderTest, SuccessfulSampleFrameCarriesPayloadAndNoError) { + auto created = + mgr_.create("node2", "apps", "/api/v1/apps/node2/data", "data", "/temperature", "sse", CyclicInterval::FAST, 60); + ASSERT_TRUE(created.has_value()) << created.error(); + + ResourceSamplerFn ok = [](const std::string &, const std::string &) { + return nlohmann::json{{"value", 42}}; + }; + ASSERT_TRUE(provider_.start(*created, ok, nullptr).has_value()); + + auto stream = provider_.make_sse_stream(created->id); + ASSERT_TRUE(stream.has_value()); + + const auto payload = first_frame_payload(*stream); + EXPECT_FALSE(payload.contains("error")) << payload.dump(); + ASSERT_TRUE(payload.contains("payload")); + EXPECT_EQ(payload.at("payload").at("value"), 42); + EXPECT_TRUE(payload.contains("timestamp")); +} diff --git a/src/ros2_medkit_gateway/test/test_trigger_manager.cpp b/src/ros2_medkit_gateway/test/test_trigger_manager.cpp index 104bd661a..a8950128b 100644 --- a/src/ros2_medkit_gateway/test/test_trigger_manager.cpp +++ b/src/ros2_medkit_gateway/test/test_trigger_manager.cpp @@ -281,6 +281,35 @@ TEST_F(TriggerManagerTest, SingleShot_RemovedAfterFiring) { EXPECT_TRUE(new_trigger.has_value()) << "Capacity should be freed after one-shot cleanup"; } +// The frame shape `TriggerEventFrame` publishes, pinned at the point that +// builds it. Multishot so the trigger is not raced away by the single-shot +// cleanup path, which is why the neighbouring test can only assert +// conditionally. +// +// `success_schema()` on the four trigger-event routes is not +// reached by `test_openapi_response_drift`: it skips SSE-classified GETs and +// reads only `application/json`. A declaration nothing checks is a declaration +// that drifts, so the check is written here instead. +TEST_F(TriggerManagerTest, EventFrameCarriesTimestampAndPayloadAndNoError) { + auto req = make_request("sensor", "OnChange"); + req.multishot = true; + auto created = manager_->create(req); + ASSERT_TRUE(created.has_value()); + + notifier_.notify("data", "sensor", "/temperature", json(42.0)); + ASSERT_TRUE(manager_->wait_for_event(created->id, std::chrono::milliseconds(2000))); + + auto event = manager_->consume_pending_event(created->id); + ASSERT_TRUE(event.has_value()); + EXPECT_TRUE(event->at("timestamp").is_string()); + EXPECT_EQ(event->at("payload"), json(42.0)); + // No error member: a trigger frame exists only because the condition fired, + // so unlike SubscriptionEventFrame there is no failed-sample case to report. + EXPECT_FALSE(event->contains("error")) << event->dump(); + // And nothing beyond the two declared members. + EXPECT_EQ(event->size(), 2u) << event->dump(); +} + // @verifies REQ_INTEROP_097 TEST_F(TriggerManagerTest, Multishot_NotifyTwice) { auto req = make_request("sensor", "OnChange"); diff --git a/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py b/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py index 613b71de5..db7f435c4 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py @@ -406,29 +406,96 @@ def test_binary_downloads_are_not_declared_as_json(self): self.assertGreater(checked, 0, 'No binary downloads in the document') def test_sse_routes_declare_the_event_stream_media_type(self): - """Every SSE route declares ``text/event-stream`` and no frame schema. + """Every SSE route declares ``text/event-stream`` and its frame shape. The media type is what cpp-httplib is handed at ``set_chunked_content_provider``, so the document and the wire come - from one fact. No schema: the three SSE families put different shapes - in ``data:``, and one schema would be wrong for two of them. + from one fact. + + The frame schema is per-family, not global: the three families put + different shapes in ``data:``, so ``sse()`` declares none and each + registration attaches its own. Declaring a schema against + ``text/event-stream`` is sound because a ``data:`` field is a JSON + document - unlike a binary download, where the media type is the whole + description and ``response()`` still refuses a schema. """ - streams = [] + streams = {} for path, method, op in self.operations(): content = op.get('responses', {}).get('200', {}).get('content', {}) if 'text/event-stream' not in content: continue - streams.append(f'{method.upper()} {path}') + where = f'{method.upper()} {path}' self.assertEqual( list(content), ['text/event-stream'], - f'{method.upper()} {path}: an event stream declares one media type') - self.assertNotIn( - 'schema', content['text/event-stream'], - f'{method.upper()} {path}: SSE frames have no single schema to declare') + f'{where}: an event stream declares one media type') + schema = content['text/event-stream'].get('schema', {}) + self.assertIn( + '$ref', schema, f'{where}: event stream without a frame schema') + streams[where] = schema['$ref'].split('/')[-1] # Four trigger-event streams (one per entity type), three subscription # streams (apps / components / functions) and the global fault stream. self.assertEqual( len(streams), 8, f'expected 8 SSE routes, found {sorted(streams)}') + # Three distinct shapes across the eight routes; a single one would + # mean a family had been given another family's schema. + self.assertEqual( + set(streams.values()), + {'TriggerEventFrame', 'SubscriptionEventFrame', 'FaultStreamEvent'}, + f'frame schemas: {streams}') + self.assertEqual(streams['GET /faults/stream'], 'FaultStreamEvent') + + def test_sse_routes_keep_their_stream_headers(self): + """Naming the frame shape does not cost the stream its headers. + + The frame schema is attached after ``sse()`` has already declared the + 200 and its headers. Attaching it with a second ``response(200, ...)`` + would replace that response object wholesale and silently drop them, + which is exactly the kind of loss a reader would not notice. + """ + checked = 0 + for path, method, op in self.operations(): + ok = op.get('responses', {}).get('200', {}) + if 'text/event-stream' not in ok.get('content', {}): + continue + checked += 1 + headers = ok.get('headers', {}) + self.assertIn('Cache-Control', headers, f'{method.upper()} {path}') + self.assertIn('X-Accel-Buffering', headers, f'{method.upper()} {path}') + self.assertEqual(checked, 8) + + def test_fault_stream_declares_the_replay_header(self): + """``Last-Event-ID`` is declared, because the stream sets ``id:``. + + The fault stream is the only one that numbers its frames, and it parses + ``Last-Event-ID`` to resume after a drop. Undeclared, the ``id:`` field + is a number a client can see and cannot use. + """ + op = self.spec()['paths']['/faults/stream']['get'] + header = self.header_params(op).get('Last-Event-ID') + self.assertIsNotNone(header, 'fault stream without Last-Event-ID') + self.assertFalse( + header.get('required'), + 'a first connection has no last event id') + + def test_binary_downloads_still_refuse_a_frame_schema(self): + """The SSE relaxation did not open the door for binary bodies. + + ``response()`` publishes a schema against a non-JSON media type only + when every declared type carries a JSON document. This pins the other + side of that rule: a rosbag download must stay described by its media + type alone. + """ + checked = 0 + for path, method, op in self.operations(): + if not op.get('x-medkit-partial-content'): + continue + for code in ('200', '206'): + for media_type, media in op['responses'][code]['content'].items(): + checked += 1 + self.assertNotIn( + 'schema', media, + f'{method.upper()} {path}: {code} {media_type} carries a schema') + self.assertGreater(checked, 0, 'No binary downloads in the document') def test_partial_content_routes_declare_the_range_request(self): """The response half of the Range contract has a request half. @@ -694,6 +761,191 @@ def test_every_ref_resolves(self): dangling.append(ref) self.assertEqual(dangling, [], f'dangling refs: {dangling}') + def test_auth_endpoints_declare_the_oauth2_error_shape(self): + """RFC 6749 endpoints must not declare the SOVD error body. + + `/auth/*` routes set `.error_renderer(kOAuth2Error)`, so every error + their handler returns reaches the wire as + ``{error, error_description}``. The document used to say + ``GenericError`` on all of them, which told a generated client to read + ``error_code`` on a body that has no such key. + """ + checked = 0 + for path, method, op in self.operations(): + if not path.startswith('/auth/'): + continue + for code in ('400', '500'): + ref = op['responses'].get(code, {}).get('$ref', '') + checked += 1 + self.assertIn( + 'OAuth2Error', ref, f'{method.upper()} {path} [{code}]: {ref}') + self.assertTrue(checked, 'No /auth/ operations in the document') + + def test_auth_token_routes_declare_the_credential_rejection(self): + """The two credential-checking routes declare their 401. + + Both answer 401 from the handler on bad credentials or a dead refresh + token, independently of ``auth.enabled`` - so unlike the middleware's + 401 it is always reachable. ``/auth/revoke`` is deliberately excluded: + RFC 7009 section 2.2 forbids it from revealing whether the token was + valid, so it answers 200 either way and declaring a 401 there would + publish a status it cannot return. + """ + for path in ('/auth/authorize', '/auth/token'): + op = self.spec()['paths'][path]['post'] + self.assertIn('401', op['responses'], f'{path}: no 401 declared') + self.assertIn('OAuth2Error', op['responses']['401'].get('$ref', '')) + revoke = self.spec()['paths']['/auth/revoke']['post'] + self.assertNotIn( + '401', revoke['responses'], + '/auth/revoke answers 200 for an invalid token (RFC 7009 2.2)') + + def test_auth_endpoints_accept_form_encoding(self): + """RFC 6749 clients default to form encoding; the spec must allow it. + + ``AuthorizeRequest::parse_request`` takes either encoding of the same + payload. ``/auth/revoke`` parses JSON only and is excluded on purpose. + """ + for path in ('/auth/authorize', '/auth/token'): + media = set( + (self.spec()['paths'][path]['post'].get('requestBody') or {}) + .get('content', {})) + self.assertEqual( + media, {'application/json', 'application/x-www-form-urlencoded'}, path) + revoke_media = set( + (self.spec()['paths']['/auth/revoke']['post'].get('requestBody') or {}) + .get('content', {})) + self.assertEqual(revoke_media, {'application/json'}, '/auth/revoke is JSON-only') + + def test_generic_error_declares_the_vendor_code(self): + """Vendor errors carry a 4th key the schema must declare. + + ``write_generic_error`` rewrites ``error_code`` to the ``vendor-error`` + sentinel and moves the real ``x-medkit-*`` code into ``vendor_code``. + A schema without that key tells a client every vendor failure is the + same error. + """ + props = self.spec()['components']['schemas']['GenericError']['properties'] + self.assertIn('vendor_code', props) + self.assertTrue(props['vendor_code'].get('description')) + + def test_generic_error_declares_the_recoverable_parameters(self): + """The `parameters` keys a client branches on are schema, not prose. + + `parameters` is open-ended, so its C++ type publishes + ``anyOf: [{}, {"type": "null"}]`` - which does not even say "object". + The two keys that carry a recovery path are declared as real + properties: ``existing_lock_id`` (the lock to break after a 409 from + the acquire) and ``lock_id`` (the lock that blocked a guarded write). + ``additionalProperties`` stays open, because the diagnostic keys are an + open set and a plugin can add its own. + """ + params = self.spec()['components']['schemas']['GenericError']['properties']['parameters'] + self.assertEqual(params.get('type'), 'object') + self.assertTrue(params.get('additionalProperties')) + declared = params.get('properties', {}) + for key in ('existing_lock_id', 'lock_id'): + self.assertIn(key, declared, f'{key} is a recovery key, not diagnostic prose') + self.assertEqual(declared[key].get('type'), 'string') + self.assertTrue(declared[key].get('description')) + + def test_no_request_body_is_an_untyped_bag(self): + """A request body is typed, or explicitly marked opaque. + + Two shapes both mean "some JSON, good luck": the + ``{"type": "object", "additionalProperties": true}`` placeholder the + multipart helper used to install, and the bare ``{"type": "object"}`` + a hand-written ``request_body`` produced. Neither tells a generated + client a single field name, so neither can be used to build a request. + + ``x-medkit-opaque`` is the escape hatch, and it is deliberately a + marker a call site has to write: a body whose shape a plugin decides + says so, rather than being indistinguishable from one nobody typed. + """ + offenders = [] + for path, method, op in self.operations(): + for media, body in (op.get('requestBody') or {}).get('content', {}).items(): + schema = body.get('schema', {}) + if '$ref' in schema or schema.get('x-medkit-opaque'): + continue + if schema.get('type') == 'object' and 'properties' not in schema: + offenders.append(f'{method.upper()} {path} [{media}]') + self.assertEqual(offenders, [], f'untyped request bodies: {offenders}') + + def test_multipart_bodies_name_their_parts(self): + """Every multipart body declares its parts, and binary parts an encoding. + + The rule above only proves a multipart body has *some* properties. This + one proves the declaration is usable: a part a client must send is in + ``required``, and a part carrying bytes says so through + ``encoding..contentType`` - which in OpenAPI 3.1 is the only way + to say it, ``format: binary`` having been dropped with the rest of the + pre-2020-12 vocabulary. + """ + checked = 0 + for path, method, op in self.operations(): + body = (op.get('requestBody') or {}).get( + 'content', {}).get('multipart/form-data') + if body is None: + continue + checked += 1 + where = f'{method.upper()} {path}' + schema = body.get('schema', {}) + self.assertTrue(schema.get('properties'), f'{where}: no parts declared') + self.assertIn('file', schema['properties'], f'{where}: no file part') + self.assertIn( + 'file', schema.get('required', []), + f'{where}: the file part is rejected when absent, so it is required') + self.assertEqual( + body.get('encoding', {}).get('file', {}).get('contentType'), + 'application/octet-stream', + f'{where}: binary part without an encoding contentType') + # Two script uploads (apps / components) and two bulk-data uploads. + self.assertEqual(checked, 4, f'expected 4 multipart bodies, found {checked}') + + def test_no_operation_has_a_content_free_body(self): + """A schema is typed, or explicitly opaque with a reason. + + ``{"type": "object"}`` and nothing else is not documentation - it is + the absence of it, and a generated client turns it into an untyped + map. Some bodies genuinely have no fixed shape because a plugin owns + it; those stay opaque and say so, naming who decides the shape and + where a client discovers it. What this rule forbids is the silent + version. + """ + schemas = self.spec()['components']['schemas'] + opaque = {n for n, s in schemas.items() + if s.get('type') == 'object' and 'properties' not in s} + undocumented = sorted(n for n in opaque if not schemas[n].get('description')) + self.assertEqual(undocumented, [], f'opaque without a reason: {undocumented}') + + def test_no_unreachable_schemas(self): + """Every named schema is reachable from some operation. + + `components/schemas` is unconditionally all of `dto::AllDtos`, so a DTO + that stops being a route's response type - or was never one - keeps + shipping a named type every generated client materialises and none can + ever receive. The walk mirrors `openapi::unreachable_schemas()`, which + the gateway runs over the same document and warns about at request time. + + This fixture launches every optional feature gate on purpose: the + script and update routes are conditional, so with them off their + schemas would show up here as false orphans. + """ + spec = self.spec() + schemas = spec['components']['schemas'] + seen, frontier = set(), refs_in(spec['paths']) + while frontier: + ref = frontier.pop() + if ref in seen: + continue + seen.add(ref) + section, name = ref[len('#/components/'):].split('/', 1) + body = spec['components'].get(section, {}).get(name, {}) + frontier |= refs_in(body) - seen + used = {r.split('/')[-1] for r in seen if '/schemas/' in r} + self.assertEqual(sorted(set(schemas) - used), [], 'unreachable schemas') + @launch_testing.post_shutdown_test() class TestShutdown(unittest.TestCase): From f3ba77a8d8100489da54012985310caec3b21dda Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Wed, 5 Aug 2026 08:43:38 +0200 Subject: [PATCH 10/17] feat(gateway): advertise the collections we serve, and describe what a client must send The document advertised entity resource collections the gateway does not serve and omitted several it does; the correspondence is now exact in both directions for all five types. FieldConstraints publishes the bounds the handlers enforce, every operation carries a description, and non-trivial request bodies carry an example. A published bound is only added where every route carrying it rejects an over-long value unconditionally - DELETE on a configuration did not, and now does. --- docs/api/rest.rst | 77 +++++++++- .../design/aggregation.rst | 9 +- .../design/dto_contract.rst | 134 ++++++++++++++++- .../core/discovery/models/area.hpp | 6 +- .../core/discovery/models/component.hpp | 4 +- .../core/http/handlers/capability_builder.hpp | 44 +++++- .../core/models/entity_capabilities.hpp | 38 +++-- .../core/models/entity_types.hpp | 26 +++- .../ros2_medkit_gateway/dto/contract.hpp | 39 ++++- .../dto/cyclic_subscriptions.hpp | 21 ++- .../ros2_medkit_gateway/dto/entities.hpp | 141 ++++++++++++------ .../dto/entity_capability.hpp | 54 +++++++ .../ros2_medkit_gateway/dto/health.hpp | 8 +- .../include/ros2_medkit_gateway/dto/locks.hpp | 22 ++- .../include/ros2_medkit_gateway/dto/logs.hpp | 27 +++- .../ros2_medkit_gateway/dto/operations.hpp | 10 +- .../ros2_medkit_gateway/dto/registry.hpp | 7 +- .../ros2_medkit_gateway/dto/schema_writer.hpp | 54 +++++-- .../ros2_medkit_gateway/dto/triggers.hpp | 28 +++- .../ros2_medkit_gateway/dto/x_medkit.hpp | 5 +- .../core/http/handlers/capability_builder.cpp | 38 +++-- .../http/parameter_error_classification.cpp | 1 + .../src/core/models/entity_capabilities.cpp | 85 +++++++---- .../src/core/models/entity_types.cpp | 9 ++ .../src/core/openapi/route_registry.cpp | 110 +++++++++++--- .../src/entity_freeze_frame_capture.cpp | 2 +- .../src/http/handlers/config_handlers.cpp | 12 ++ .../src/http/handlers/discovery_handlers.cpp | 101 +++++++++---- .../src/http/rest_server.cpp | 89 ++++++++++- .../src/openapi/capability_generator.cpp | 52 +++++-- .../src/openapi/route_registry.hpp | 35 +++++ .../test/test_capability_builder.cpp | 62 +++++--- .../test/test_capability_generator.cpp | 51 +++++++ .../test/test_discovery_models.cpp | 10 +- .../test/test_entity_resource_model.cpp | 100 ++++++++++++- .../test/test_route_registry.cpp | 76 ++++++++++ .../test/test_schema_builder.cpp | 15 +- .../features/test_configuration_api.test.py | 33 ++++ .../test/features/test_faults_api.test.py | 45 ++++++ .../features/test_openapi_contract.test.py | 138 +++++++++++++++++ .../test_scenario_discovery_manifest.test.py | 11 ++ 41 files changed, 1560 insertions(+), 269 deletions(-) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/entity_capability.hpp diff --git a/docs/api/rest.rst b/docs/api/rest.rst index dce466574..14665c9d0 100644 --- a/docs/api/rest.rst +++ b/docs/api/rest.rst @@ -240,9 +240,10 @@ Areas .. 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), read-only - ``/bulk-data``, and ``/triggers``. See :ref:`sovd-compliance` for details. + which only defines them for apps and components. Areas provide ``/data``, + ``/data-categories``, ``/data-groups``, ``/operations``, ``/configurations``, + ``/faults``, ``/logs`` (namespace prefix aggregation), read-only ``/bulk-data``, + and ``/triggers``. See :ref:`sovd-compliance` for details. Components ~~~~~~~~~~ @@ -3075,7 +3076,34 @@ use cases benefit. **Pragmatic 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: +extends this to areas and functions where aggregation makes practical sense. + +The matrix below transcribes ``EntityCapabilities::for_type``. That drives the +paths in an entity's ``/docs`` sub-document, and the collection check in +``validate_collection_access_typed``. It is **not** where the ``capabilities`` +array of ``GET /{entity-type}/{id}`` comes from: that array is built from a +second, independent list, the ``CapabilityBuilder::Capability`` vector each +handler in ``discovery_handlers.cpp`` assembles. The two surfaces overlap but +are not the same set - the component array also carries ``status``, +``subcomponents``, ``hosts`` and ``depends-on``, and the area array +``subareas``, ``contains`` and ``components``, none of which are resource +collections and so none of which appear in the table. + +The transcription is by hand. What is checked mechanically is the property the +table exists to describe: +``test_openapi_contract::test_every_advertised_collection_is_served`` takes the +first discovered entity of each type, follows every non-templated ``href`` in +its ``capabilities`` array **and** every path in its ``/docs`` sub-document +against a live gateway, and fails on a 404 - so it covers both surfaces, +including where they disagree. Its fixture discovers no areas, so the Areas +column below is covered by the ``EntityCapabilities`` unit tests instead, which +assert the per-type lists directly. ``501`` is a served answer, not a missing +one: see ``data-categories`` and ``data-groups`` below. + +Collections named by the SOVD standard that the gateway does **not** serve +per entity - ``data-lists``, ``modes`` and ``communication-logs`` - are absent +from the table and from every capability list. ``updates`` is server-scoped +only (``/api/v1/updates``), never mounted under an entity. .. list-table:: Resource Collection Support Matrix :header-rows: 1 @@ -3093,6 +3121,18 @@ extends this to areas and functions where aggregation makes practical sense: - yes - aggregated - apps, components + * - data-categories + - 501 + - 501 + - 501 + - 501 + - apps, components + * - data-groups + - 501 + - 501 + - 501 + - 501 + - apps, components * - operations - aggregated - yes @@ -3135,12 +3175,41 @@ extends this to areas and functions where aggregation makes practical sense: - yes - \- - apps, components + * - locks + - \- + - yes + - yes + - \- + - apps, components * - triggers - yes (x-medkit) - yes - yes - yes (x-medkit) - apps, components + * - fault-triggers + - \- + - \- + - yes (x-medkit) + - \- + - not in SOVD + +Three rows depend on configuration, and the two advertising surfaces answer +differently, which is worth stating rather than leaving to be discovered: + +- ``locks``: the routes are always registered for components and apps and answer + ``501`` when there is no lock manager (``locking.enabled`` off). The + ``capabilities`` entry and the ``locks`` URI field follow the lock manager; the + ``/docs`` sub-document lists ``/locks`` unconditionally, because + ``for_type(COMPONENT)`` does. +- ``scripts``: the same shape. ``ScriptManager`` is constructed unconditionally, + so all eight script routes are always registered for components and apps, and + they answer ``501`` until a backend exists - either a plugin + ``ScriptProvider`` or a non-empty ``scripts.scripts_dir``. The + ``capabilities`` entry follows the backend; the sub-document lists + ``/scripts`` unconditionally. +- ``fault-triggers``: always registered and always advertised, for apps only; + with no fault-trigger engine running the routes answer ``501``. Other extensions beyond SOVD: diff --git a/src/ros2_medkit_gateway/design/aggregation.rst b/src/ros2_medkit_gateway/design/aggregation.rst index e5e5e1c44..79611f3bc 100644 --- a/src/ros2_medkit_gateway/design/aggregation.rst +++ b/src/ros2_medkit_gateway/design/aggregation.rst @@ -163,7 +163,8 @@ entities instead. Resource Collections on Functions and Areas ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Functions and Areas support the same resource collections as Components and Apps: +Functions and Areas expose the aggregating subset of the collections Components +and Apps expose: - **data** - Aggregated topic data from all hosted entities - **operations** - Aggregated services and actions from hosted entities @@ -171,6 +172,12 @@ Functions and Areas support the same resource collections as Components and Apps - **faults** - Aggregated faults from hosted entities - **logs** - Aggregated log entries from hosted entities +They do not expose ``locks`` or ``scripts`` (components and apps only), nor +``fault-triggers`` (apps only); Areas additionally have no +``cyclic-subscriptions``. What each entity type serves - and therefore what it +advertises in its ``capabilities`` array - is the matrix in +:ref:`sovd-compliance`. + Requests to ``/functions/{id}/data`` are fan-out queries that collect data from all entities listed in the Function's ``hosts`` field. Similarly, Area resource collection requests aggregate from all Components contained in that Area. diff --git a/src/ros2_medkit_gateway/design/dto_contract.rst b/src/ros2_medkit_gateway/design/dto_contract.rst index aa63fa80b..bbcd40aa4 100644 --- a/src/ros2_medkit_gateway/design/dto_contract.rst +++ b/src/ros2_medkit_gateway/design/dto_contract.rst @@ -127,6 +127,7 @@ defined in ``contract.hpp``: std::string_view description; // OpenAPI property description const std::string_view * enum_values; // allowed string values (or nullptr) std::size_t enum_count; + FieldConstraints constraints; // minimum / maximum / maxLength / pattern / format }; Fields are never constructed directly. The ``field()`` and ``field_enum()`` @@ -144,6 +145,54 @@ argument: // Enum-constrained field with inline constexpr string_view array field_enum("status", &FaultStatus::aggregated_status, kFaultAggregatedStatusValues) +Schema Constraints (``FieldConstraints``) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A field can publish JSON Schema keywords its C++ type does not imply. Every +member of ``FieldConstraints`` is unset by default, so a ``field()`` call that +names none emits what it always did. C++17 has no designated initialisers, so +a call site spells the unset members out: + +.. code-block:: cpp + + field("max_entries", &LogConfiguration::max_entries, + "Ceiling on how many entries one GET answers with...", + FieldConstraints{/*minimum=*/1.0, /*maximum=*/10000.0, {}, {}, {}}) + +**Only declare a bound the handler enforces unconditionally.** A ceiling the +gateway reads from a ROS parameter is deployment configuration, and publishing +it as ``maximum`` makes the document wrong on any deployment that changed it - +those belong in the ``description``. ``AcquireLockRequest.lock_expiration`` is +the worked example: ``minimum: 1`` is a constraint because ``LockManager`` +always rejects a non-positive expiration, while the 3600 s ceiling is +``locking.default_max_expiration`` and is described rather than declared. + +Numeric width is derived, not declared: ``schema_of`` emits ``format: int64`` +for a **signed** 64-bit integral. Signedness is part of the test because +``sizeof(U) == 8`` alone also matches ``uint64_t`` and ``std::size_t``, whose +upper half the format excludes. Set ``FieldConstraints::format`` only for a +string format the type cannot imply, such as ``date-time``. + +Constraints and ``enum`` are attached through one lambda in +``derived_object_schema``, so their placement cannot drift: an optional member +renders as ``{anyOf: [, {type: null}]}`` and every validation keyword +goes on the non-null branch ``anyOf[0]``, because one written at the property +level would apply to the null branch too and reject the ``null`` that +``anyOf`` advertises. Required members have no ``anyOf`` and take the keyword +at the top level. The ``description`` is the exception and stays at the +property level either way: it describes the field, not one branch of its +schema. + +**An enum is not always the right way to publish a vocabulary.** Where the +handler answers an unrecognised value with an error richer than "value not in +allowed set" - ``ExecutionUpdateRequest.capability`` names +``supported_capabilities`` for the backend in front of the caller, and +``LogConfiguration.severity_filter``'s 400 lists the accepted severities - a +schema-level ``enum`` makes ``JsonReader`` reject the request first and +replaces that error with a generic body-validation failure. Both fields +therefore publish their vocabulary as prose. Use ``field_enum`` where the +parser rejecting the value is the whole of the validation. + ``dto_fields`` - the Descriptor Tuple ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -572,6 +621,28 @@ Further ``RouteEntry`` knobs shape the published operation: them. Declared headers carry no ``required`` flag - OpenAPI response headers are optional by definition, which matches headers the gateway sets conditionally. +- ``description(text)`` - the behaviour a caller has to know that the field + names and the summary do not carry: what an omitted filter defaults to, what + a 202 does and does not promise, what the answer is silently truncated to. + Required on every operation, and gated: + ``test_openapi_contract.test.py::test_every_operation_has_a_description`` + fails on any operation that ships with only a ``summary``. +- ``body_example(json)`` - publish a working request body a caller can copy out + of the document, emitted as + ``requestBody.content[].examples.default.value``. A + ``$ref`` names the fields and their types; it does not say that + ``trigger_condition`` needs a ``condition_type`` key, or that ``interval`` is + a word rather than a number. Examples live here and nowhere else: + ``dto_fields`` is ``inline constexpr`` and could hold only a string + literal per property, and putting one on the schema as well would be a second + source for one concept. Attaches to the primary body only - the extra + encodings ``accepts()`` adds are the same payload in another wire format, + where a JSON example would not parse. Whether the route has a body is read at + emission, not at the call, so the fluent chain can order the two either way; + a route with no body at all drops the example and ``validate_completeness()`` + reports it rather than minting a body the route does not take. + ``test_openapi_contract.test.py::test_non_trivial_request_bodies_carry_an_example`` + pins the App-entity variant of each route family that carries one. - ``gated_on(available, unavailable)`` - the route's backing feature can be absent. ``available`` is re-evaluated per request (a manager can appear after registration), and when it is false the framework answers with @@ -749,6 +820,59 @@ copied through verbatim. No finite ``errors({...})`` describes "whatever the peer said", and choosing what the document should promise is an aggregation-contract question rather than a documentation one. +Path parameters are synthesised, not declared +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``to_openapi_paths()`` walks each route's ``{param}`` templates and emits a +parameter object for every one the registration did not declare by hand, taking +its prose - and, where there is one, its length constraint - from a table in +that function. Almost every route relies on this: the entity-scoped routes are +registered from loops over the four entity types, and only the fault-trigger +routes and one ``app_id`` call ``path_param()`` explicitly. + +That is why the constraint lives in the table rather than at the call sites. +``{fault_code}`` and ``{config_id}`` appear on several registrations each, none +of which describes them today; a per-registration ``path_param(name, desc, +schema)`` would have to be repeated on each and could be omitted on the next +route added, whereas the table applies to every route carrying the template and +there is nothing to forget. + +That convenience carries a precondition, and it is the table's one weakness: +being keyed by parameter *name*, it cannot distinguish a route whose handler +checks from one whose handler does not. A ``maxLength`` therefore goes in only +where **every** handler behind the template rejects an over-long value +unconditionally - 256 for ``fault_code``, 512 for ``config_id`` (256-character +entity id, ``:``, 256-character parameter name). + +The first version of this table did not hold that precondition: +``ConfigHandlers::delete_configuration`` was the one verb of the three that +read ``config_id`` without measuring it, while GET and PUT both rejected, so +four routes published a bound nothing enforced. The check was added rather than +the ``maxLength`` removed - GET and PUT checking and DELETE not was a real +inconsistency in the handler family. + +Both rows are now driven on every verb they publish to, so the precondition is +tested rather than assumed: +``test_configuration_api.test.py::test_06b_every_verb_rejects_an_oversized_config_id`` +covers ``config_id`` on GET, PUT and DELETE, and +``test_faults_api.test.py::test_both_verbs_reject_an_oversized_fault_code`` +covers ``fault_code`` on GET and DELETE. A new row needs its own, or the bound +it publishes rests on a reading of the handlers rather than on a run. + +One ordering difference the tests deliberately avoid depending on: +``FaultHandlers::clear_fault`` measures the code *after* +``validate_lock_access``, where the configuration handlers measure before it. +The published bound still holds - an over-long value is always rejected - but +under a competing lock the fault route answers 409 rather than 400. + +``config_id`` is the one whose prose carries a contract rather than a +restatement of its name. On an entity that aggregates several ROS 2 nodes the +identifier is the ``app_id:param_name`` form the configurations list returns as +each item's ``id``, and a write of a bare parameter name is refused as +ambiguous; on a single-node entity it is the bare name and a colon is part of +it. The earlier description - "the ROS 2 parameter name" - named exactly the +form the write path rejects. + .. _emitted-status-recorder: Emitted-status recorder @@ -1006,8 +1130,14 @@ the codebase: Fields backed by ``std::optional`` rather than ``opaque_object`` (notably ``extended_data_records`` / ``snapshots`` on -``FaultEnvironmentData``) follow the same rule: pass the JSON through -verbatim because the fault reporter plugin owns the shape. The opaque DTO +``FaultEnvironmentData``, and ``_links`` on the four ``*Detail`` DTOs) follow +the same rule: pass the JSON through verbatim because something other than the +DTO layer owns the shape. ``_links`` is the case where the reason is a type +rather than a plugin: its values are a union - a path string for every relation +except ``depends-on``, which is an array of paths - and ``SchemaWriter`` walks +``dto_fields``, so it has no descriptor for a map whose values differ in type. +The field carries a description naming each relation and its value shape, so a +generated client reads prose instead of an unexplained ``{}``. The opaque DTO marker (``is_opaque_dto_v = true``) plays the analogous role at the envelope level: it tells the framework "this whole DTO has a hand-written JsonWriter / JsonReader / SchemaWriter trio because its shape is opaque", diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/discovery/models/area.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/discovery/models/area.hpp index bd6b39a3b..995973320 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/discovery/models/area.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/discovery/models/area.hpp @@ -118,9 +118,11 @@ struct Area { j["translationId"] = translation_id; } - // Capabilities as URI references (SOVD compliant) + // Capabilities as URI references (SOVD compliant). Both name a route + // `rest_server.cpp` registers for areas - `/related-components` did not, + // and was the last spelling of a segment no entity type serves. j["subareas"] = area_url + "/subareas"; - j["related-components"] = area_url + "/related-components"; + j["components"] = area_url + "/components"; // x-medkit extension for ROS 2 specific info j["x-medkit"] = {{"entityType", type}, {"namespace", namespace_path}}; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/discovery/models/component.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/discovery/models/component.hpp index 276e2a80e..b11baebe9 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/discovery/models/component.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/discovery/models/component.hpp @@ -176,7 +176,9 @@ struct Component { } j["faults"] = component_url + "/faults"; j["subcomponents"] = component_url + "/subcomponents"; - j["related-apps"] = component_url + "/related-apps"; + // `/hosts` is the registered route that lists a component's apps. + // `/related-apps` named no route for any entity type. + j["hosts"] = component_url + "/hosts"; if (!depends_on.empty()) { j["depends-on"] = component_url + "/depends-on"; } diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/capability_builder.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/capability_builder.hpp index 5182e13e1..475658fd7 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/capability_builder.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/capability_builder.hpp @@ -19,6 +19,8 @@ #include +#include "ros2_medkit_gateway/dto/entity_capability.hpp" + namespace ros2_medkit_gateway { namespace handlers { @@ -31,7 +33,7 @@ namespace handlers { * @example * using Cap = CapabilityBuilder::Capability; * std::vector caps = {Cap::DATA, Cap::OPERATIONS, Cap::CONFIGURATIONS}; - * auto json = CapabilityBuilder::build_capabilities("components", "my-comp", caps); + * auto items = CapabilityBuilder::build_capabilities("components", "my-comp", caps); * * @verifies REQ_DISCOVERY_003 Entity capabilities */ @@ -39,17 +41,44 @@ class CapabilityBuilder { public: /** * @brief Available capability types for SOVD entities. + * + * Each enumerator names a route registered for the entity types that list it + * in `discovery_handlers.cpp`. The name doubles as the path segment, so an + * enumerator with no route produces an `href` that 404s - which is why + * `RELATED_COMPONENTS` and `RELATED_APPS` are gone: `/related-components` and + * `/related-apps` are registered for no entity type, and the route an area + * really serves is `/areas/{area_id}/components` (`COMPONENTS` below). + * + * What is checked, by what, and what is not: + * + * - Every enumerator has an arm in `capability_to_name` - enforced by the + * **compiler**, `-Werror=switch-enum`. Adding one here without an arm does + * not build. + * - No arm resolves to the `"unknown"` placeholder or an empty segment - + * `CapabilityBuilderTest.NamesEveryEnumerator`. The compiler cannot see + * this: an arm that returns the placeholder is a handled enumerator. + * - Every enumerator a handler list actually uses reaches a route that does + * not 404 - `test_openapi_contract::test_every_advertised_collection_is_served`, + * which follows each `href` against a live gateway. + * + * Not checked: that every enumerator is listed by some entity type. The four + * lists are function-local in `discovery_handlers.cpp`, so an enumerator no + * list uses is dead rather than wrong, and nothing here will say so. Pinning + * it would mean declaring the per-type membership a second time, which is the + * duplicated-fact problem this file already has three copies of. */ enum class Capability { DATA, ///< Entity has data endpoints + DATA_CATEGORIES, ///< Entity has a data-categories endpoint (answers 501) + DATA_GROUPS, ///< Entity has a data-groups endpoint (answers 501) OPERATIONS, ///< Entity has operations (services/actions) CONFIGURATIONS, ///< Entity has configurations (parameters) FAULTS, ///< Entity has fault management + FAULT_TRIGGERS, ///< Entity has fault-trigger threshold rules (apps only) SUBAREAS, ///< Entity has child areas (areas only) SUBCOMPONENTS, ///< Entity has child components (components only) - RELATED_COMPONENTS, ///< Entity has related components (areas only) + COMPONENTS, ///< Entity has member 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) IS_LOCATED_ON, ///< Entity has parent component (apps only) @@ -64,15 +93,16 @@ class CapabilityBuilder { }; /** - * @brief Build capabilities JSON array for an entity. + * @brief Build the capabilities array for an entity. * * @param entity_type The entity type (e.g., "areas", "components", "apps", "functions") * @param entity_id The entity identifier * @param capabilities Vector of capability types to include - * @return JSON array of capability objects with name and href + * @return One dto::EntityCapability per requested capability, in order */ - static nlohmann::json build_capabilities(const std::string & entity_type, const std::string & entity_id, - const std::vector & capabilities); + static std::vector build_capabilities(const std::string & entity_type, + const std::string & entity_id, + const std::vector & capabilities); /** * @brief Convert capability enum to string name. diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/models/entity_capabilities.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/models/entity_capabilities.hpp index 7d804d18f..d840ad226 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/models/entity_capabilities.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/models/entity_capabilities.hpp @@ -23,31 +23,37 @@ namespace ros2_medkit_gateway { /** - * @brief SOVD Entity Capabilities based on Table 8 and Table 10 + * @brief SOVD Entity Capabilities (SOVD Table 8 and Table 10, as served) * - * This class encapsulates which resource collections and resources - * are supported by each entity type according to SOVD specification. + * Which resource collections and resources each entity type exposes. The lists + * describe the routes `rest_server.cpp::setup_routes()` actually registers, not + * the union of what SOVD permits: an entry here becomes an `href` in the + * entity's `capabilities` array and a path in its `/docs` sub-document, so a + * collection with no route is a dead link rather than a statement of intent. + * `for_type` in the .cpp carries the per-type reasoning. * - * Resource Collections: - * - configurations: SERVER, COMPONENT, APP - * - data: SERVER, COMPONENT, APP, FUNCTION* - * - faults: SERVER, COMPONENT, APP - * - operations: SERVER, COMPONENT, APP, FUNCTION* - * - (others): SERVER, COMPONENT, APP + * Resource collections: + * - data, data-categories, data-groups, operations, configurations, faults, + * logs, bulk-data, triggers: AREA, COMPONENT, APP, FUNCTION + * - cyclic-subscriptions: COMPONENT, APP, FUNCTION + * - locks, scripts: COMPONENT, APP + * - fault-triggers: APP + * - faults, updates: SERVER (mounted at the API root) * * Resources: * - docs: all * - version-info: SERVER only - * - logs: SERVER, COMPONENT, APP + * - logs: COMPONENT, APP * - hosts: COMPONENT, FUNCTION - * - is-located-on: APP only - * - contains: AREA only - * - belongs-to: SERVER, COMPONENT, APP - * - depends-on: SERVER, COMPONENT, APP, FUNCTION - * - data-categories: SERVER, COMPONENT, APP - * - data-groups: SERVER, COMPONENT, APP + * - is-located-on, belongs-to: APP only + * - contains, subareas, components: AREA only + * - subcomponents: COMPONENT only + * - depends-on: COMPONENT, APP * * Note: FUNCTION data/operations are aggregated from hosted Apps (read-only). + * data-categories and data-groups are registered for every entity type and + * answer 501 - a served route reporting no ROS 2 mapping, which is a different + * thing from an unregistered one. */ class EntityCapabilities { public: diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/models/entity_types.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/models/entity_types.hpp index dbd72117d..1eafa75de 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/models/entity_types.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/models/entity_types.hpp @@ -38,22 +38,36 @@ enum class SovdEntityType { * @brief SOVD Resource Collections (Table 7) * * Standardized collections of diagnostic resources that entities may expose. + * + * An enumerator here names a collection the SOVD model knows about. It says + * nothing about whether the gateway serves it: a collection joins an entity's + * capability list in `EntityCapabilities::for_type` only once an entity-scoped + * route answers it, because every entry in that list becomes an `href` a client + * follows and a path in the entity's `/docs` sub-document. No entity type lists + * the four enumerators marked "no entity-scoped route" below for that reason - + * `UPDATES` appears only in the SERVER list, whose collections are the ones + * mounted at the API root. They are kept so a manifest or a plugin that later + * serves one has a name for it, and so `parse_resource_collection` still + * recognises the segment rather than reporting it as an unknown collection. */ enum class ResourceCollection { CONFIGURATIONS, ///< Configuration resources (ROS 2 parameters) DATA, ///< Static and dynamic data (topic subscriptions) + DATA_CATEGORIES, ///< Data categories (route answers 501 - no ROS 2 mapping) + DATA_GROUPS, ///< Data groups (route answers 501 - no ROS 2 mapping) FAULTS, ///< Fault resources (DiagnosticStatus messages) + FAULT_TRIGGERS, ///< Threshold rules that raise a fault (apps only, x-medkit) OPERATIONS, ///< Operation resources (services + actions) BULK_DATA, ///< Bulk data resources (large topic payloads) - DATA_LISTS, ///< Combined data resources (multi-topic groups) - LOCKS, ///< Lock resources (lifecycle states) - MODES, ///< Mode resources (node modes) + DATA_LISTS, ///< Combined data resources - no entity-scoped route + LOCKS, ///< Lock resources (exclusive entity access) + MODES, ///< Mode resources - no entity-scoped route CYCLIC_SUBSCRIPTIONS, ///< Cyclic subscriptions (topic polling) LOGS, ///< Application log entries (/rosout) - COMMUNICATION_LOGS, ///< Communication logs (CAN/UDS/DoIP - not implemented) + COMMUNICATION_LOGS, ///< Communication logs (CAN/UDS/DoIP) - no entity-scoped route TRIGGERS, ///< Trigger resources (event topics) - SCRIPTS, ///< Script resources (not mapped in ROS 2) - UPDATES ///< Update packages (not mapped in ROS 2) + SCRIPTS, ///< Diagnostic script resources + UPDATES ///< Update packages - server-scoped only, no entity-scoped route }; /** diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/contract.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/contract.hpp index f6d80841f..e6793cbf7 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/contract.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/contract.hpp @@ -77,6 +77,26 @@ constexpr Presence default_presence() { // `opaque_object`) and dispatched in the visitors via the // `is_opaque_object_field_v` detection trait below. +/// JSON Schema constraints a field publishes beyond its C++ type. +/// +/// Every member is unset by default, so a `field()` call that names no +/// constraints emits exactly what it emitted before this type existed. Only +/// write a constraint the handler enforces unconditionally: a bound the +/// gateway reads from a ROS parameter is configuration, and publishing it as +/// a schema keyword would make the document wrong on any deployment that +/// changed it. Say those in the `description` instead. +/// +/// `format` is an override. Numeric width already derives from the member type +/// (`SchemaWriter` emits `format: int64` for a signed 64-bit integral), so set +/// this only for a string format the type cannot imply - `date-time`, `uri`. +struct FieldConstraints { + std::optional minimum{}; + std::optional maximum{}; + std::optional max_length{}; + std::string_view pattern{}; + std::string_view format{}; +}; + /// Binds a JSON key to a struct member plus OpenAPI metadata. /// NEVER brace-initialize Field directly: aggregate CTAD is C++20-only. /// Always construct via the field() / field_enum() factories below. @@ -88,16 +108,29 @@ struct Field { std::string_view description; const std::string_view * enum_values; // (ptr,count) into an inline constexpr array std::size_t enum_count; + /// Written by every factory below rather than defaulted at the call sites: + /// the three `Field{...}` aggregate initialisations would otherwise trip + /// -Wmissing-field-initializers, which the build promotes to an error. + FieldConstraints constraints; }; template constexpr Field field(std::string_view key, M C::*ptr, std::string_view desc = std::string_view{}) { - return Field{key, ptr, default_presence(), desc, nullptr, 0}; + return Field{key, ptr, default_presence(), desc, nullptr, 0, FieldConstraints{}}; +} + +/// Constrained field. C++17 has no designated initialisers, so a call site +/// spells the unset members out: +/// field("max_entries", &T::max_entries, "…", +/// FieldConstraints{/*minimum=*/1.0, /*maximum=*/10000.0, {}, {}, {}}) +template +constexpr Field field(std::string_view key, M C::*ptr, std::string_view desc, FieldConstraints c) { + return Field{key, ptr, default_presence(), desc, nullptr, 0, c}; } template constexpr Field field(std::string_view key, M C::*ptr, Presence p, std::string_view desc = std::string_view{}) { - return Field{key, ptr, p, desc, nullptr, 0}; + return Field{key, ptr, p, desc, nullptr, 0, FieldConstraints{}}; } /// Enum-constrained field: `values` must be an inline constexpr std::string_view array. @@ -109,7 +142,7 @@ constexpr Field field_enum(std::string_view key, M C::*ptr, const std::str std::string_view desc = std::string_view{}) { static_assert(std::is_same_v || std::is_same_v>, "field_enum requires a std::string or std::optional member"); - return Field{key, ptr, default_presence(), desc, values, N}; + return Field{key, ptr, default_presence(), desc, values, N, FieldConstraints{}}; } // --- OpaqueObjectField ------------------------------------------------------ diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/cyclic_subscriptions.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/cyclic_subscriptions.hpp index fe7ed8d1d..1e602fd00 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/cyclic_subscriptions.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/cyclic_subscriptions.hpp @@ -77,8 +77,15 @@ struct CyclicSubscriptionCreateRequest { template <> inline constexpr auto dto_fields = std::make_tuple(field("resource", &CyclicSubscriptionCreateRequest::resource), - field("interval", &CyclicSubscriptionCreateRequest::interval), - field("duration", &CyclicSubscriptionCreateRequest::duration), + field("interval", &CyclicSubscriptionCreateRequest::interval, + "Sampling rate, named rather than numeric: `fast`, `normal` or `slow`. Any other value " + "is rejected with 400."), + field("duration", &CyclicSubscriptionCreateRequest::duration, + "How long the subscription runs, in seconds. Must be at least 1 and no more than the " + "gateway's configured ceiling; only the lower bound is a schema constraint, because " + "the ceiling is deployment configuration and a request over it answers 400 naming the " + "limit.", + FieldConstraints{/*minimum=*/1.0, {}, {}, {}, {}}), field("protocol", &CyclicSubscriptionCreateRequest::protocol)); template <> @@ -99,8 +106,14 @@ struct CyclicSubscriptionUpdateRequest { template <> inline constexpr auto dto_fields = - std::make_tuple(field("interval", &CyclicSubscriptionUpdateRequest::interval), - field("duration", &CyclicSubscriptionUpdateRequest::duration)); + std::make_tuple(field("interval", &CyclicSubscriptionUpdateRequest::interval, + "New sampling rate, validated exactly as on create: `fast`, `normal` or `slow`, " + "anything else 400. Omit it to leave the rate unchanged."), + field("duration", &CyclicSubscriptionUpdateRequest::duration, + "New run length in seconds, validated exactly as on create: at least 1 and no more " + "than the gateway's configured ceiling. Restarts the countdown from now rather than " + "adding to what is left. Omit it to leave the duration unchanged.", + FieldConstraints{/*minimum=*/1.0, {}, {}, {}, {}})); template <> inline constexpr std::string_view dto_name = "CyclicSubscriptionUpdateRequest"; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/entities.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/entities.hpp index 98a2da99c..2df81d067 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/entities.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/entities.hpp @@ -22,12 +22,37 @@ #include #include "ros2_medkit_gateway/dto/contract.hpp" +#include "ros2_medkit_gateway/dto/entity_capability.hpp" #include "ros2_medkit_gateway/dto/enums.hpp" #include "ros2_medkit_gateway/dto/x_medkit.hpp" namespace ros2_medkit_gateway { namespace dto { +// ============================================================================= +// Shared entity-detail members +// ============================================================================= + +/// Prose published for every ``_links`` member. The object stays untyped +/// because its value type is a union: every relation `LinksBuilder` writes is a +/// path string, but `depends-on` is written straight into the built object as an +/// array of paths, by the app handler and the function handler both. +/// `SchemaWriter` walks `dto_fields`, so it has no descriptor for a map whose +/// values differ in type - naming the relations and their value shapes in prose +/// is what a client can actually act on, and beats publishing `{}` with no +/// explanation. +/// +/// The per-relation conditions, read off `discovery_handlers.cpp`: `self` and +/// `collection` are unconditional; `parent` needs a `parent_area_id` (areas) or +/// a `parent_component_id` (components) and is emitted for no other type; +/// `area` needs `comp.area`; `is-located-on` and `belongs-to` need +/// `app.component_id`; `depends-on` needs a non-empty `depends_on` list. +inline constexpr std::string_view kLinksDescription = + "HATEOAS relation map. Relations emitted today: self and collection (all entity types), parent " + "(areas and components, when the entity has a parent), area (components), is-located-on and " + "belongs-to (apps) - each an absolute path - and depends-on (apps and functions), an array of " + "absolute paths."; + // ============================================================================= // Area DTOs // ============================================================================= @@ -62,10 +87,10 @@ inline constexpr std::string_view dto_name = "AreaListItem"; // // Wire keys: // id, name, description?, tags?, -// subareas, components, contains, data, operations, configurations, faults, -// logs, bulk-data, triggers, -// capabilities (free-form JSON array of {name, href} objects), -// _links (free-form JSON object), +// subareas, components, contains, data, data-categories, data-groups, +// operations, configurations, faults, logs, bulk-data, triggers, +// capabilities (array of EntityCapability), +// _links (open relation map - see kLinksDescription), // x-medkit // ----------------------------------------------------------------------------- struct AreaDetail { @@ -79,6 +104,8 @@ struct AreaDetail { std::string components; std::string contains; std::string data; + std::string data_categories; // wire key: "data-categories" + std::string data_groups; // wire key: "data-groups" std::string operations; std::string configurations; std::string faults; @@ -86,9 +113,9 @@ struct AreaDetail { std::string bulk_data; // wire key: "bulk-data" std::string triggers; // Free-form fields - std::optional capabilities; // array of {name, href} - std::optional links; // wire key: "_links" - std::optional x_medkit; // wire key: "x-medkit" + std::optional> capabilities; + std::optional links; // wire key: "_links" - see kLinksDescription + std::optional x_medkit; // wire key: "x-medkit" }; template <> @@ -98,11 +125,12 @@ inline constexpr auto dto_fields = field("description", &AreaDetail::description), field("tags", &AreaDetail::tags), field("subareas", &AreaDetail::subareas), field("components", &AreaDetail::components), field("contains", &AreaDetail::contains), field("data", &AreaDetail::data), - field("operations", &AreaDetail::operations), field("configurations", &AreaDetail::configurations), - field("faults", &AreaDetail::faults), field("logs", &AreaDetail::logs), - field("bulk-data", &AreaDetail::bulk_data), field("triggers", &AreaDetail::triggers), - field("capabilities", &AreaDetail::capabilities), field("_links", &AreaDetail::links), - field("x-medkit", &AreaDetail::x_medkit)); + field("data-categories", &AreaDetail::data_categories), + field("data-groups", &AreaDetail::data_groups), field("operations", &AreaDetail::operations), + field("configurations", &AreaDetail::configurations), field("faults", &AreaDetail::faults), + field("logs", &AreaDetail::logs), field("bulk-data", &AreaDetail::bulk_data), + field("triggers", &AreaDetail::triggers), field("capabilities", &AreaDetail::capabilities), + field("_links", &AreaDetail::links, kLinksDescription), field("x-medkit", &AreaDetail::x_medkit)); template <> inline constexpr std::string_view dto_name = "AreaDetail"; @@ -142,12 +170,14 @@ inline constexpr std::string_view dto_name = "ComponentListIt // // Wire keys: // id, name, description?, tags?, -// status, data, operations, configurations, faults, subcomponents, hosts, logs, -// bulk-data, cyclic-subscriptions, triggers, -// scripts? (conditional on script backend), depends-on? (conditional), +// status, data, data-categories, data-groups, operations, configurations, +// faults, subcomponents, hosts, logs, bulk-data, cyclic-subscriptions, +// triggers, +// scripts? (conditional on script backend), locks? (conditional on locking), +// depends-on? (conditional), // belongs-to? (conditional on area), is-located-on? (not present here - app only), -// capabilities (free-form JSON array), -// _links (free-form JSON object), +// capabilities (array of EntityCapability), +// _links (open relation map - see kLinksDescription), // x-medkit // ----------------------------------------------------------------------------- struct ComponentDetail { @@ -159,6 +189,8 @@ struct ComponentDetail { // Always-present resource collection URIs std::string status; std::string data; + std::string data_categories; // wire key: "data-categories" + std::string data_groups; // wire key: "data-groups" std::string operations; std::string configurations; std::string faults; @@ -170,11 +202,12 @@ struct ComponentDetail { std::string triggers; // Conditional URI fields std::optional scripts; // present only with script backend + std::optional locks; // present only when locking is enabled std::optional depends_on; // wire key: "depends-on" std::optional belongs_to; // wire key: "belongs-to" // Free-form fields - std::optional capabilities; - std::optional links; // wire key: "_links" + std::optional> capabilities; + std::optional links; // wire key: "_links" - see kLinksDescription std::optional x_medkit; // wire key: "x-medkit" }; @@ -183,15 +216,16 @@ inline constexpr auto dto_fields = std::make_tuple( field("id", &ComponentDetail::id), field("name", &ComponentDetail::name), field_enum("type", &ComponentDetail::type, kEntityTypeValues), field("description", &ComponentDetail::description), field("tags", &ComponentDetail::tags), field("status", &ComponentDetail::status), - field("data", &ComponentDetail::data), field("operations", &ComponentDetail::operations), + field("data", &ComponentDetail::data), field("data-categories", &ComponentDetail::data_categories), + field("data-groups", &ComponentDetail::data_groups), field("operations", &ComponentDetail::operations), field("configurations", &ComponentDetail::configurations), field("faults", &ComponentDetail::faults), field("subcomponents", &ComponentDetail::subcomponents), field("hosts", &ComponentDetail::hosts), field("logs", &ComponentDetail::logs), field("bulk-data", &ComponentDetail::bulk_data), field("cyclic-subscriptions", &ComponentDetail::cyclic_subscriptions), field("triggers", &ComponentDetail::triggers), field("scripts", &ComponentDetail::scripts), - field("depends-on", &ComponentDetail::depends_on), field("belongs-to", &ComponentDetail::belongs_to), - field("capabilities", &ComponentDetail::capabilities), field("_links", &ComponentDetail::links), - field("x-medkit", &ComponentDetail::x_medkit)); + field("locks", &ComponentDetail::locks), field("depends-on", &ComponentDetail::depends_on), + field("belongs-to", &ComponentDetail::belongs_to), field("capabilities", &ComponentDetail::capabilities), + field("_links", &ComponentDetail::links, kLinksDescription), field("x-medkit", &ComponentDetail::x_medkit)); template <> inline constexpr std::string_view dto_name = "ComponentDetail"; @@ -230,14 +264,15 @@ inline constexpr std::string_view dto_name = "AppListItem"; // // Wire keys: // id, name, description?, translation_id?, tags?, -// status, data, operations, configurations, faults, logs, bulk-data, -// cyclic-subscriptions, triggers, +// status, data, data-categories, data-groups, operations, configurations, +// fault-triggers, faults, logs, bulk-data, cyclic-subscriptions, triggers, // scripts? (conditional on script backend), +// locks? (conditional on locking being enabled), // is-located-on? (conditional on component_id), // belongs-to? (conditional on component_id), // depends-on? (conditional on depends_on list), -// capabilities (free-form JSON array), -// _links (free-form JSON object), +// capabilities (array of EntityCapability), +// _links (open relation map - see kLinksDescription), // x-medkit // ----------------------------------------------------------------------------- struct AppDetail { @@ -250,8 +285,11 @@ struct AppDetail { // Always-present resource collection URIs std::string status; std::string data; + std::string data_categories; // wire key: "data-categories" + std::string data_groups; // wire key: "data-groups" std::string operations; std::string configurations; + std::string fault_triggers; // wire key: "fault-triggers" (apps only) std::string faults; std::string logs; std::string bulk_data; // wire key: "bulk-data" @@ -259,29 +297,31 @@ struct AppDetail { std::string triggers; // Conditional URI fields std::optional scripts; // present only with script backend + std::optional locks; // present only when locking is enabled std::optional is_located_on; // wire key: "is-located-on" std::optional belongs_to; // wire key: "belongs-to" std::optional depends_on; // wire key: "depends-on" // Free-form fields - std::optional capabilities; - std::optional links; // wire key: "_links" + std::optional> capabilities; + std::optional links; // wire key: "_links" - see kLinksDescription std::optional x_medkit; // wire key: "x-medkit" }; template <> -inline constexpr auto dto_fields = - std::make_tuple(field("id", &AppDetail::id), field("name", &AppDetail::name), - field_enum("type", &AppDetail::type, kEntityTypeValues), - field("description", &AppDetail::description), field("translation_id", &AppDetail::translation_id), - field("tags", &AppDetail::tags), field("status", &AppDetail::status), - field("data", &AppDetail::data), field("operations", &AppDetail::operations), - field("configurations", &AppDetail::configurations), field("faults", &AppDetail::faults), - field("logs", &AppDetail::logs), field("bulk-data", &AppDetail::bulk_data), - field("cyclic-subscriptions", &AppDetail::cyclic_subscriptions), - field("triggers", &AppDetail::triggers), field("scripts", &AppDetail::scripts), - field("is-located-on", &AppDetail::is_located_on), field("belongs-to", &AppDetail::belongs_to), - field("depends-on", &AppDetail::depends_on), field("capabilities", &AppDetail::capabilities), - field("_links", &AppDetail::links), field("x-medkit", &AppDetail::x_medkit)); +inline constexpr auto dto_fields = std::make_tuple( + field("id", &AppDetail::id), field("name", &AppDetail::name), + field_enum("type", &AppDetail::type, kEntityTypeValues), field("description", &AppDetail::description), + field("translation_id", &AppDetail::translation_id), field("tags", &AppDetail::tags), + field("status", &AppDetail::status), field("data", &AppDetail::data), + field("data-categories", &AppDetail::data_categories), field("data-groups", &AppDetail::data_groups), + field("operations", &AppDetail::operations), field("configurations", &AppDetail::configurations), + field("fault-triggers", &AppDetail::fault_triggers), field("faults", &AppDetail::faults), + field("logs", &AppDetail::logs), field("bulk-data", &AppDetail::bulk_data), + field("cyclic-subscriptions", &AppDetail::cyclic_subscriptions), field("triggers", &AppDetail::triggers), + field("scripts", &AppDetail::scripts), field("locks", &AppDetail::locks), + field("is-located-on", &AppDetail::is_located_on), field("belongs-to", &AppDetail::belongs_to), + field("depends-on", &AppDetail::depends_on), field("capabilities", &AppDetail::capabilities), + field("_links", &AppDetail::links, kLinksDescription), field("x-medkit", &AppDetail::x_medkit)); template <> inline constexpr std::string_view dto_name = "AppDetail"; @@ -321,10 +361,10 @@ inline constexpr std::string_view dto_name = "FunctionListItem // // Wire keys: // id, name, description?, translation_id?, tags?, -// hosts, data, operations, configurations, faults, logs, bulk-data, -// x-medkit-graph, cyclic-subscriptions, triggers, -// capabilities (free-form JSON array), -// _links (free-form JSON object), +// hosts, data, data-categories, data-groups, operations, configurations, +// faults, logs, bulk-data, x-medkit-graph, cyclic-subscriptions, triggers, +// capabilities (array of EntityCapability), +// _links (open relation map - see kLinksDescription), // x-medkit // ----------------------------------------------------------------------------- struct FunctionDetail { @@ -337,6 +377,8 @@ struct FunctionDetail { // Always-present resource collection URIs std::string hosts; std::string data; + std::string data_categories; // wire key: "data-categories" + std::string data_groups; // wire key: "data-groups" std::string operations; std::string configurations; std::string faults; @@ -346,8 +388,8 @@ struct FunctionDetail { std::string cyclic_subscriptions; // wire key: "cyclic-subscriptions" std::string triggers; // Free-form fields - std::optional capabilities; - std::optional links; // wire key: "_links" + std::optional> capabilities; + std::optional links; // wire key: "_links" - see kLinksDescription std::optional x_medkit; // wire key: "x-medkit" }; @@ -357,11 +399,12 @@ inline constexpr auto dto_fields = std::make_tuple( field_enum("type", &FunctionDetail::type, kEntityTypeValues), field("description", &FunctionDetail::description), field("translation_id", &FunctionDetail::translation_id), field("tags", &FunctionDetail::tags), field("hosts", &FunctionDetail::hosts), field("data", &FunctionDetail::data), + field("data-categories", &FunctionDetail::data_categories), field("data-groups", &FunctionDetail::data_groups), field("operations", &FunctionDetail::operations), field("configurations", &FunctionDetail::configurations), field("faults", &FunctionDetail::faults), field("logs", &FunctionDetail::logs), field("bulk-data", &FunctionDetail::bulk_data), field("x-medkit-graph", &FunctionDetail::x_medkit_graph), field("cyclic-subscriptions", &FunctionDetail::cyclic_subscriptions), field("triggers", &FunctionDetail::triggers), - field("capabilities", &FunctionDetail::capabilities), field("_links", &FunctionDetail::links), + field("capabilities", &FunctionDetail::capabilities), field("_links", &FunctionDetail::links, kLinksDescription), field("x-medkit", &FunctionDetail::x_medkit)); template <> diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/entity_capability.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/entity_capability.hpp new file mode 100644 index 000000000..fcbae524b --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/entity_capability.hpp @@ -0,0 +1,54 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include + +#include "ros2_medkit_gateway/dto/contract.hpp" + +namespace ros2_medkit_gateway { +namespace dto { + +// ----------------------------------------------------------------------------- +// EntityCapability - one element of an entity's "capabilities" array. +// +// Built by `CapabilityBuilder::build_capabilities`, then extended in place by +// `append_plugin_capabilities`. Both write the same two keys, which is why the +// array is typed rather than free-form: a client reads `name` to decide what +// the entity supports and follows `href` to reach it. +// +// It lives in its own header because both the entity detail DTOs and the +// `x-medkit` payloads that carry a copy of the same array need it, and +// `entities.hpp` already includes `x_medkit.hpp`. +// +// Wire keys: name, href +// ----------------------------------------------------------------------------- +struct EntityCapability { + std::string name; ///< Collection or relationship name, e.g. "data", "belongs-to" + std::string href; ///< Absolute path, API prefix included ("/api/v1/...") +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("name", &EntityCapability::name, "Collection or relationship this entity supports."), + field("href", &EntityCapability::href, "Absolute path of the collection, API prefix included.")); + +template <> +inline constexpr std::string_view dto_name = "EntityCapability"; + +} // namespace dto +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/health.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/health.hpp index 335ac50b6..f8fa4220f 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/health.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/health.hpp @@ -140,8 +140,12 @@ struct Health { template <> inline constexpr auto dto_fields = std::make_tuple( - field("status", &Health::status), field("timestamp", &Health::timestamp), field("discovery", &Health::discovery), - field("x-medkit-data-provider", &Health::x_medkit_data_provider), + field("status", &Health::status), + field("timestamp", &Health::timestamp, + "Gateway wall-clock reading taken while the response was being built, in nanoseconds since the Unix " + "epoch. Not monotonic: it follows the host clock, so two readings can move backwards across an NTP " + "step and the difference between them is not a reliable elapsed time."), + field("discovery", &Health::discovery), field("x-medkit-data-provider", &Health::x_medkit_data_provider), field("x-medkit-subscription-executor", &Health::x_medkit_subscription_executor), field("x-medkit-entity-cache", &Health::x_medkit_entity_cache), field("peers", &Health::peers), field("warning_schema_version", &Health::warning_schema_version), field("warnings", &Health::warnings)); diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/locks.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/locks.hpp index 4cd8e9e1d..097593a73 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/locks.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/locks.hpp @@ -71,9 +71,25 @@ struct AcquireLockRequest { }; template <> -inline constexpr auto dto_fields = - std::make_tuple(field("lock_expiration", &AcquireLockRequest::lock_expiration), - field("scopes", &AcquireLockRequest::scopes), field("break_lock", &AcquireLockRequest::break_lock)); +inline constexpr auto dto_fields = std::make_tuple( + field("lock_expiration", &AcquireLockRequest::lock_expiration, + "How long the lock lives, in seconds from the moment the gateway grants it - a duration, unlike the " + "`lock_expiration` on the Lock response, which is an ISO 8601 instant. Must be at least 1 and no more " + "than the configured ceiling (`locking.default_max_expiration`, 3600 s unless a manifest overrides it " + "for the entity); only the lower bound is a schema constraint, because the ceiling is deployment " + "configuration. Extending a lock restarts the countdown from the new value - it does not add to what " + "is left. Reaching the expiry is not the same as releasing the lock; the acquire operation says how " + "they differ.", + FieldConstraints{/*minimum=*/1.0, {}, {}, {}, {}}), + field("scopes", &AcquireLockRequest::scopes, + "Resource collections the lock covers, from `data`, `operations`, `configurations`, `faults`, `modes`, " + "`scripts`, `bulk-data`, `logs` and `cyclic-subscriptions` (the set `valid_lock_scopes()` accepts; any " + "other value is rejected with 400). Omitted or empty locks every collection."), + field("break_lock", &AcquireLockRequest::break_lock, + "Take the lock even though another client already holds one. Without it an existing lock answers 409 " + "`lock-conflict`; with it, an existing lock the entity's configuration marks non-breakable answers 409 " + "`lock-not-breakable` instead. Either way the 409 body carries the blocking lock's id in " + "`parameters.existing_lock_id`.")); template <> inline constexpr std::string_view dto_name = "AcquireLockRequest"; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/logs.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/logs.hpp index 073dd5e65..f4820c06d 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/logs.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/logs.hpp @@ -154,7 +154,17 @@ inline constexpr std::string_view dto_name> // // Wire shape (from log_configuration_schema() in schema_builder.cpp): // severity_filter - log level filter string (optional) -// max_entries - maximum number of buffered log entries (optional, 1..10000) +// max_entries - maximum number of entries ONE GET /logs answers with +// (optional, 1..10000) +// +// Both settings filter the answer, not the buffer: LogManager::on_log_entry +// consults neither, so nothing is dropped on account of them, and the ring +// buffer's own size is the separate max_buffer_size_ constructor argument. +// (Ingestion does drop entries for its own reasons - ring-buffer overflow, the +// distinct-node cap, a LogProvider observer claiming the entry - none of which +// these two settings influence.) See the note on LogConfig in +// core/log_types.hpp: SOVD reads max_entries as a storage limit, and this +// implementation deliberately does not. // // severity_filter uses plain field() (not field_enum) because handle_put_logs_configuration // performs its own bespoke validation of severity via log_mgr->update_config(), which @@ -168,7 +178,20 @@ struct LogConfiguration { template <> inline constexpr auto dto_fields = std::make_tuple( - field("severity_filter", &LogConfiguration::severity_filter), field("max_entries", &LogConfiguration::max_entries)); + field("severity_filter", &LogConfiguration::severity_filter, + "Lowest severity a `GET /{entity}/logs` answers with: `debug`, `info`, `warning`, `error` or `fatal`. " + "Like `max_entries` this filters the answer, not the buffer - nothing is discarded on account of it, " + "so raising it hides quieter entries from subsequent reads and lowering it brings them back. The " + "effective floor is the stricter of this and the request's own `severity` parameter, so a lenient " + "query cannot see past a strict configuration. A registered LogProvider plugin serves the query " + "itself and this setting does not apply to it."), + field("max_entries", &LogConfiguration::max_entries, + "Ceiling on how many entries one `GET /{entity}/logs` answers with. A match longer than this is cut " + "down to the most recent `max_entries` and the older ones are dropped from the answer - silently, with " + "nothing on the response saying so and no way to page past it. Accepted range is 1 to 10000. It caps " + "the answer, not the gateway's log buffer, whose size is a separate start-up setting. A registered " + "LogProvider plugin serves the query itself and this setting does not apply to it.", + FieldConstraints{/*minimum=*/1.0, /*maximum=*/10000.0, {}, {}, {}})); template <> inline constexpr std::string_view dto_name = "LogConfiguration"; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/operations.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/operations.hpp index cf72f0a6f..0c5667e59 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/operations.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/operations.hpp @@ -184,8 +184,14 @@ struct ExecutionUpdateRequest { }; template <> -inline constexpr auto dto_fields = - std::make_tuple(field("capability", &ExecutionUpdateRequest::capability)); +inline constexpr auto dto_fields = std::make_tuple( + field("capability", &ExecutionUpdateRequest::capability, + "SOVD control capability to apply to the running execution: `stop`, `execute`, `freeze` or `reset`. A ROS 2 " + "action implements only `stop`, which cancels the goal and answers 202; `execute` answers 409 (cancel first, " + "then start a new execution) and `freeze` and `reset` answer 400. Described rather than declared as an " + "`enum` on purpose - the handler answers an unknown value with a 400 that names `supported_capabilities` for " + "the backend in front of the caller, and a schema-level enum would replace that with a generic " + "body-validation error. Same reason `LogConfiguration.severity_filter` carries no enum.")); template <> inline constexpr std::string_view dto_name = "ExecutionUpdateRequest"; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp index 59a77e1ba..1970c6370 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp @@ -27,6 +27,7 @@ #include "ros2_medkit_gateway/dto/cyclic_subscriptions.hpp" #include "ros2_medkit_gateway/dto/data.hpp" #include "ros2_medkit_gateway/dto/entities.hpp" +#include "ros2_medkit_gateway/dto/entity_capability.hpp" #include "ros2_medkit_gateway/dto/errors.hpp" #include "ros2_medkit_gateway/dto/fault_triggers.hpp" #include "ros2_medkit_gateway/dto/faults.hpp" @@ -49,9 +50,9 @@ namespace dto { /// (Phase 2/3) appends its types here. Order is irrelevant. using AllDtos = std::tuple, Collection, - Collection, Collection, FaultListItem, + XMedkitCollection, EntityCapability, AreaListItem, AreaDetail, ComponentListItem, ComponentDetail, + AppListItem, AppDetail, FunctionListItem, FunctionDetail, Collection, + Collection, Collection, Collection, FaultListItem, Collection, Collection, FaultListXMedkit, FaultListAggXMedkit, FaultStatus, FaultItem, FaultEnvironmentData, FaultXMedkit, FaultDetail, FaultListResult, FaultDetailResult, FaultClearResult, diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/schema_writer.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/schema_writer.hpp index 4bc00a03b..a8649902a 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/schema_writer.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/schema_writer.hpp @@ -58,7 +58,14 @@ nlohmann::json schema_of() { } else if constexpr (std::is_same_v) { return nlohmann::json{{"type", "boolean"}}; } else if constexpr (std::is_integral_v) { - return nlohmann::json{{"type", "integer"}}; + nlohmann::json integer{{"type", "integer"}}; + // `format: int64` asserts a signed 64-bit range, so it is derived from + // signedness as well as width: `sizeof(U) == 8` alone also matches + // `uint64_t` and `std::size_t`, whose upper half the format excludes. + if constexpr (sizeof(U) == 8 && std::is_signed_v) { + integer["format"] = "int64"; + } + return integer; } else if constexpr (std::is_floating_point_v) { return nlohmann::json{{"type", "number"}}; } else { @@ -87,24 +94,49 @@ nlohmann::json derived_object_schema() { } else { using MemberT = std::decay_t().*(f.ptr))>; nlohmann::json prop = schema_of(); + // The description stays at the property level even for a nullable + // member: it describes the field, not one branch of its schema, and a + // reader shows it either way. Everything below is a *validation* + // keyword, which is why it goes somewhere else. if (!f.description.empty()) { prop["description"] = std::string(f.description); } + // For optional members schema_of() yields {anyOf:[, {null}]}. + // A validation keyword written at the property level would apply to the + // null branch too and reject the null that anyOf advertises, so it goes + // on the non-null branch ([0]). Required members have no anyOf and take + // it at the top level. One lambda for the enum and every constraint, so + // the two placements cannot drift apart. + auto attach = [&prop](const char * keyword, nlohmann::json value) { + if (prop.contains("anyOf") && prop["anyOf"].is_array() && !prop["anyOf"].empty()) { + prop["anyOf"][0][keyword] = std::move(value); + } else { + prop[keyword] = std::move(value); + } + }; if (f.enum_count > 0) { nlohmann::json values = nlohmann::json::array(); for (std::size_t i = 0; i < f.enum_count; ++i) { values.push_back(std::string(f.enum_values[i])); } - // For optional members schema_of() yields {anyOf:[, {null}]}; - // attach the enum to the non-null branch ([0]) so the nullable claim - // and the enum constraint agree (a top-level enum lacking "null" would - // reject the null that anyOf advertises). Required members get the - // enum at the top level. - if (prop.contains("anyOf") && prop["anyOf"].is_array() && !prop["anyOf"].empty()) { - prop["anyOf"][0]["enum"] = values; - } else { - prop["enum"] = values; - } + attach("enum", values); + } + if (f.constraints.minimum.has_value()) { + attach("minimum", *f.constraints.minimum); + } + if (f.constraints.maximum.has_value()) { + attach("maximum", *f.constraints.maximum); + } + if (f.constraints.max_length.has_value()) { + attach("maxLength", *f.constraints.max_length); + } + if (!f.constraints.pattern.empty()) { + attach("pattern", std::string(f.constraints.pattern)); + } + // Last, so an explicit format overrides the one derived from the member + // type (`int64` for a signed 64-bit integral). + if (!f.constraints.format.empty()) { + attach("format", std::string(f.constraints.format)); } props[std::string(f.key)] = prop; if (f.presence == Presence::kRequired) { diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/triggers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/triggers.hpp index ed510cbc1..87a409a7b 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/triggers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/triggers.hpp @@ -87,7 +87,11 @@ inline constexpr std::string_view dto_name = "Trigger"; // multishot - fire multiple times (optional) // persistent - survive server restarts (optional) // lifetime - lifetime in seconds, must be > 0 (optional) -// path - JSON Pointer delivery path (optional) +// path - JSON Pointer selecting the part of the observed value the +// condition is evaluated against, max 1024 chars (optional). +// NOT a delivery path: TriggerManager applies it to the +// incoming value before evaluating, and skips any update in +// which it does not resolve. // log_settings - free-form log capture settings (optional) // ============================================================================= struct TriggerCreateRequest { @@ -104,10 +108,26 @@ struct TriggerCreateRequest { template <> inline constexpr auto dto_fields = std::make_tuple( field("resource", &TriggerCreateRequest::resource), + // trigger_condition's prose lives on the schema override below, which + // replaces this property wholesale - see detail::trigger_condition_schema. field("trigger_condition", &TriggerCreateRequest::trigger_condition), - field("protocol", &TriggerCreateRequest::protocol), field("multishot", &TriggerCreateRequest::multishot), - field("persistent", &TriggerCreateRequest::persistent), field("lifetime", &TriggerCreateRequest::lifetime), - field("path", &TriggerCreateRequest::path), field("log_settings", &TriggerCreateRequest::log_settings)); + field("protocol", &TriggerCreateRequest::protocol, + "Transport the trigger's events are delivered over. `sse` is the only value the gateway accepts and is " + "what an omitted field means; anything else is rejected with 400."), + field("multishot", &TriggerCreateRequest::multishot), field("persistent", &TriggerCreateRequest::persistent), + field("lifetime", &TriggerCreateRequest::lifetime, + "How long the trigger stays active, in seconds from creation. Omit it and the trigger never expires on " + "its own - it lives until it is deleted, or until a single-shot trigger fires. `PUT " + "/{entity}/triggers/{trigger_id}` restarts the countdown from the new value rather than adding to what " + "is left.", + FieldConstraints{/*minimum=*/1.0, {}, {}, {}, {}}), + field("path", &TriggerCreateRequest::path, + "JSON Pointer (RFC 6901) selecting the part of the observed resource the condition is evaluated " + "against, e.g. `/data/temperature`. Omit it to evaluate the whole value. An update in which the " + "pointer does not resolve is skipped rather than treated as a change, so a pointer that never matches " + "yields a trigger that never fires. Maximum 1024 characters.", + FieldConstraints{{}, {}, /*max_length=*/1024U, {}, {}}), + field("log_settings", &TriggerCreateRequest::log_settings)); template <> inline constexpr std::string_view dto_name = "TriggerCreateRequest"; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/x_medkit.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/x_medkit.hpp index caacbb74d..14118285d 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/x_medkit.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/x_medkit.hpp @@ -24,6 +24,7 @@ #include "ros2_medkit_gateway/dto/aggregation.hpp" #include "ros2_medkit_gateway/dto/contract.hpp" +#include "ros2_medkit_gateway/dto/entity_capability.hpp" namespace ros2_medkit_gateway { namespace dto { @@ -99,7 +100,7 @@ inline constexpr std::string_view dto_name = "XMedkitArea"; // variant <- comp.variant (via ext.add()) // description <- comp.description (via ext.add()) // contributors <- comp.contributors -// capabilities <- capabilities JSON array (via ext.add()) +// capabilities <- the same EntityCapability array the response carries at root level // external <- comp.external, non-ROS external asset classification; emitted true-only on every // route that carries the component x-medkit, so "absence == not external" holds (#516) // @@ -119,7 +120,7 @@ struct XMedkitComponent { std::optional variant; std::optional description; std::optional> contributors; - std::optional capabilities; // free-form JSON array + std::optional> capabilities; // Asset-identity nameplate (AssetIdentity::to_json shape: camelCase fields + // "_provenance"). Free-form JSON so the DTO layer reuses the exact // serialization emitted by Component::to_json and consumed by peer parsing. diff --git a/src/ros2_medkit_gateway/src/core/http/handlers/capability_builder.cpp b/src/ros2_medkit_gateway/src/core/http/handlers/capability_builder.cpp index 30bb582be..831b3b003 100644 --- a/src/ros2_medkit_gateway/src/core/http/handlers/capability_builder.cpp +++ b/src/ros2_medkit_gateway/src/core/http/handlers/capability_builder.cpp @@ -21,22 +21,26 @@ std::string CapabilityBuilder::capability_to_name(Capability cap) { switch (cap) { case Capability::DATA: return "data"; + case Capability::DATA_CATEGORIES: + return "data-categories"; + case Capability::DATA_GROUPS: + return "data-groups"; case Capability::OPERATIONS: return "operations"; case Capability::CONFIGURATIONS: return "configurations"; case Capability::FAULTS: return "faults"; + case Capability::FAULT_TRIGGERS: + return "fault-triggers"; case Capability::SUBAREAS: return "subareas"; case Capability::SUBCOMPONENTS: return "subcomponents"; - case Capability::RELATED_COMPONENTS: - return "related-components"; + case Capability::COMPONENTS: + return "components"; case Capability::CONTAINS: return "contains"; - case Capability::RELATED_APPS: - return "related-apps"; case Capability::HOSTS: return "hosts"; case Capability::DEPENDS_ON: @@ -69,20 +73,22 @@ std::string CapabilityBuilder::capability_to_path(Capability cap) { return capability_to_name(cap); } -nlohmann::json CapabilityBuilder::build_capabilities(const std::string & entity_type, const std::string & entity_id, - const std::vector & capabilities) { - nlohmann::json result = nlohmann::json::array(); +std::vector CapabilityBuilder::build_capabilities(const std::string & entity_type, + const std::string & entity_id, + const std::vector & capabilities) { + std::vector result; + result.reserve(capabilities.size()); - for (const auto & cap : capabilities) { - nlohmann::json cap_obj; - cap_obj["name"] = capability_to_name(cap); - - // Build href: /api/v1/{entity_type}/{entity_id}/{capability_path} - std::string href = "/api/v1/"; - href.append(entity_type).append("/").append(entity_id).append("/").append(capability_to_path(cap)); - cap_obj["href"] = href; + // Shared href prefix: /api/v1/{entity_type}/{entity_id}/ + std::string href_prefix = "/api/v1/"; + href_prefix.append(entity_type).append("/").append(entity_id).append("/"); - result.push_back(cap_obj); + for (const auto & cap : capabilities) { + dto::EntityCapability item; + item.name = capability_to_name(cap); + item.href = href_prefix; + item.href.append(capability_to_path(cap)); + result.push_back(std::move(item)); } return result; diff --git a/src/ros2_medkit_gateway/src/core/http/parameter_error_classification.cpp b/src/ros2_medkit_gateway/src/core/http/parameter_error_classification.cpp index 1c487bff6..cf0b4b2f3 100644 --- a/src/ros2_medkit_gateway/src/core/http/parameter_error_classification.cpp +++ b/src/ros2_medkit_gateway/src/core/http/parameter_error_classification.cpp @@ -112,6 +112,7 @@ ParameterErrorClassification classify_parameter_error(const ParameterResult & re const std::vector & parameter_error_statuses() { static const std::vector statuses = [] { std::vector out; + out.reserve(kAllParameterErrorCodes.size()); for (ParameterErrorCode code : kAllParameterErrorCodes) { out.push_back(classify_error_code(code).status_code); } diff --git a/src/ros2_medkit_gateway/src/core/models/entity_capabilities.cpp b/src/ros2_medkit_gateway/src/core/models/entity_capabilities.cpp index ee8a89480..b3d91aa2b 100644 --- a/src/ros2_medkit_gateway/src/core/models/entity_capabilities.cpp +++ b/src/ros2_medkit_gateway/src/core/models/entity_capabilities.cpp @@ -19,82 +19,103 @@ namespace ros2_medkit_gateway { EntityCapabilities EntityCapabilities::for_type(SovdEntityType type) { EntityCapabilities caps; + // Every entry below names a route that exists for that entity type. The lists + // are read back out as `href`s in an entity's `capabilities` array and as + // paths in its `/docs` sub-document, so an entry with no route is a link a + // client follows into a 404. `rest_server.cpp::setup_routes()` is the source + // of truth: the four-entity-type loop registers data / data-categories / + // data-groups / operations / configurations / faults / logs / bulk-data / + // triggers for every type, and gates cyclic-subscriptions (not areas), locks + // and scripts (components and apps) and fault-triggers (apps) behind an + // entity-type check. switch (type) { case SovdEntityType::SERVER: - // SERVER supports all collections + // Server-scoped collections are the two mounted at the API root: + // `/faults` (+ `/faults/stream`) and `/updates`. Everything else in this + // enum is entity-scoped only - `/logs`, `/data`, `/operations`, + // `/configurations`, `/bulk-data`, `/locks`, `/triggers`, `/scripts` and + // `/cyclic-subscriptions` all answer 404 at the root. caps.collections_ = { - ResourceCollection::CONFIGURATIONS, ResourceCollection::DATA, ResourceCollection::FAULTS, - ResourceCollection::OPERATIONS, ResourceCollection::BULK_DATA, ResourceCollection::DATA_LISTS, - ResourceCollection::LOCKS, ResourceCollection::MODES, ResourceCollection::CYCLIC_SUBSCRIPTIONS, - ResourceCollection::LOGS, ResourceCollection::TRIGGERS, ResourceCollection::SCRIPTS, + ResourceCollection::FAULTS, ResourceCollection::UPDATES, }; // SERVER resources. SOVD (ISO 17978-3 §7.6) does not define // /belongs-to for server, only for apps - advertising it here would // make supports_resource("belongs-to") return true and clients would - // get 404 when following it. - caps.resources_ = {"docs", "version-info", "logs", "depends-on", "data-categories", "data-groups"}; + // get 404 when following it. The same argument removed /logs, + // /depends-on, /data-categories and /data-groups: all four are + // entity-scoped routes with nothing mounted at the root. + caps.resources_ = {"docs", "version-info"}; break; case SovdEntityType::AREA: // 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, + ResourceCollection::DATA, ResourceCollection::DATA_CATEGORIES, ResourceCollection::DATA_GROUPS, + ResourceCollection::OPERATIONS, ResourceCollection::CONFIGURATIONS, ResourceCollection::FAULTS, + ResourceCollection::LOGS, ResourceCollection::BULK_DATA, ResourceCollection::TRIGGERS, }; caps.aggregated_collections_ = { ResourceCollection::DATA, ResourceCollection::OPERATIONS, ResourceCollection::CONFIGURATIONS, ResourceCollection::FAULTS, ResourceCollection::LOGS, }; - caps.resources_ = {"docs", "contains", "subareas", "related-components"}; + // The route is `/areas/{area_id}/components`; "related-components" was a + // name no registration ever used. + caps.resources_ = {"docs", "contains", "subareas", "components"}; break; case SovdEntityType::COMPONENT: - // COMPONENT supports most collections caps.collections_ = { - ResourceCollection::CONFIGURATIONS, ResourceCollection::DATA, ResourceCollection::FAULTS, - ResourceCollection::OPERATIONS, ResourceCollection::BULK_DATA, ResourceCollection::DATA_LISTS, - ResourceCollection::LOCKS, ResourceCollection::MODES, ResourceCollection::CYCLIC_SUBSCRIPTIONS, - ResourceCollection::LOGS, ResourceCollection::TRIGGERS, ResourceCollection::SCRIPTS, - ResourceCollection::UPDATES, + ResourceCollection::CONFIGURATIONS, ResourceCollection::DATA, ResourceCollection::DATA_CATEGORIES, + ResourceCollection::DATA_GROUPS, ResourceCollection::FAULTS, ResourceCollection::OPERATIONS, + ResourceCollection::BULK_DATA, ResourceCollection::LOCKS, ResourceCollection::CYCLIC_SUBSCRIPTIONS, + ResourceCollection::LOGS, ResourceCollection::TRIGGERS, ResourceCollection::SCRIPTS, }; // SOVD (ISO 17978-3 §7.6) defines /belongs-to only for apps; component // exposes parent area via /is-located-on (which is itself app-only in // the spec, but ros2_medkit treats it as the canonical area pointer). // Listing belongs-to here would be a 404 promise. - caps.resources_ = {"docs", "logs", "hosts", "depends-on", "subcomponents", "data-categories", "data-groups"}; + caps.resources_ = {"docs", "logs", "hosts", "depends-on", "subcomponents"}; break; case SovdEntityType::APP: - // APP supports most collections + // Apps carry everything a component does plus the fault-trigger rule + // collection, whose routes are registered for `/apps` alone. caps.collections_ = { - ResourceCollection::CONFIGURATIONS, ResourceCollection::DATA, ResourceCollection::FAULTS, - ResourceCollection::OPERATIONS, ResourceCollection::BULK_DATA, ResourceCollection::DATA_LISTS, - ResourceCollection::LOCKS, ResourceCollection::MODES, ResourceCollection::CYCLIC_SUBSCRIPTIONS, - ResourceCollection::LOGS, ResourceCollection::TRIGGERS, ResourceCollection::SCRIPTS, - ResourceCollection::UPDATES, + ResourceCollection::CONFIGURATIONS, + ResourceCollection::DATA, + ResourceCollection::DATA_CATEGORIES, + ResourceCollection::DATA_GROUPS, + ResourceCollection::FAULTS, + ResourceCollection::FAULT_TRIGGERS, + ResourceCollection::OPERATIONS, + ResourceCollection::BULK_DATA, + ResourceCollection::LOCKS, + ResourceCollection::CYCLIC_SUBSCRIPTIONS, + ResourceCollection::LOGS, + ResourceCollection::TRIGGERS, + ResourceCollection::SCRIPTS, }; - caps.resources_ = {"docs", "logs", "is-located-on", "belongs-to", "depends-on", "data-categories", "data-groups"}; + caps.resources_ = {"docs", "logs", "is-located-on", "belongs-to", "depends-on"}; break; case SovdEntityType::FUNCTION: // ros2_medkit extension: functions support additional collections via aggregation // (SOVD spec only defines data/operations for functions) caps.collections_ = { - ResourceCollection::DATA, - ResourceCollection::OPERATIONS, - ResourceCollection::CONFIGURATIONS, - ResourceCollection::FAULTS, - ResourceCollection::LOGS, - ResourceCollection::BULK_DATA, - ResourceCollection::CYCLIC_SUBSCRIPTIONS, + ResourceCollection::DATA, ResourceCollection::DATA_CATEGORIES, ResourceCollection::DATA_GROUPS, + ResourceCollection::OPERATIONS, ResourceCollection::CONFIGURATIONS, ResourceCollection::FAULTS, + ResourceCollection::LOGS, ResourceCollection::BULK_DATA, ResourceCollection::CYCLIC_SUBSCRIPTIONS, + ResourceCollection::TRIGGERS, }; caps.aggregated_collections_ = { ResourceCollection::DATA, ResourceCollection::OPERATIONS, ResourceCollection::CONFIGURATIONS, ResourceCollection::FAULTS, ResourceCollection::LOGS, }; - caps.resources_ = {"docs", "hosts", "depends-on"}; + // /depends-on is registered for components and apps only - a function + // that listed it handed clients a 404. + caps.resources_ = {"docs", "hosts"}; break; case SovdEntityType::UNKNOWN: diff --git a/src/ros2_medkit_gateway/src/core/models/entity_types.cpp b/src/ros2_medkit_gateway/src/core/models/entity_types.cpp index 429f8f44f..c55853010 100644 --- a/src/ros2_medkit_gateway/src/core/models/entity_types.cpp +++ b/src/ros2_medkit_gateway/src/core/models/entity_types.cpp @@ -44,8 +44,14 @@ std::string to_string(ResourceCollection col) { return "configurations"; case ResourceCollection::DATA: return "data"; + case ResourceCollection::DATA_CATEGORIES: + return "data-categories"; + case ResourceCollection::DATA_GROUPS: + return "data-groups"; case ResourceCollection::FAULTS: return "faults"; + case ResourceCollection::FAULT_TRIGGERS: + return "fault-triggers"; case ResourceCollection::OPERATIONS: return "operations"; case ResourceCollection::BULK_DATA: @@ -82,7 +88,10 @@ std::optional parse_resource_collection(const std::string & static const std::unordered_map mapping = { {"configurations", ResourceCollection::CONFIGURATIONS}, {"data", ResourceCollection::DATA}, + {"data-categories", ResourceCollection::DATA_CATEGORIES}, + {"data-groups", ResourceCollection::DATA_GROUPS}, {"faults", ResourceCollection::FAULTS}, + {"fault-triggers", ResourceCollection::FAULT_TRIGGERS}, {"operations", ResourceCollection::OPERATIONS}, {"bulk-data", ResourceCollection::BULK_DATA}, {"data-lists", ResourceCollection::DATA_LISTS}, diff --git a/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp b/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp index aebdcd716..0db3aabfa 100644 --- a/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp +++ b/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp @@ -214,6 +214,11 @@ RouteEntry & RouteEntry::accepts(const std::string & content_type, const nlohman return *this; } +RouteEntry & RouteEntry::body_example(nlohmann::json example) { + body_example_ = std::move(example); + return *this; +} + RouteEntry & RouteEntry::path_param(const std::string & name, const std::string & desc) { nlohmann::json param; param["name"] = name; @@ -932,26 +937,69 @@ nlohmann::json RouteRegistry::to_openapi_paths() const { } std::string pname = route.path_.substr(pos + 1, close - pos - 1); if (explicit_params.find(pname) == explicit_params.end()) { - // Auto-generate path parameter with description - static const std::unordered_map kParamDescriptions = { - {"area_id", "The area identifier"}, - {"component_id", "The component identifier"}, - {"app_id", "The app identifier"}, - {"function_id", "The function identifier"}, - {"data_id", "The data item identifier (ROS 2 topic name)"}, - {"operation_id", "The operation identifier"}, - {"execution_id", "The execution identifier"}, - {"config_id", "The configuration parameter identifier (ROS 2 parameter name)"}, - {"fault_code", "The fault code identifier"}, - {"subscription_id", "The cyclic subscription identifier"}, - {"category_id", "The bulk data category identifier"}, - {"file_id", "The bulk data file identifier"}, - {"update_id", "The software update identifier"}, - {"subarea_id", "The subarea identifier"}, - {"subcomponent_id", "The subcomponent identifier"}, - {"trigger_id", "The trigger identifier"}, - {"lock_id", "The lock identifier"}, - {"script_id", "The script identifier"}, + // Auto-generated path parameters. Every route carrying `{fault_code}` + // or `{config_id}` is registered from a loop over the four entity + // types, and none of them declares the parameter by hand, so this + // table is the one place either is described - which is why the + // length the handler enforces is a column here rather than a + // per-registration call that a new route could forget. + // + // `max_length` 0 means "no length constraint published". + // + // **Precondition, and it is on you to keep it.** This table is keyed + // by parameter *name*, so a bound written here is published on EVERY + // route carrying that template - it cannot say "this route's handler + // checks, that one's does not", and nothing verifies the mapping. So + // only fill it where *every* handler behind the template rejects an + // over-long value unconditionally. The first version of this table + // broke that: `delete_configuration` was the one verb of the three + // that never measured `config_id`, and the 512 was published on its + // routes anyway. That check now exists. + // + // Both rows are covered on every verb they publish to, so the + // precondition is tested rather than asserted: + // config_id - test_configuration_api.test.py + // ::test_06b_every_verb_rejects_an_oversized_config_id + // (GET / PUT / DELETE) + // fault_code - test_faults_api.test.py + // ::test_both_verbs_reject_an_oversized_fault_code + // (GET / DELETE) + // A new row needs its own, or the bound it publishes rests on a + // reading of the handlers rather than on a run. + struct PathParamInfo { + const char * description; + std::size_t max_length; + }; + static const std::unordered_map kParamDescriptions = { + {"area_id", {"The area identifier", 0}}, + {"component_id", {"The component identifier", 0}}, + {"app_id", {"The app identifier", 0}}, + {"function_id", {"The function identifier", 0}}, + {"data_id", {"The data item identifier (ROS 2 topic name)", 0}}, + {"operation_id", {"The operation identifier", 0}}, + {"execution_id", {"The execution identifier", 0}}, + // Not simply "the ROS 2 parameter name": on an entity backed by + // more than one node a write of a bare name is rejected with 400 + // (`config_handlers.cpp`, "Aggregated configuration requires + // app_id prefix"), and it is the prefixed form the list response + // hands back as each item's `id`. 512 = 256 (entity id) + 1 (`:`) + // + 256 (parameter name). + {"config_id", + {"The configuration parameter identifier. On an entity that aggregates several ROS 2 nodes this is " + "the `app_id:param_name` form the configurations list returns as each item's `id`, and a write of " + "a bare parameter name is rejected as ambiguous; on a single-node entity it is the bare parameter " + "name and a colon in it is part of the name. Maximum 512 characters.", + 512}}, + {"fault_code", {"The fault code identifier. Maximum 256 characters.", 256}}, + {"subscription_id", {"The cyclic subscription identifier", 0}}, + {"category_id", {"The bulk data category identifier", 0}}, + {"file_id", {"The bulk data file identifier", 0}}, + {"update_id", {"The software update identifier", 0}}, + {"subarea_id", {"The subarea identifier", 0}}, + {"subcomponent_id", {"The subcomponent identifier", 0}}, + {"trigger_id", {"The trigger identifier", 0}}, + {"lock_id", {"The lock identifier", 0}}, + {"script_id", {"The script identifier", 0}}, }; nlohmann::json param; param["name"] = pname; @@ -959,7 +1007,14 @@ nlohmann::json RouteRegistry::to_openapi_paths() const { param["required"] = true; param["schema"] = {{"type", "string"}}; auto desc_it = kParamDescriptions.find(pname); - param["description"] = (desc_it != kParamDescriptions.end()) ? desc_it->second : "The " + pname + " value"; + if (desc_it != kParamDescriptions.end()) { + param["description"] = desc_it->second.description; + if (desc_it->second.max_length > 0) { + param["schema"]["maxLength"] = desc_it->second.max_length; + } + } else { + param["description"] = "The " + pname + " value"; + } if (!operation.contains("parameters")) { operation["parameters"] = nlohmann::json::array(); } @@ -979,6 +1034,11 @@ nlohmann::json RouteRegistry::to_openapi_paths() const { if (!route.multipart_encoding_.empty()) { operation["requestBody"]["content"][ct]["encoding"] = route.multipart_encoding_; } + // Primary media type only: the extra encodings below are the same payload + // in another wire format, and a JSON example would not parse as one. + if (route.body_example_.has_value()) { + operation["requestBody"]["content"][ct]["examples"]["default"]["value"] = *route.body_example_; + } // Further encodings of the same payload (the auth endpoints' RFC 6749 // form encoding). Merged into the same content object, because they are // alternative representations of one body rather than separate bodies. @@ -1297,6 +1357,14 @@ std::vector RouteRegistry::validate_completeness() const { "that 2xx carries a media type no JSON Schema can describe"}); } + // body_example() attaches to a declared request body. On a route with none + // the example is dropped rather than minting a body the route does not + // take, which would tell a client to send a payload the handler ignores. + if (route.body_example_.has_value() && !route.request_body_.has_value()) { + issues.push_back({ValidationIssue::Severity::kError, route_id, + "body_example() was dropped: the route declares no request body to attach it to"}); + } + // Check response schemas for non-DELETE methods if (route.method_ != "delete") { bool has_success_response_with_schema = false; diff --git a/src/ros2_medkit_gateway/src/entity_freeze_frame_capture.cpp b/src/ros2_medkit_gateway/src/entity_freeze_frame_capture.cpp index 2e7df83ee..fe060826c 100644 --- a/src/ros2_medkit_gateway/src/entity_freeze_frame_capture.cpp +++ b/src/ros2_medkit_gateway/src/entity_freeze_frame_capture.cpp @@ -61,7 +61,7 @@ EntityFreezeFrameCapture::EntityFreezeFrameCapture(rclcpp::Node * node, ros2_com // pre-match window instead. auto slot = ros2_common::Ros2SubscriptionSlot::create_typed( exec, fault_events_topic, rclcpp::QoS(100).reliable(), - [this](std::shared_ptr msg) { + [this](const std::shared_ptr & msg) { on_fault_event(msg); }); if (!slot) { diff --git a/src/ros2_medkit_gateway/src/http/handlers/config_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/config_handlers.cpp index c90467ce6..4d0264be4 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/config_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/config_handlers.cpp @@ -643,6 +643,18 @@ http::Result ConfigHandlers::delete_configuration(const http::T } const std::string param_id = *param_id_result; + // Same bound, and in the same position, as `set_configuration`: validate the + // caller's own inputs before consulting the lock, because a malformed id is + // the client's bug rather than a contended resource. `read_param_id` checks + // only that the capture is present, so without this DELETE was the one verb + // in the family accepting an unbounded parameter id - and the OpenAPI + // document publishes `maxLength: 512` on every route carrying `{config_id}`, + // this one included. + if (param_id.empty() || param_id.length() > kMaxAggregatedParamIdLength) { + return tl::unexpected(make_error(400, ERR_INVALID_PARAMETER, "Invalid parameter ID", + json{{"details", "Parameter ID is empty or too long"}})); + } + auto entity_result = ctx_.validate_entity_for_route(req, entity_id); if (!entity_result) { return tl::unexpected(flatten_validator_error(entity_result.error())); 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 2aa6802dc..2a939d418 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp @@ -52,17 +52,14 @@ void set_x_medkit_external(dto::XMedkitApp & x_medkit, const std::optional } /// Check if a capability name is already present in the capabilities array -bool has_capability(const json & capabilities, const std::string & name) { - for (const auto & cap : capabilities) { - if (cap.contains("name") && cap["name"] == name) { - return true; - } - } - return false; +bool has_capability(const std::vector & capabilities, const std::string & name) { + return std::any_of(capabilities.begin(), capabilities.end(), [&name](const dto::EntityCapability & cap) { + return cap.name == name; + }); } -/// Append plugin-registered capabilities to a capabilities JSON array -void append_plugin_capabilities(json & capabilities, const std::string & entity_type_path, +/// Append plugin-registered capabilities to an entity's capabilities array +void append_plugin_capabilities(std::vector & capabilities, const std::string & entity_type_path, const std::string & entity_id, SovdEntityType entity_type, const GatewayNode * node) { auto * pmgr = node->get_plugin_manager(); if (!pmgr) { @@ -73,15 +70,22 @@ void append_plugin_capabilities(json & capabilities, const std::string & entity_ href_prefix.reserve(64); href_prefix.append("/api/v1/").append(entity_type_path).append("/").append(entity_id).append("/"); + auto add = [&capabilities, &href_prefix](const std::string & name) { + if (has_capability(capabilities, name)) { + return; + } + capabilities.push_back(dto::EntityCapability{name, href_prefix + name}); + }; + // Auto-add standard capabilities based on registered providers - if (pmgr->get_data_provider_for_entity(entity_id) && !has_capability(capabilities, "data")) { - capabilities.push_back({{"name", "data"}, {"href", href_prefix + "data"}}); + if (pmgr->get_data_provider_for_entity(entity_id)) { + add("data"); } - if (pmgr->get_operation_provider_for_entity(entity_id) && !has_capability(capabilities, "operations")) { - capabilities.push_back({{"name", "operations"}, {"href", href_prefix + "operations"}}); + if (pmgr->get_operation_provider_for_entity(entity_id)) { + add("operations"); } - if (pmgr->get_fault_provider_for_entity(entity_id) && !has_capability(capabilities, "faults")) { - capabilities.push_back({{"name", "faults"}, {"href", href_prefix + "faults"}}); + if (pmgr->get_fault_provider_for_entity(entity_id)) { + add("faults"); } // Plugin-registered custom capabilities (via PluginContext) @@ -92,16 +96,12 @@ void append_plugin_capabilities(json & capabilities, const std::string & entity_ // Type-level capabilities (registered for all entities of this type) for (const auto & cap_name : ctx->get_type_capabilities(entity_type)) { - if (!has_capability(capabilities, cap_name)) { - capabilities.push_back({{"name", cap_name}, {"href", href_prefix + cap_name}}); - } + add(cap_name); } // Entity-specific capabilities for (const auto & cap_name : ctx->get_entity_capabilities(entity_id)) { - if (!has_capability(capabilities, cap_name)) { - capabilities.push_back({{"name", cap_name}, {"href", href_prefix + cap_name}}); - } + add(cap_name); } } @@ -291,6 +291,8 @@ http::Result DiscoveryHandlers::get_area(const http::TypedReque detail.components = base_uri + "/components"; detail.contains = base_uri + "/contains"; detail.data = base_uri + "/data"; + detail.data_categories = base_uri + "/data-categories"; + detail.data_groups = base_uri + "/data-groups"; detail.operations = base_uri + "/operations"; detail.configurations = base_uri + "/configurations"; detail.faults = base_uri + "/faults"; @@ -299,8 +301,12 @@ http::Result DiscoveryHandlers::get_area(const http::TypedReque detail.triggers = base_uri + "/triggers"; using Cap = CapabilityBuilder::Capability; - std::vector caps = {Cap::SUBAREAS, Cap::CONTAINS, Cap::DATA, Cap::OPERATIONS, Cap::CONFIGURATIONS, - Cap::FAULTS, Cap::LOGS, Cap::BULK_DATA, Cap::TRIGGERS}; + // Cap::COMPONENTS matches `detail.components` above: `/areas/{area_id}/components` + // is registered for areas, so the capability array advertises it like the + // other three types advertise their relationship endpoints. + std::vector caps = {Cap::SUBAREAS, Cap::CONTAINS, Cap::COMPONENTS, Cap::DATA, + Cap::DATA_CATEGORIES, Cap::DATA_GROUPS, Cap::OPERATIONS, Cap::CONFIGURATIONS, + Cap::FAULTS, Cap::LOGS, Cap::BULK_DATA, Cap::TRIGGERS}; prune_plugin_unserved_capabilities(caps, area.id, ctx_.node()); auto area_caps = CapabilityBuilder::build_capabilities("areas", area.id, caps); append_plugin_capabilities(area_caps, "areas", area.id, SovdEntityType::AREA, ctx_.node()); @@ -662,6 +668,8 @@ http::Result DiscoveryHandlers::get_component(const http:: std::string base = "/api/v1/components/" + comp.id; detail.status = base + "/status"; detail.data = base + "/data"; + detail.data_categories = base + "/data-categories"; + detail.data_groups = base + "/data-groups"; detail.operations = base + "/operations"; detail.configurations = base + "/configurations"; detail.faults = base + "/faults"; @@ -676,6 +684,13 @@ http::Result DiscoveryHandlers::get_component(const http:: detail.scripts = base + "/scripts"; } + // Same gate as Cap::LOCKS below. The routes are registered either way; with + // `locking.enabled` off there is no LockManager and they answer 501, so the + // URI is emitted only when it leads to a collection that exists. + if (ctx_.node() && ctx_.node()->get_lock_manager()) { + detail.locks = base + "/locks"; + } + if (!comp.depends_on.empty()) { detail.depends_on = base + "/depends-on"; } @@ -737,9 +752,10 @@ http::Result DiscoveryHandlers::get_component(const http:: set_x_medkit_external(x_medkit_comp, comp.external); using Cap = CapabilityBuilder::Capability; - std::vector caps = { - Cap::STATUS, Cap::DATA, Cap::OPERATIONS, Cap::CONFIGURATIONS, Cap::FAULTS, Cap::LOGS, - Cap::SUBCOMPONENTS, Cap::HOSTS, Cap::BULK_DATA, Cap::CYCLIC_SUBSCRIPTIONS, Cap::TRIGGERS}; + std::vector caps = {Cap::STATUS, Cap::DATA, Cap::DATA_CATEGORIES, Cap::DATA_GROUPS, + Cap::OPERATIONS, Cap::CONFIGURATIONS, Cap::FAULTS, Cap::LOGS, + Cap::SUBCOMPONENTS, Cap::HOSTS, Cap::BULK_DATA, Cap::CYCLIC_SUBSCRIPTIONS, + Cap::TRIGGERS}; if (ctx_.node()->get_script_manager() && ctx_.node()->get_script_manager()->has_backend()) { caps.push_back(Cap::SCRIPTS); } @@ -1085,8 +1101,14 @@ http::Result DiscoveryHandlers::get_app(const http::TypedRequest std::string base_uri = "/api/v1/apps/" + app.id; detail.status = base_uri + "/status"; detail.data = base_uri + "/data"; + detail.data_categories = base_uri + "/data-categories"; + detail.data_groups = base_uri + "/data-groups"; detail.operations = base_uri + "/operations"; detail.configurations = base_uri + "/configurations"; + // Registered for `/apps` alone, and unconditionally: with no engine running + // the route answers 501, which is why it is advertised whether or not the + // feature is on. + detail.fault_triggers = base_uri + "/fault-triggers"; detail.faults = base_uri + "/faults"; detail.logs = base_uri + "/logs"; detail.bulk_data = base_uri + "/bulk-data"; @@ -1097,6 +1119,12 @@ http::Result DiscoveryHandlers::get_app(const http::TypedRequest detail.scripts = base_uri + "/scripts"; } + // Same gate as Cap::LOCKS below, and the same reasoning as the component + // handler: registered either way, 501 without a LockManager. + if (ctx_.node() && ctx_.node()->get_lock_manager()) { + detail.locks = base_uri + "/locks"; + } + if (!app.component_id.empty()) { detail.is_located_on = "/api/v1/components/" + app.component_id; detail.belongs_to = base_uri + "/belongs-to"; @@ -1107,8 +1135,18 @@ http::Result DiscoveryHandlers::get_app(const http::TypedRequest } using Cap = CapabilityBuilder::Capability; - std::vector caps = {Cap::STATUS, Cap::DATA, Cap::OPERATIONS, Cap::CONFIGURATIONS, Cap::FAULTS, - Cap::LOGS, Cap::BULK_DATA, Cap::CYCLIC_SUBSCRIPTIONS, Cap::TRIGGERS}; + std::vector caps = {Cap::STATUS, + Cap::DATA, + Cap::DATA_CATEGORIES, + Cap::DATA_GROUPS, + Cap::OPERATIONS, + Cap::CONFIGURATIONS, + Cap::FAULTS, + Cap::FAULT_TRIGGERS, + Cap::LOGS, + Cap::BULK_DATA, + Cap::CYCLIC_SUBSCRIPTIONS, + Cap::TRIGGERS}; // Relationship endpoints are gated the same way as the top-level URI keys // above so the three advertising surfaces (top-level URIs, `_links`, // `capabilities` array) describe the same set of available collections. @@ -1513,6 +1551,8 @@ http::Result DiscoveryHandlers::get_function(const http::Ty std::string base_uri = "/api/v1/functions/" + func.id; detail.hosts = base_uri + "/hosts"; detail.data = base_uri + "/data"; + detail.data_categories = base_uri + "/data-categories"; + detail.data_groups = base_uri + "/data-groups"; detail.operations = base_uri + "/operations"; detail.configurations = base_uri + "/configurations"; detail.faults = base_uri + "/faults"; @@ -1523,8 +1563,9 @@ http::Result DiscoveryHandlers::get_function(const http::Ty detail.triggers = base_uri + "/triggers"; using Cap = CapabilityBuilder::Capability; - std::vector caps = {Cap::HOSTS, Cap::DATA, Cap::OPERATIONS, Cap::CONFIGURATIONS, Cap::FAULTS, - Cap::LOGS, Cap::BULK_DATA, Cap::CYCLIC_SUBSCRIPTIONS, Cap::TRIGGERS}; + std::vector caps = { + Cap::HOSTS, Cap::DATA, Cap::DATA_CATEGORIES, Cap::DATA_GROUPS, Cap::OPERATIONS, Cap::CONFIGURATIONS, + Cap::FAULTS, Cap::LOGS, Cap::BULK_DATA, Cap::CYCLIC_SUBSCRIPTIONS, Cap::TRIGGERS}; prune_plugin_unserved_capabilities(caps, func.id, ctx_.node()); auto func_caps = CapabilityBuilder::build_capabilities("functions", func.id, caps); append_plugin_capabilities(func_caps, "functions", func.id, SovdEntityType::FUNCTION, ctx_.node()); diff --git a/src/ros2_medkit_gateway/src/http/rest_server.cpp b/src/ros2_medkit_gateway/src/http/rest_server.cpp index 291a3c3a7..c8109277e 100644 --- a/src/ros2_medkit_gateway/src/http/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/http/rest_server.cpp @@ -458,7 +458,11 @@ void RESTServer::setup_routes() { .description( "Body: data_name, operator (>, <, >=, <=, ==), threshold, fault_code, severity " "(INFO|WARNING|ERROR|CRITICAL), optional active. fault_code must be unique across " - "all rules (409 on duplicates).") + "all rules (409 on duplicates). The rule is level-triggered, not edge-triggered: while the value " + "stays past the threshold the engine re-reports the fault on every poll, so it confirms whatever " + "the fault manager's debounce threshold is and stays asserted until the value comes back. A poll " + "that cannot read the source neither reports nor clears - the rule holds whatever state it was in, " + "so a source that goes unreadable while the fault is asserted leaves it asserted.") .operation_id("createFaultTrigger") .path_param("app_id", "App (entity) to scope the rule to") .request_body("Fault-trigger rule definition") @@ -747,6 +751,12 @@ void RESTServer::setup_routes() { .tag("Operations") .summary(std::string("Start operation execution for ") + et.singular) .description("Starts a new execution. Returns 200 for synchronous, 202 for asynchronous operations.") + // `parameters` is what the handler reads first for both branches (the + // `goal` / `request` aliases are the fallbacks), and its contents are + // the ROS service request or action goal, whose shape comes from the + // operation - read it from GET .../operations/{operation_id}. The + // example shows the envelope, which is what every operation shares. + .body_example(nlohmann::json{{"parameters", nlohmann::json{{"target_temperature", 85.0}}}}) // OperationHandlers::create_execution -> validate_lock_access("operations"). .lock_guarded() .operation_id(std::string("execute") + capitalize(et.singular) + "Operation"); @@ -853,6 +863,9 @@ void RESTServer::setup_routes() { .tag("Configuration") .summary(std::string("Set configuration for ") + et.singular) .description(std::string("Sets a ROS 2 node parameter value for this ") + et.singular + ".") + // `data` is the preferred key; `value` is the legacy alias the handler + // falls back to. Showing `data` is what steers a new client onto it. + .body_example(nlohmann::json{{"data", 85.0}}) // ConfigHandlers::set_configuration -> validate_lock_access("configurations"). .lock_guarded() // Parameter failures reach the wire through `classify_parameter_error`, @@ -980,7 +993,15 @@ void RESTServer::setup_routes() { }) .tag("Logs") .summary(std::string("Query log entries for ") + et.singular) - .description(std::string("Queries application log entries for this ") + et.singular + ".") + .description( + std::string("Queries application log entries for this ") + et.singular + + ". Served, unless a LogProvider plugin is registered, from the gateway's own log buffer and filtered " + "by the entity's log configuration: the effective severity floor is the stricter of that " + "configuration and the request's own `severity`, so asking for `debug` against a configuration set " + "to `error` still returns errors and above; and the answer is then capped at the configuration's " + "`max_entries`, most recent kept - silently, with nothing on the response saying it was cut and no " + "way to page past it. Both of those filter the answer rather than the buffer. A registered " + "LogProvider serves the query itself, and neither applies to it.") // LogHandlers::get_logs -> fan_out_collection. .fan_out_aware() // All three log routes answer 503 when no LogManager is attached, or @@ -1185,6 +1206,13 @@ void RESTServer::setup_routes() { .tag("Triggers") .summary(std::string("Create trigger for ") + et.singular) .description(std::string("Creates a new event trigger for this ") + et.singular + ".") + .body_example(nlohmann::json{ + {"resource", "/api/v1/apps/temp_sensor/data/engine_temperature"}, + {"trigger_condition", + nlohmann::json{{"condition_type", "EnterRange"}, {"lower_bound", 90.0}, {"upper_bound", 120.0}}}, + {"path", "/data"}, + {"multishot", true}, + {"lifetime", 3600}}) .success_description("Trigger created") .gated_on(triggers_available, triggers_unavailable) // TriggerHandlers::post_trigger answers 503 when the trigger engine @@ -1340,8 +1368,16 @@ void RESTServer::setup_routes() { }) .tag("Locking") .summary(std::string("Acquire lock on ") + et.singular) - .description(std::string("Acquires an exclusive lock on this ") + et.singular + ".") + .description( + std::string("Acquires an exclusive lock on this ") + et.singular + + ", covering either the whole entity or the resource collections named in `scopes`. While it " + "holds, a write to a covered collection by any other client - including one sending no " + "`X-Client-Id` - is answered 409. Letting the lock reach its expiry is not the same as releasing " + "it: on expiry the gateway also deletes this entity's cyclic subscriptions, unless `scopes` was " + "given and left `cyclic-subscriptions` out. `DELETE /{entity}/locks/{lock_id}` never touches them.") .header_param("X-Client-Id", "Unique client identifier for lock ownership", true, client_id_schema) + .body_example( + nlohmann::json{{"lock_expiration", 300}, {"scopes", nlohmann::json::array({"data", "configurations"})}}) .success_description("Lock acquired") // 409 from LockManager::acquire, passed through verbatim by // post_lock: `lock-conflict` when the entity is already locked and @@ -1527,6 +1563,11 @@ void RESTServer::setup_routes() { // comes from a DTO descriptor, so the declaration and the fields the // handler reads are one edit apart, not two files apart. .request_body("Execution parameters") + // `now` is the only execution_type the shipped backend accepts; a + // ScriptProvider plugin defines its own vocabulary. `parameters` is + // the script's own shape - read `parameters_schema` from GET + // .../scripts/{script_id}. + .body_example(nlohmann::json{{"execution_type", "now"}, {"parameters", nlohmann::json{{"iterations", 3}}}}) .success_description("Execution started") // DefaultScriptProvider::start_execution -> ConcurrencyLimit .errors({429, 501}) @@ -1597,7 +1638,14 @@ void RESTServer::setup_routes() { }) .tag("Discovery") .summary("List entities contained in area") - .description("Lists all entities contained in this area.") + // Components only, not "all entities": the handler walks the area and + // its descendant subareas collecting `get_components_for_area` and + // returns ComponentListItem. Apps reached through those components + // are not in this answer. + .description( + "Lists the components in this area, including those in its subareas. Components only - the apps " + "those components host are reached through the component, and the subareas themselves through " + "`/areas/{area_id}/subareas`.") .operation_id("listAreaContains"); } @@ -1675,7 +1723,11 @@ void RESTServer::setup_routes() { }) .tag("Discovery") .summary("List function hosts") - .description("Lists components hosting this function.") + // The handler resolves the function's host ids through + // `cache.get_app(...)` and returns AppListItem with `/api/v1/apps/` + // hrefs, so the previous "components hosting this function" named + // the wrong entity type in the one place a client reads to find out. + .description("Lists the apps that host this function.") .operation_id("listFunctionHosts"); } @@ -1859,7 +1911,16 @@ void RESTServer::setup_routes() { }) .tag("Faults") .summary("Clear all faults globally") - .description("Clears all faults across the entire system.") + // "Across the entire system" was wrong twice over: the request never + // leaves this gateway, and an omitted `status` clears two of the four + // states rather than all of them. + .description( + "Clears the faults this gateway's own FaultManager holds. In an aggregated deployment the peers are " + "not touched, which the 204 reports through `X-Medkit-Local-Only`; clear those per peer. Which faults " + "go is the `status` filter's decision, and omitting it does not mean all of them - it means pending " + "and confirmed, leaving already-cleared and healed records in place. Faults on entities another client " + "has locked are skipped silently and the request still answers 204, with nothing on the response " + "naming what survived.") // A 204 cannot carry a body, so the "peers were not cleared" caveat this // route ships travels as a header - which makes declaring it the only way // a generated client can see it at all. @@ -2112,6 +2173,13 @@ void RESTServer::setup_routes() { }) .tag("Lifecycle") .summary(std::string("Request lifecycle transition '") + action + "'") + .description(std::string("Asks the entity's LifecycleProvider to perform the '") + action + + "' transition. The 202 says the request was accepted, not that the transition finished: it " + "carries no body, and the outcome is observed by polling `GET " + + base_lc + + "/status`, which the `Location` header names. Whether this transition is implemented at all is " + "the provider's decision - without one, or where the provider reports it unsupported, the route " + "answers 501.") .success_description("Lifecycle transition accepted") // 501: no LifecycleProvider, or the provider reports the transition // unsupported. 403 and 409 come from the same total mapper @@ -2132,6 +2200,15 @@ void RESTServer::setup_routes() { }) .tag("Lifecycle") .summary(std::string("Get ") + et_lc.second + " lifecycle status") + .description( + "Reports whether the entity is `ready` or `notReady`, and which lifecycle transitions can be " + "requested on it. A registered LifecycleProvider answers both; the transition fields it returns are " + "the transitions it implements, and they are the only place the document commits to a transition " + "being available on a given entity. Without a provider the gateway derives readiness itself and " + "returns no transition fields at all, which is the same entity for which every `PUT " + "/{entity}/status/{action}` answers 501. That derivation reads a managed ROS 2 node's own lifecycle " + "state where the node exposes one - `active` is the only ready state - and otherwise falls back to " + "the node being present in the ROS graph; a component is ready unless every app it hosts is offline.") // 501 when the provider reports the entity unsupported; 403/409 from // the same mapper - see the transition routes above. .errors({403, 409, 501}) diff --git a/src/ros2_medkit_gateway/src/openapi/capability_generator.cpp b/src/ros2_medkit_gateway/src/openapi/capability_generator.cpp index 21bccabc7..b3fbd56da 100644 --- a/src/ros2_medkit_gateway/src/openapi/capability_generator.cpp +++ b/src/ros2_medkit_gateway/src/openapi/capability_generator.cpp @@ -735,23 +735,49 @@ void CapabilityGenerator::add_resource_collection_paths(nlohmann::json & paths, paths[col_path] = path_builder.build_logs_collection(entity_path); add_log_configuration_path(paths, col_path, entity_path); break; - case ResourceCollection::DATA_LISTS: + // Registered for every entity type and unconditionally 501: the routes + // carry `.only_status(501, ...)`, so a 200 here would be a success a + // client can never observe. + case ResourceCollection::DATA_CATEGORIES: + case ResourceCollection::DATA_GROUPS: { + nlohmann::json not_implemented; + nlohmann::json get_op; + get_op["tags"] = nlohmann::json::array({"Data"}); + get_op["summary"] = "List " + to_string(col) + " for " + entity_id; + get_op["description"] = "Not implemented for ROS 2 - this route always answers 501."; + get_op["responses"]["501"] = nlohmann::json{{"$ref", "#/components/responses/GenericError"}}; + not_implemented["get"] = std::move(get_op); + paths[col_path] = std::move(not_implemented); + break; + } + + // Served, but with no dedicated builder in this file yet, so the listing + // is generic. `to_openapi_paths()` already holds each of these routes + // with its real statuses and schema; projecting the sub-document out of + // the registry is what removes the last of this hand-written half. case ResourceCollection::LOCKS: - case ResourceCollection::MODES: - case ResourceCollection::COMMUNICATION_LOGS: case ResourceCollection::TRIGGERS: case ResourceCollection::SCRIPTS: + case ResourceCollection::FAULT_TRIGGERS: { + nlohmann::json generic_path; + nlohmann::json get_op; + get_op["summary"] = "List " + to_string(col) + " for " + entity_id; + get_op["responses"]["200"]["description"] = "Successful response"; + generic_path["get"] = std::move(get_op); + paths[col_path] = std::move(generic_path); + break; + } + + // No entity-scoped route, so no entity type lists one of these and the + // loop cannot reach these labels. (`UPDATES` is in the SERVER list, and + // SERVER never reaches this function - it is called for the four entity + // types only.) They are spelled out rather than folded into a `default:` + // so that adding an enumerator fails the build here + // (-Werror=switch-enum) instead of silently acquiring a fabricated 200. + case ResourceCollection::DATA_LISTS: + case ResourceCollection::MODES: + case ResourceCollection::COMMUNICATION_LOGS: case ResourceCollection::UPDATES: - default: - // For other collections we don't have specific builders, add generic listing - { - nlohmann::json generic_path; - nlohmann::json get_op; - get_op["summary"] = "List " + to_string(col) + " for " + entity_id; - get_op["responses"]["200"]["description"] = "Successful response"; - generic_path["get"] = std::move(get_op); - paths[col_path] = std::move(generic_path); - } break; } } diff --git a/src/ros2_medkit_gateway/src/openapi/route_registry.hpp b/src/ros2_medkit_gateway/src/openapi/route_registry.hpp index f889b5a55..14aaea275 100644 --- a/src/ros2_medkit_gateway/src/openapi/route_registry.hpp +++ b/src/ros2_medkit_gateway/src/openapi/route_registry.hpp @@ -164,6 +164,30 @@ class RouteEntry { /// client library reaches for by default looks unsupported. RouteEntry & accepts(const std::string & content_type, const nlohmann::json & schema); + /// Publish a working request body a caller can copy out of the document. + /// + /// A `$ref` names the fields and their types; it does not say that + /// `trigger_condition` needs a `condition_type` key, or that `interval` is a + /// word rather than a number. The example is the part of the document a + /// caller can paste into a request unmodified, so it belongs to the request + /// body and lives nowhere else - not in the descriptor, where + /// `dto_fields` is `inline constexpr` and could only hold a string + /// literal per property, and not on the schema, where it would be a second + /// source for one concept. + /// + /// Emitted as `requestBody.content[].examples.default`. + /// Named rather than the singular `example` because that is the form a + /// second example could be added to without moving the first. Whether the + /// route has a body is read at emission, not here, so the fluent chain can + /// call this before or after `request_body()`; a route with no body at all + /// drops the example and `validate_completeness()` reports it rather than + /// minting a body the route does not take. + /// + /// Attaches to the primary body only. The extra encodings `accepts()` adds + /// are the same payload in another wire format, and a JSON example against + /// `application/x-www-form-urlencoded` would not parse. + RouteEntry & body_example(nlohmann::json example); + /// Typed response: the schema is a $ref to the DTO's component schema. template RouteEntry & response(int status_code, const std::string & desc) { @@ -458,6 +482,17 @@ class RouteEntry { /// Emitted beside the request body's schema. Empty for every other body. nlohmann::json multipart_encoding_{}; + /// Example payload set by body_example(), emitted under the primary body's + /// media type. Checked against `request_body_` at emission rather than at the + /// call, so the fluent chain can order the two either way. + /// + /// `optional`, not an empty-json sentinel: `nlohmann::json::empty()` is true + /// for `{}`, `[]` and `null`, so a call site legitimately documenting an + /// empty body would have its example dropped *and* skipped by the + /// validate_completeness() report - the silent loss that report exists to + /// make impossible. + std::optional body_example_; + std::vector parameters_; }; diff --git a/src/ros2_medkit_gateway/test/test_capability_builder.cpp b/src/ros2_medkit_gateway/test/test_capability_builder.cpp index 3f170d96c..4531852b7 100644 --- a/src/ros2_medkit_gateway/test/test_capability_builder.cpp +++ b/src/ros2_medkit_gateway/test/test_capability_builder.cpp @@ -28,11 +28,11 @@ TEST(CapabilityBuilderTest, BuildsCorrectCapabilities) { auto result = CapabilityBuilder::build_capabilities("components", "test-comp", caps); - ASSERT_EQ(result.size(), 2); - EXPECT_EQ(result[0]["name"], "data"); - EXPECT_EQ(result[0]["href"], "/api/v1/components/test-comp/data"); - EXPECT_EQ(result[1]["name"], "operations"); - EXPECT_EQ(result[1]["href"], "/api/v1/components/test-comp/operations"); + ASSERT_EQ(result.size(), 2u); + EXPECT_EQ(result[0].name, "data"); + EXPECT_EQ(result[0].href, "/api/v1/components/test-comp/data"); + EXPECT_EQ(result[1].name, "operations"); + EXPECT_EQ(result[1].href, "/api/v1/components/test-comp/operations"); } TEST(CapabilityBuilderTest, BuildsEmptyArray) { @@ -40,38 +40,64 @@ TEST(CapabilityBuilderTest, BuildsEmptyArray) { auto result = CapabilityBuilder::build_capabilities("areas", "test-area", caps); - EXPECT_TRUE(result.is_array()); - EXPECT_EQ(result.size(), 0); + EXPECT_TRUE(result.empty()); } TEST(CapabilityBuilderTest, BuildsAllCapabilities) { - std::vector caps = {Cap::DATA, Cap::OPERATIONS, Cap::CONFIGURATIONS, Cap::FAULTS, Cap::SUBAREAS, - Cap::SUBCOMPONENTS, Cap::RELATED_COMPONENTS, Cap::RELATED_APPS, Cap::HOSTS}; + std::vector caps = {Cap::DATA, Cap::OPERATIONS, Cap::CONFIGURATIONS, Cap::FAULTS, Cap::SUBAREAS, + Cap::SUBCOMPONENTS, Cap::COMPONENTS, Cap::CONTAINS, Cap::HOSTS}; auto result = CapabilityBuilder::build_capabilities("entities", "test-id", caps); - EXPECT_EQ(result.size(), 9); + EXPECT_EQ(result.size(), 9u); } TEST(CapabilityBuilderTest, CapabilityToNameReturnsCorrectStrings) { EXPECT_EQ(CapabilityBuilder::capability_to_name(Cap::DATA), "data"); + EXPECT_EQ(CapabilityBuilder::capability_to_name(Cap::DATA_CATEGORIES), "data-categories"); + EXPECT_EQ(CapabilityBuilder::capability_to_name(Cap::DATA_GROUPS), "data-groups"); + EXPECT_EQ(CapabilityBuilder::capability_to_name(Cap::FAULT_TRIGGERS), "fault-triggers"); EXPECT_EQ(CapabilityBuilder::capability_to_name(Cap::OPERATIONS), "operations"); EXPECT_EQ(CapabilityBuilder::capability_to_name(Cap::CONFIGURATIONS), "configurations"); EXPECT_EQ(CapabilityBuilder::capability_to_name(Cap::FAULTS), "faults"); EXPECT_EQ(CapabilityBuilder::capability_to_name(Cap::SUBAREAS), "subareas"); EXPECT_EQ(CapabilityBuilder::capability_to_name(Cap::SUBCOMPONENTS), "subcomponents"); - EXPECT_EQ(CapabilityBuilder::capability_to_name(Cap::RELATED_COMPONENTS), "related-components"); - EXPECT_EQ(CapabilityBuilder::capability_to_name(Cap::RELATED_APPS), "related-apps"); + // `/areas/{area_id}/components`, the route an area really serves. The + // `related-components` / `related-apps` enumerators this replaced named + // segments no entity type registers. + EXPECT_EQ(CapabilityBuilder::capability_to_name(Cap::COMPONENTS), "components"); + EXPECT_EQ(CapabilityBuilder::capability_to_name(Cap::CONTAINS), "contains"); EXPECT_EQ(CapabilityBuilder::capability_to_name(Cap::HOSTS), "hosts"); EXPECT_EQ(CapabilityBuilder::capability_to_name(Cap::LOGS), "logs"); EXPECT_EQ(CapabilityBuilder::capability_to_name(Cap::STATUS), "status"); } +// `-Werror=switch-enum` already refuses an enumerator with no arm, so what this +// adds is the case the compiler cannot see: an arm that *is* present but +// resolves to the `"unknown"` placeholder the `default:` returns, which would +// render an href of `/api/v1/{type}/{id}/unknown`. +// +// Scope: the name table only. An enumerator that no handler list uses is dead +// and invisible to this and every other check - see the header comment for why +// pinning that would cost a duplicated fact. +TEST(CapabilityBuilderTest, NamesEveryEnumerator) { + // Range end is the last enumerator; extend it when the enum grows - the + // static_assert below is what makes forgetting that fail to compile. + constexpr auto kLast = Cap::STATUS; + static_assert(static_cast(kLast) == 21, "Capability gained or lost an enumerator - update kLast"); + + for (int i = 0; i <= static_cast(kLast); ++i) { + const auto cap = static_cast(i); + EXPECT_NE(CapabilityBuilder::capability_to_name(cap), "unknown") << "enumerator " << i << " has no name arm"; + EXPECT_FALSE(CapabilityBuilder::capability_to_name(cap).empty()) << "enumerator " << i; + } +} + TEST(CapabilityBuilderTest, CapabilityToPathMatchesName) { // For all capabilities, the path segment matches the name EXPECT_EQ(CapabilityBuilder::capability_to_path(Cap::DATA), CapabilityBuilder::capability_to_name(Cap::DATA)); - EXPECT_EQ(CapabilityBuilder::capability_to_path(Cap::RELATED_COMPONENTS), - CapabilityBuilder::capability_to_name(Cap::RELATED_COMPONENTS)); + EXPECT_EQ(CapabilityBuilder::capability_to_path(Cap::COMPONENTS), + CapabilityBuilder::capability_to_name(Cap::COMPONENTS)); EXPECT_EQ(CapabilityBuilder::capability_to_path(Cap::LOGS), CapabilityBuilder::capability_to_name(Cap::LOGS)); } @@ -83,10 +109,10 @@ TEST(CapabilityBuilderTest, BuildsForDifferentEntityTypes) { auto apps_result = CapabilityBuilder::build_capabilities("apps", "app1", caps); auto functions_result = CapabilityBuilder::build_capabilities("functions", "f1", caps); - EXPECT_EQ(areas_result[0]["href"], "/api/v1/areas/a1/data"); - EXPECT_EQ(components_result[0]["href"], "/api/v1/components/c1/data"); - EXPECT_EQ(apps_result[0]["href"], "/api/v1/apps/app1/data"); - EXPECT_EQ(functions_result[0]["href"], "/api/v1/functions/f1/data"); + EXPECT_EQ(areas_result[0].href, "/api/v1/areas/a1/data"); + EXPECT_EQ(components_result[0].href, "/api/v1/components/c1/data"); + EXPECT_EQ(apps_result[0].href, "/api/v1/apps/app1/data"); + EXPECT_EQ(functions_result[0].href, "/api/v1/functions/f1/data"); } // ============================================================================= diff --git a/src/ros2_medkit_gateway/test/test_capability_generator.cpp b/src/ros2_medkit_gateway/test/test_capability_generator.cpp index e082250ab..f6baba27b 100644 --- a/src/ros2_medkit_gateway/test/test_capability_generator.cpp +++ b/src/ros2_medkit_gateway/test/test_capability_generator.cpp @@ -328,6 +328,57 @@ TEST_F(CapabilityGeneratorTest, GenerateNonexistentComponentReturnsNullopt) { EXPECT_FALSE(result.has_value()); } +// The entity sub-document is the second surface that advertises an entity's +// collections - the `capabilities` array on the detail response is the first. +// It is driven by `EntityCapabilities::for_type`, and `/data-lists`, `/modes` +// and `/updates` used to reach it and be published with a fabricated 200: no +// entity type registers them. +// +// What this pins is the generator's own half of the guarantee. The switch in +// `add_resource_collection_paths` is exhaustive and gives the four unrouted +// collections an empty arm, so even a capability list that named one again +// would produce no path here - `EntityCapabilities.NoEntityAdvertises...` +// covers the list itself, and the two together are what keep both surfaces +// clean. +TEST_F(CapabilityGeneratorTest, EntityDocumentOffersNoCollectionWithoutARoute) { + const auto & cache = node_->get_thread_safe_cache(); + auto components = cache.get_components(); + ASSERT_FALSE(components.empty()) << "no component discovered - the assertions below would prove nothing"; + const std::string entity_path = "/components/" + components.front().id; + + auto result = generator_->generate(entity_path); + ASSERT_TRUE(result.has_value()); + + for (const auto * phantom : {"/data-lists", "/modes", "/updates", "/communication-logs"}) { + EXPECT_FALSE(result->at("paths").contains(entity_path + phantom)) << phantom; + } + // ... while the collections the loop does register are still offered. + for (const auto * served : {"/data", "/data-categories", "/data-groups", "/operations", "/configurations", "/faults", + "/logs", "/bulk-data", "/triggers", "/cyclic-subscriptions"}) { + EXPECT_TRUE(result->at("paths").contains(entity_path + served)) << served; + } +} + +// data-categories and data-groups are registered with `.only_status(501, ...)`, +// so the sub-document has to say 501 and nothing else. The generic listing it +// used to fall through to declared a 200 no client can ever observe. +TEST_F(CapabilityGeneratorTest, NotImplementedCollectionsDeclareOnly501) { + const auto & cache = node_->get_thread_safe_cache(); + auto components = cache.get_components(); + ASSERT_FALSE(components.empty()) << "no component discovered - the assertions below would prove nothing"; + const std::string entity_path = "/components/" + components.front().id; + + auto result = generator_->generate(entity_path); + ASSERT_TRUE(result.has_value()); + + for (const auto * col : {"/data-categories", "/data-groups"}) { + const auto & op = result->at("paths").at(entity_path + col).at("get"); + EXPECT_EQ(op.at("responses").size(), 1u) << col; + EXPECT_TRUE(op.at("responses").contains("501")) << col; + EXPECT_EQ(op.at("responses").at("501").at("$ref"), "#/components/responses/GenericError") << col; + } +} + // ============================================================================= // Resource collection - validates entity existence // ============================================================================= diff --git a/src/ros2_medkit_gateway/test/test_discovery_models.cpp b/src/ros2_medkit_gateway/test/test_discovery_models.cpp index 23d0b574f..418f7b1e8 100644 --- a/src/ros2_medkit_gateway/test/test_discovery_models.cpp +++ b/src/ros2_medkit_gateway/test/test_discovery_models.cpp @@ -105,7 +105,11 @@ TEST_F(AreaModelTest, ToCapabilities_ContainsSubResources) { EXPECT_TRUE(j.contains("x-medkit")); EXPECT_EQ(j["x-medkit"]["entityType"], "Area"); EXPECT_TRUE(j.contains("subareas")); - EXPECT_TRUE(j.contains("related-components")); + // `/areas/{area_id}/components` is the registered route. The former + // "related-components" key named a segment no entity type serves, so a green + // assertion on it read as evidence that the segment was real. + EXPECT_TRUE(j.contains("components")); + EXPECT_FALSE(j.contains("related-components")); } // ============================================================================= @@ -172,6 +176,10 @@ TEST_F(ComponentModelTest, ToCapabilities_ContainsConfigurationsForNodes) { // Node-based components should have configurations capability EXPECT_TRUE(j.contains("configurations")); EXPECT_EQ(j["configurations"], "http://localhost:8080/api/v1/components/motor_controller/configurations"); + + // `/hosts` is registered for components; "related-apps" named no route. + EXPECT_TRUE(j.contains("hosts")); + EXPECT_FALSE(j.contains("related-apps")); } TEST_F(ComponentModelTest, ToJson_OmitsExternalWhenUnsetOrFalse) { 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 73bb67546..91c655e3a 100644 --- a/src/ros2_medkit_gateway/test/test_entity_resource_model.cpp +++ b/src/ros2_medkit_gateway/test/test_entity_resource_model.cpp @@ -95,7 +95,10 @@ TEST(EntityTypes, ToStringReturnsCorrectValues) { TEST(EntityTypes, ResourceCollectionToString) { EXPECT_EQ(to_string(ResourceCollection::CONFIGURATIONS), "configurations"); EXPECT_EQ(to_string(ResourceCollection::DATA), "data"); + EXPECT_EQ(to_string(ResourceCollection::DATA_CATEGORIES), "data-categories"); + EXPECT_EQ(to_string(ResourceCollection::DATA_GROUPS), "data-groups"); EXPECT_EQ(to_string(ResourceCollection::FAULTS), "faults"); + EXPECT_EQ(to_string(ResourceCollection::FAULT_TRIGGERS), "fault-triggers"); EXPECT_EQ(to_string(ResourceCollection::OPERATIONS), "operations"); EXPECT_EQ(to_string(ResourceCollection::BULK_DATA), "bulk-data"); EXPECT_EQ(to_string(ResourceCollection::DATA_LISTS), "data-lists"); @@ -106,10 +109,24 @@ TEST(EntityTypes, ParseResourceCollection) { ASSERT_TRUE(configs.has_value()); EXPECT_EQ(*configs, ResourceCollection::CONFIGURATIONS); + // Recognised even though no entity lists it as a capability: parsing a path + // segment and advertising a collection are separate questions. auto data_lists = parse_resource_collection("data-lists"); ASSERT_TRUE(data_lists.has_value()); EXPECT_EQ(*data_lists, ResourceCollection::DATA_LISTS); + auto categories = parse_resource_collection("data-categories"); + ASSERT_TRUE(categories.has_value()); + EXPECT_EQ(*categories, ResourceCollection::DATA_CATEGORIES); + + auto groups = parse_resource_collection("data-groups"); + ASSERT_TRUE(groups.has_value()); + EXPECT_EQ(*groups, ResourceCollection::DATA_GROUPS); + + auto fault_triggers = parse_resource_collection("fault-triggers"); + ASSERT_TRUE(fault_triggers.has_value()); + EXPECT_EQ(*fault_triggers, ResourceCollection::FAULT_TRIGGERS); + auto invalid = parse_resource_collection("invalid"); EXPECT_FALSE(invalid.has_value()); } @@ -127,12 +144,75 @@ TEST(EntityTypes, ParseEntityType) { // EntityCapabilities Tests // ============================================================================ -TEST(EntityCapabilities, ServerSupportsAllCollections) { +TEST(EntityCapabilities, ServerSupportsOnlyTheRootMountedCollections) { + // `/faults` and `/updates` are the only collections mounted at the API root. + // Everything else in the enum is entity-scoped, so listing it here would put + // an href into a 404 on the server capability surface. auto caps = EntityCapabilities::for_type(SovdEntityType::SERVER); - 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::UPDATES)); + EXPECT_FALSE(caps.supports_collection(ResourceCollection::CONFIGURATIONS)); + EXPECT_FALSE(caps.supports_collection(ResourceCollection::DATA)); + EXPECT_FALSE(caps.supports_collection(ResourceCollection::OPERATIONS)); + EXPECT_FALSE(caps.supports_collection(ResourceCollection::LOGS)); + EXPECT_FALSE(caps.supports_collection(ResourceCollection::LOCKS)); + EXPECT_FALSE(caps.supports_resource("logs")); + EXPECT_FALSE(caps.supports_resource("depends-on")); +} + +// The three collections with no entity-scoped route anywhere. This is the +// invariant the phantom hrefs violated: `/x/{id}/data-lists`, `/x/{id}/modes` +// and `/x/{id}/updates` are registered for no entity type, so no capability +// list may name them. +TEST(EntityCapabilities, NoEntityAdvertisesACollectionWithoutARoute) { + for (auto type : {SovdEntityType::AREA, SovdEntityType::COMPONENT, SovdEntityType::APP, SovdEntityType::FUNCTION}) { + auto caps = EntityCapabilities::for_type(type); + EXPECT_FALSE(caps.supports_collection(ResourceCollection::DATA_LISTS)) << to_string(type); + EXPECT_FALSE(caps.supports_collection(ResourceCollection::MODES)) << to_string(type); + EXPECT_FALSE(caps.supports_collection(ResourceCollection::UPDATES)) << to_string(type); + EXPECT_FALSE(caps.supports_collection(ResourceCollection::COMMUNICATION_LOGS)) << to_string(type); + } +} + +// The four-entity-type loop in rest_server.cpp registers these for every type. +TEST(EntityCapabilities, EveryEntityTypeAdvertisesTheUnconditionalCollections) { + for (auto type : {SovdEntityType::AREA, SovdEntityType::COMPONENT, SovdEntityType::APP, SovdEntityType::FUNCTION}) { + auto caps = EntityCapabilities::for_type(type); + for (auto col : {ResourceCollection::DATA, ResourceCollection::DATA_CATEGORIES, ResourceCollection::DATA_GROUPS, + ResourceCollection::OPERATIONS, ResourceCollection::CONFIGURATIONS, ResourceCollection::FAULTS, + ResourceCollection::LOGS, ResourceCollection::BULK_DATA, ResourceCollection::TRIGGERS}) { + EXPECT_TRUE(caps.supports_collection(col)) << to_string(type) << " / " << to_string(col); + } + } +} + +// Routes registered behind an entity-type check: locks and scripts for +// components and apps, cyclic-subscriptions for everything but areas, and +// fault-triggers for apps alone. +TEST(EntityCapabilities, TypeGatedCollectionsMatchTheirRegistrations) { + auto area = EntityCapabilities::for_type(SovdEntityType::AREA); + auto component = EntityCapabilities::for_type(SovdEntityType::COMPONENT); + auto app = EntityCapabilities::for_type(SovdEntityType::APP); + auto function = EntityCapabilities::for_type(SovdEntityType::FUNCTION); + + EXPECT_FALSE(area.supports_collection(ResourceCollection::CYCLIC_SUBSCRIPTIONS)); + EXPECT_TRUE(component.supports_collection(ResourceCollection::CYCLIC_SUBSCRIPTIONS)); + EXPECT_TRUE(app.supports_collection(ResourceCollection::CYCLIC_SUBSCRIPTIONS)); + EXPECT_TRUE(function.supports_collection(ResourceCollection::CYCLIC_SUBSCRIPTIONS)); + + for (const auto & caps : {component, app}) { + EXPECT_TRUE(caps.supports_collection(ResourceCollection::LOCKS)); + EXPECT_TRUE(caps.supports_collection(ResourceCollection::SCRIPTS)); + } + for (const auto & caps : {area, function}) { + EXPECT_FALSE(caps.supports_collection(ResourceCollection::LOCKS)); + EXPECT_FALSE(caps.supports_collection(ResourceCollection::SCRIPTS)); + } + + EXPECT_TRUE(app.supports_collection(ResourceCollection::FAULT_TRIGGERS)); + EXPECT_FALSE(component.supports_collection(ResourceCollection::FAULT_TRIGGERS)); + EXPECT_FALSE(area.supports_collection(ResourceCollection::FAULT_TRIGGERS)); + EXPECT_FALSE(function.supports_collection(ResourceCollection::FAULT_TRIGGERS)); } TEST(EntityCapabilities, AreaSupportsCollectionsViaAggregation) { @@ -158,6 +238,9 @@ TEST(EntityCapabilities, AreaSupportsContains) { EXPECT_TRUE(caps.supports_resource("contains")); EXPECT_TRUE(caps.supports_resource("subareas")); EXPECT_TRUE(caps.supports_resource("docs")); + // The route is /areas/{area_id}/components; "related-components" named none. + EXPECT_TRUE(caps.supports_resource("components")); + EXPECT_FALSE(caps.supports_resource("related-components")); } TEST(EntityCapabilities, ComponentSupportsOperations) { @@ -180,6 +263,15 @@ TEST(EntityCapabilities, AppSupportsBelongsTo) { EXPECT_TRUE(caps.supports_resource("belongs-to")); } +TEST(EntityCapabilities, FunctionHasNoDependsOnResource) { + // /depends-on is registered for components and apps only. + auto caps = EntityCapabilities::for_type(SovdEntityType::FUNCTION); + EXPECT_TRUE(caps.supports_resource("hosts")); + EXPECT_FALSE(caps.supports_resource("depends-on")); + EXPECT_TRUE(EntityCapabilities::for_type(SovdEntityType::COMPONENT).supports_resource("depends-on")); + EXPECT_TRUE(EntityCapabilities::for_type(SovdEntityType::APP).supports_resource("depends-on")); +} + TEST(EntityCapabilities, FunctionAggregatesCollections) { auto caps = EntityCapabilities::for_type(SovdEntityType::FUNCTION); // ros2_medkit extension: functions support additional collections via aggregation diff --git a/src/ros2_medkit_gateway/test/test_route_registry.cpp b/src/ros2_medkit_gateway/test/test_route_registry.cpp index af1ab4495..a829ef39e 100644 --- a/src/ros2_medkit_gateway/test/test_route_registry.cpp +++ b/src/ros2_medkit_gateway/test/test_route_registry.cpp @@ -1480,3 +1480,79 @@ TEST_F(RouteRegistryTest, TheRangeRejectionSurvivesOnlyStatus) { EXPECT_TRUE(responses.contains("501")); EXPECT_FALSE(responses.contains("400")) << "only_status must still suppress the blanket handler statuses"; } + +// ============================================================================= +// body_example +// ============================================================================= + +TEST_F(RouteRegistryTest, BodyExampleIsPublishedUnderThePrimaryMediaType) { + // The example is what a caller pastes into a request, so it has to land in + // the request body's own content entry - beside the schema, not in place of + // it. `examples.default.value` is the OpenAPI Example Object form. + seed_post(registry_, "/things").tag("Test").summary("Create thing").body_example(nlohmann::json{{"value", "sample"}}); + + const auto body = registry_.to_openapi_paths()["/things"]["post"]["requestBody"]; + const auto & media = body["content"]["application/json"]; + EXPECT_TRUE(media.contains("schema")) << "the example must not displace the schema"; + ASSERT_TRUE(media.contains("examples")); + EXPECT_EQ(media["examples"]["default"]["value"], (nlohmann::json{{"value", "sample"}})); +} + +TEST_F(RouteRegistryTest, BodyExampleAppliesBeforeTheRequestBodyItAttachesTo) { + // Whether the route has a body is read at emission, not at the call, so a + // fluent chain that sets the example first still publishes it. Without that + // the correctness of a registration would depend on builder-call order, + // which nothing at the call site signals. + registry_ + .post( + "/manual", std::function(TypedRequest)>( + [](TypedRequest) -> Result { + return ros2_medkit_gateway::http::NoContent{}; + })) + .tag("Test") + .summary("Manual") + .body_example(nlohmann::json{{"value", "sample"}}) + .request_body("Payload", nlohmann::json{{"type", "object"}}); + + const auto media = registry_.to_openapi_paths()["/manual"]["post"]["requestBody"]["content"]["application/json"]; + ASSERT_TRUE(media.contains("examples")); + EXPECT_EQ(media["examples"]["default"]["value"], (nlohmann::json{{"value", "sample"}})); + EXPECT_FALSE(has_error_mentioning(registry_, "body_example()")); +} + +TEST_F(RouteRegistryTest, AnEmptyBodyExampleIsStillPublished) { + // The three cases around this one all pass an object with a key in it, so + // they pass under an empty-json sentinel too. This is the case the + // `std::optional` member exists for: `json::empty()` is true + // for `{}`, `[]` and `null`, so a route documenting "send an empty object" + // would have had its example dropped *and* skipped by the + // validate_completeness() report - lost with nothing said, which is the one + // outcome that report is there to prevent. + seed_post(registry_, "/things").tag("Test").summary("Create thing").body_example(nlohmann::json::object()); + + const auto media = registry_.to_openapi_paths()["/things"]["post"]["requestBody"]["content"]["application/json"]; + ASSERT_TRUE(media.contains("examples")); + EXPECT_EQ(media["examples"]["default"]["value"], nlohmann::json::object()); + EXPECT_FALSE(has_error_mentioning(registry_, "body_example()")); +} + +TEST_F(RouteRegistryTest, AnEmptyBodyExampleOnABodylessRouteIsStillReported) { + // The other half of the same fix: an empty example must not become invisible + // to the completeness check either, or a miscall on a body-less route would + // pass silently precisely when the example carries no content to notice. + seed_get(registry_, "/things").tag("Test").summary("List things").body_example(nlohmann::json::object()); + + EXPECT_FALSE(registry_.to_openapi_paths()["/things"]["get"].contains("requestBody")); + EXPECT_TRUE(has_error_mentioning(registry_, "body_example()")); +} + +TEST_F(RouteRegistryTest, BodyExampleOnABodylessRouteIsReportedRatherThanMintingABody) { + // A GET takes no payload. Emitting the example anyway would create a + // `requestBody` the route does not read and tell a generated client to send + // one, so the call is dropped - and dropping it silently is the failure mode + // validate_completeness() exists to prevent. + seed_get(registry_, "/things").tag("Test").summary("List things").body_example(nlohmann::json{{"value", "sample"}}); + + EXPECT_FALSE(registry_.to_openapi_paths()["/things"]["get"].contains("requestBody")); + EXPECT_TRUE(has_error_mentioning(registry_, "body_example()")); +} diff --git a/src/ros2_medkit_gateway/test/test_schema_builder.cpp b/src/ros2_medkit_gateway/test/test_schema_builder.cpp index e32218777..b37abda94 100644 --- a/src/ros2_medkit_gateway/test/test_schema_builder.cpp +++ b/src/ros2_medkit_gateway/test/test_schema_builder.cpp @@ -653,9 +653,18 @@ TEST(SchemaBuilderStaticTest, DataWriteRequestSchemaComesFromDto) { // @verifies REQ_INTEROP_002 TEST(SchemaBuilderStaticTest, ExecutionUpdateRequestSchemaComesFromDto) { // ExecutionUpdateRequest is now generated from the DTO; verify via component_schemas(). - // capability is a plain string field (no enum constraint) so that custom - // x-vendor-* capabilities pass parse_body and reach the handler's own - // validation logic. + // + // capability is a plain string field, and the absence of the enum is the + // assertion. `OperationHandlers::update_execution` answers an unrecognised + // value with a 400 carrying `supported_capabilities` for the backend the + // caller is talking to; a schema-level enum would make `JsonReader` reject + // the request first and replace that with a generic body-validation error. + // The vocabulary is published as prose on the field instead. + // + // The previous note here justified this by custom `x-vendor-*` capabilities + // "reaching the handler's own validation logic". There is no such branch: + // update_execution has no plugin delegation, and every value outside + // stop/execute/freeze/reset lands in the same 400. const auto & schemas = SchemaBuilder::component_schemas(); ASSERT_TRUE(schemas.count("ExecutionUpdateRequest") > 0); const auto & schema = schemas.at("ExecutionUpdateRequest"); diff --git a/src/ros2_medkit_integration_tests/test/features/test_configuration_api.test.py b/src/ros2_medkit_integration_tests/test/features/test_configuration_api.test.py index d6c9b7978..f79cf2a08 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_configuration_api.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_configuration_api.test.py @@ -270,6 +270,39 @@ def test_06_configuration_nonexistent_parameter(self): self.assertIn('parameters', data) self.assertEqual(data['parameters'].get('id'), 'nonexistent_param') + def test_06b_every_verb_rejects_an_oversized_config_id(self): + """GET, PUT and DELETE all enforce the 512-character `config_id` bound. + + The OpenAPI document publishes `maxLength: 512` on every route carrying + `{config_id}`, from one table keyed by the parameter name - so it cannot + distinguish a route whose handler checks from one whose handler does + not. DELETE was that route: it read the capture and never measured it, + while GET and PUT both rejected. All three verbs are driven here so the + published bound is backed on each of them rather than on two out of + three. The `fault_code` half of the same table is covered by + `test_faults_api.test.py`. + + @verifies REQ_INTEROP_049 + @verifies REQ_INTEROP_050 + @verifies REQ_INTEROP_052 + """ + oversized = 'p' * 513 + base = f'{self.BASE_URL}/apps/temp_sensor/configurations/{oversized}' + for verb, call in ( + ('GET', lambda: requests.get(base, timeout=10)), + ('PUT', lambda: requests.put(base, json={'data': 1.0}, timeout=10)), + ('DELETE', lambda: requests.delete(base, timeout=10)), + ): + with self.subTest(verb=verb): + response = call() + self.assertEqual(response.status_code, 400, f'{verb}: {response.text}') + self.assertIn('error_code', response.json()) + + # A 512-character id is inside the bound, so the rejection above is the + # length check and not the route refusing every long-ish name. + at_limit = f'{self.BASE_URL}/apps/temp_sensor/configurations/{"p" * 512}' + self.assertEqual(requests.get(at_limit, timeout=10).status_code, 404) + def test_07_set_configuration_missing_value(self): """PUT configurations/{param_name} returns 400 when value missing. diff --git a/src/ros2_medkit_integration_tests/test/features/test_faults_api.test.py b/src/ros2_medkit_integration_tests/test/features/test_faults_api.test.py index afaf1b117..be5a3ae19 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_faults_api.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_faults_api.test.py @@ -135,6 +135,51 @@ def test_get_nonexistent_fault(self): self.assertIn('parameters', data) self.assertEqual(data['parameters'].get('fault_code'), 'NONEXISTENT_FAULT') + def test_both_verbs_reject_an_oversized_fault_code(self): + """GET and DELETE both enforce the 256-character `fault_code` bound. + + The OpenAPI document publishes `maxLength: 256` on every route carrying + `{fault_code}`, from one table keyed by the parameter name - so it + cannot distinguish a route whose handler checks from one whose handler + does not, and a bound belongs there only when every handler behind the + template rejects. Both verbs are driven so that precondition is tested + rather than assumed; the `config_id` half of the same table is covered + by `test_configuration_api.test.py`. + + No lock is taken here on purpose. `clear_fault` measures the code + *after* `validate_lock_access`, so a competing lock would answer 409 + before the length check is reached - a real ordering difference from + the configuration handlers, and not what this test is pinning. + + @verifies REQ_INTEROP_013 + @verifies REQ_INTEROP_015 + """ + oversized = 'F' * 257 + url = f'{self.BASE_URL}/apps/lidar_sensor/faults/{oversized}' + for verb, call in ( + ('GET', lambda: requests.get(url, timeout=10)), + ('DELETE', lambda: requests.delete(url, timeout=10)), + ): + with self.subTest(verb=verb): + response = call() + self.assertEqual(response.status_code, 400, f'{verb}: {response.text}') + self.assertIn('error_code', response.json()) + + # Control: a code the route serves normally still reaches the store, so + # the rejections above are the length gate rather than the route + # refusing every long-ish code. + # + # Deliberately well short of 256 rather than at it. The gateway's gate + # is 256, but the fault manager applies a stricter 128 + # (`kMaxFaultCodeLength`, fault_manager_node.cpp:41) and the gateway + # turns that refusal into 503, because its 404-vs-503 split is a + # substring match for "not found" on the store's message. So 129..256 + # answers 503 today even though the document publishes 256 - recorded, + # not pinned here, because reconciling the two bounds is a contract + # decision spanning both nodes. + inside = f'{self.BASE_URL}/apps/lidar_sensor/faults/{"F" * 64}' + self.assertEqual(requests.get(inside, timeout=10).status_code, 404) + def test_list_all_faults_globally(self): """GET /faults returns all faults across the system. diff --git a/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py b/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py index db7f435c4..b3472c2b2 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py @@ -95,6 +95,36 @@ 'deleteComponentBulkData', 'deleteAppBulkData', } +# Properties whose meaning a client cannot recover from the property name and +# the JSON type alone, so the document has to spell it out. Not a list of +# "everything important" - it is the set this file asserts on, and it grows by +# hand when a field turns out to need prose. +# +# Every name was read out of the DTO header before being written here: +# `dto/locks.hpp` (AcquireLockRequest), `dto/health.hpp` (Health, whose +# dto_name is `HealthStatus`), `dto/triggers.hpp` (TriggerCreateRequest) and +# `dto/logs.hpp` (LogConfiguration, which has exactly two members). +LOAD_BEARING = { + 'AcquireLockRequest': ['lock_expiration', 'scopes', 'break_lock'], + 'HealthStatus': ['timestamp'], + 'TriggerCreateRequest': ['trigger_condition', 'protocol', 'lifetime', 'path'], + 'LogConfiguration': ['severity_filter', 'max_entries'], +} + +# Operations whose request body carries a copyable example. +# +# One operationId per body-carrying route family, not all of them: the four +# entity types are registered from a single call site in a loop, so the App +# variant is what proves the call site has the example. A family whose App +# variant lost its `.body_example(...)` turns this red. +EXAMPLE_BODIES = { + 'createAppTrigger', + 'acquireAppLock', + 'executeAppOperation', + 'setAppConfiguration', + 'startAppScriptExecution', +} + _SCRIPTS_DIR = tempfile.mkdtemp(prefix='medkit-contract-scripts-') PYTHON_SCRIPT = '#!/usr/bin/env python3\nimport json\nprint(json.dumps({"result": "ok"}))\n' @@ -159,6 +189,63 @@ def test_every_operation_is_identified(self): op_id, seen, f'{where}: operationId collides with {seen.get(op_id)}') seen[op_id] = where + def test_load_bearing_properties_are_described(self): + """The properties in LOAD_BEARING carry prose, on whichever branch holds them. + + Scoped to that list - it says nothing about the rest of the document. + An optional member renders as ``{anyOf: [, {type: null}]}``, so + the description can sit on the property or on the non-null branch, and + both are accepted: which one a property uses is a consequence of its + C++ type, not a fact a client cares about. + """ + schemas = self.spec()['components']['schemas'] + offenders = [] + for name, props in LOAD_BEARING.items(): + schema = schemas.get(name) + self.assertIsNotNone(schema, f'{name} missing from components/schemas') + for prop in props: + node = schema.get('properties', {}).get(prop) + self.assertIsNotNone(node, f'{name}.{prop} missing') + described = node.get('description') or any( + b.get('description') for b in node.get('anyOf', []) + if isinstance(b, dict)) + if not described: + offenders.append(f'{name}.{prop}') + self.assertEqual(offenders, [], f'undocumented: {offenders}') + + def test_every_operation_has_a_description(self): + """No operation ships without prose. + + ``summary`` is a one-liner a picker shows; ``description`` is where the + behaviour a caller has to know about goes. An operation with only the + former is one whose caveats live in the source and nowhere a client can + read them. + """ + missing = [f'{m.upper()} {p}' for p, m, op in self.operations() + if not op.get('description')] + self.assertEqual(missing, [], f'{len(missing)} operations without description') + + def test_non_trivial_request_bodies_carry_an_example(self): + """Each operation in EXAMPLE_BODIES publishes a copyable body. + + A ``$ref`` tells a client the field names and types; it does not tell it + that ``trigger_condition`` needs a ``condition_type`` key or that + ``interval`` is a word rather than a number. The example is the only + part of the document a caller can paste into a request unmodified. + """ + missing = sorted(oid for _, _, op in self.operations() + if (oid := op.get('operationId')) in EXAMPLE_BODIES + and not any('example' in b or 'examples' in b + for b in (op.get('requestBody') or {}) + .get('content', {}).values())) + self.assertEqual(missing, [], f'no example: {missing}') + # Guard against a vacuous pass: an operationId that stopped existing + # would silently drop out of the comprehension above. + present = {op.get('operationId') for _, _, op in self.operations()} + self.assertEqual( + EXAMPLE_BODIES - present, set(), + 'EXAMPLE_BODIES names an operation the document lacks') + def test_every_tag_used_is_declared(self): """No operation carries a tag missing from the document tag list.""" declared = {t['name'] for t in self.spec().get('tags', [])} @@ -946,6 +1033,57 @@ def test_no_unreachable_schemas(self): used = {r.split('/')[-1] for r in seen if '/schemas/' in r} self.assertEqual(sorted(set(schemas) - used), [], 'unreachable schemas') + def test_every_advertised_collection_is_served(self): + """Nothing an entity advertises answers 404. + + Two surfaces advertise an entity's resource collections and they are + built from two different lists: the ``capabilities`` array on + ``GET /{type}/{id}`` comes from a per-handler ``CapabilityBuilder`` + call, the entity's ``/docs`` sub-document from + ``EntityCapabilities::for_type``. Both are followed here, for all four + entity types, because a collection can be right in one list and wrong + in the other. + + A 501 is a served answer - the route exists and reports that the + backend does not. A 404 is what this pins: an href the gateway + published that no route answers. + + @verifies REQ_INTEROP_002 + """ + offenders = [] + covered = {} + for entity_type in ('areas', 'components', 'apps', 'functions'): + items = self.get_json(f'/{entity_type}').get('items', []) + if not items: + # This fixture's demo nodes produce no areas. Skipping keeps + # the assertion below honest about what was actually probed; + # the per-type lists themselves are pinned by the + # `EntityCapabilities` unit tests. + continue + entity_id = items[0]['id'] + detail = self.get_json(f'/{entity_type}/{entity_id}') + subtree = self.get_json(f'/{entity_type}/{entity_id}/docs') + advertised = {c['href'] for c in detail.get('capabilities', [])} + advertised |= {f'/api/v1{p}' for p in subtree['paths']} + followed = 0 + for href in sorted(advertised): + if '{' in href: + # A templated path names no concrete resource to fetch. + continue + resp = requests.get( + f'{self.BASE_URL}{href[len("/api/v1"):]}', timeout=10) + followed += 1 + if resp.status_code == 404: + offenders.append(f'{entity_type}: {href}') + covered[entity_type] = followed + self.assertEqual(offenders, [], f'advertised but 404: {offenders}') + # Guard against a vacuous pass: an entity type that advertised nothing, + # or a listing that came back empty, must not read as green. + for entity_type in ('components', 'apps', 'functions'): + self.assertGreater( + covered.get(entity_type, 0), 8, + f'{entity_type}: only {covered.get(entity_type, 0)} hrefs followed') + @launch_testing.post_shutdown_test() class TestShutdown(unittest.TestCase): 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 563d886ca..02c9d0ed8 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 @@ -162,11 +162,22 @@ def test_04_area_subareas(self): def test_05_area_components(self): """GET /areas/{id}/components returns components in area. + The area's `capabilities` array must advertise the same route. Areas were + the one entity type whose relationship endpoint was served and named in + `AreaDetail.components` but missing from the capability array, so a + client reading only that array could not find it. + @verifies REQ_INTEROP_006 """ data = self.get_json('/areas/engine/components') self.assertIn('items', data) + area = self.get_json('/areas/engine') + advertised = {c['name']: c['href'] for c in area['capabilities']} + self.assertIn('components', advertised) + self.assertEqual( + advertised['components'], '/api/v1/areas/engine/components') + # ========================================================================= # Components # ========================================================================= From e119cf1d9bf6a08195c16aca5bcea8f5577c5246 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Wed, 5 Aug 2026 08:43:38 +0200 Subject: [PATCH 11/17] fix(fault-manager,gateway): reconcile the fault_code bound and stop losing bags silently The document published a maximum of 256 while FaultManagerNode enforced 128, so a code in between produced a server error on a value the document calls valid. The bound is now 256 on both sides; every fault_code column is unconstrained TEXT and every IDL field an unbounded string, so nothing downstream caps it. Raising it exposed silent loss: fault__ pushed the rosbag directory name past NAME_MAX, the exception was caught and only logged, and the bag was never written. The name is bounded and carries a digest of the whole code, so two codes sharing every kept byte still get separate directories. Separately, get_fault decided 404 against 503 by substring-matching the store's message, so a validation refusal from the fault manager reached the client as a server error. The transport now records whether it got a response at all, and the client-error side is the default so a 503 must be asked for. --- docs/api/rest.rst | 23 +++++- docs/tutorials/snapshots.rst | 11 ++- .../rosbag_capture.hpp | 27 +++++++ .../src/fault_manager_node.cpp | 18 ++++- .../src/rosbag_capture.cpp | 70 +++++++++++++++- .../test/test_rosbag_capture.cpp | 58 ++++++++++++++ .../core/faults/fault_types.hpp | 27 +++++++ .../http/handlers/fault_handlers.hpp | 28 +++++++ .../src/core/openapi/route_registry.cpp | 21 +++++ .../src/http/handlers/fault_handlers.cpp | 51 ++++++------ .../src/http/rest_server.cpp | 4 +- .../ros2_fault_service_transport.cpp | 8 ++ .../test/test_fault_handlers.cpp | 59 ++++++++++++++ .../test/test_fault_manager.cpp | 51 ++++++++++++ .../test/features/test_faults_api.test.py | 80 ++++++++++++++++--- src/ros2_medkit_msgs/README.md | 2 +- 16 files changed, 490 insertions(+), 48 deletions(-) diff --git a/docs/api/rest.rst b/docs/api/rest.rst index 14665c9d0..c50bb7906 100644 --- a/docs/api/rest.rst +++ b/docs/api/rest.rst @@ -1045,14 +1045,33 @@ Query and manage faults. **Response codes:** - **200:** Fault details - - **404:** Fault not found, or reported by an app outside this entity's scope + - **400:** ``fault_code`` empty or longer than 256 characters + - **404:** Fault not found, reported by an app outside this entity's scope, + or declined by the fault manager - **503:** Fault manager unavailable ``DELETE /api/v1/components/{id}/faults/{fault_code}`` Clear a fault. - **204:** Fault cleared - - **404:** Fault not found, or reported by an app outside this entity's scope + - **400:** ``fault_code`` empty or longer than 256 characters + - **404:** Fault not found, reported by an app outside this entity's scope, + or declined by the fault manager + - **503:** Fault manager unavailable + +.. note:: + + ``503`` on these two routes means the fault manager did not answer - it is + absent, still starting, or timed out. A fault manager that answers and + declines the request is reported as ``404``, not ``503``: it is reachable + and healthy, and the request is what it would not serve. That covers a + ``fault_code`` it does not hold and one it will not accept - it restricts + codes to alphanumerics, underscore, hyphen and dot, a narrower set than the + ``maxLength`` the OpenAPI document publishes, so a short code containing + anything else is admitted by the gateway and answered ``404``. + + Both nodes bound ``fault_code`` at the published 256 characters, so every + length the document admits reaches the fault manager. ``DELETE /api/v1/components/{id}/faults`` Clear all faults for an entity. diff --git a/docs/tutorials/snapshots.rst b/docs/tutorials/snapshots.rst index c2f2d8d1f..71806199d 100644 --- a/docs/tutorials/snapshots.rst +++ b/docs/tutorials/snapshots.rst @@ -485,7 +485,16 @@ Rosbag Configuration Options - Directory for bag files. Empty string uses system temp directory (``/tmp``). Bags are named ``fault_{code}_{timestamp}/`` after the first fault of the recording; faults that attached during its post-roll window - are served from that same directory. + are served from that same directory. From a fault code of about 224 + characters the name keeps only a leading slice of the code, followed by + a digest of the whole of it, so two long codes sharing a prefix are + overwhelmingly unlikely to land on one directory. The threshold is not + the 255-byte limit on a path component - at 224 the directory name is + still only 244 bytes - but the room reserved inside that limit for the + data file rosbag2 writes *within* the directory, which is named after + it. Nothing reconstructs this name - the path is recorded in the + database when the bag is written - so the shortening is not something + callers need to reproduce. * - ``snapshots.rosbag.auto_cleanup`` - ``true`` - Automatically delete a fault's bag when it is cleared. A recording diff --git a/src/ros2_medkit_fault_manager/include/ros2_medkit_fault_manager/rosbag_capture.hpp b/src/ros2_medkit_fault_manager/include/ros2_medkit_fault_manager/rosbag_capture.hpp index c2077f2c3..ea4478337 100644 --- a/src/ros2_medkit_fault_manager/include/ros2_medkit_fault_manager/rosbag_capture.hpp +++ b/src/ros2_medkit_fault_manager/include/ros2_medkit_fault_manager/rosbag_capture.hpp @@ -126,6 +126,33 @@ class RosbagCapture { /// Static + public so the quota arithmetic is testable without a live recording. static std::vector evict_bags_over_quota(FaultStorage * storage, size_t max_bytes); + /// Build the single path component naming a fault's bag directory. + /// + /// `NAME_MAX` caps one path component at 255 bytes, and rosbag2 writes the + /// data file inside the directory as `_.`, so the + /// component has to leave that room too. A `fault_code` long enough to + /// overrun the budget is kept only up to a bounded prefix, which is why this + /// does not simply trust the validator's maximum: the two limits answer to + /// different things, and a code the fault services accept must still yield a + /// directory the filesystem will take. + /// + /// A truncated name carries a digest of the whole code, and has to: two + /// distinct codes sharing a long prefix would otherwise name one directory, + /// and nothing downstream would reject it. `rosbag_files.file_path` has no + /// UNIQUE constraint, and two rows pointing at one bag is a supported state + /// rather than an error - it is how a burst of correlated faults shares a + /// recording. The collision would be written, not refused, and the losing + /// writer's failure is swallowed by `flush_to_bag`. + /// + /// Shortening costs no lookup. A bag is found through the `rosbag_files` + /// table, which stores the path it was created with, so nothing recomputes + /// this name from a fault code. + /// + /// Static + public so the budget is testable without a live recording. + /// @param fault_code Validated fault code (no `/`, so no traversal). + /// @param timestamp_ms Milliseconds since the epoch, taken by the caller. + static std::string bag_directory_name(const std::string & fault_code, int64_t timestamp_ms); + private: /// Initialize subscriptions for configured topics void init_subscriptions(); diff --git a/src/ros2_medkit_fault_manager/src/fault_manager_node.cpp b/src/ros2_medkit_fault_manager/src/fault_manager_node.cpp index 72841cd6e..642f0dec4 100644 --- a/src/ros2_medkit_fault_manager/src/fault_manager_node.cpp +++ b/src/ros2_medkit_fault_manager/src/fault_manager_node.cpp @@ -37,8 +37,22 @@ namespace ros2_medkit_fault_manager { namespace { -/// Maximum allowed length for fault_code -constexpr size_t kMaxFaultCodeLength = 128; +/// Maximum allowed length for fault_code. +/// +/// Matches the bound the gateway enforces on every route carrying +/// `{fault_code}` and publishes as that parameter's `maxLength`. The two are +/// one contract seen from two nodes, so they have to be the same number: while +/// this was the lower of the two, a code the document called well-formed was +/// admitted by the gateway and then refused here, and the refusal reached the +/// client as a server error. +/// +/// Nothing downstream constrains it further. Every column holding a fault code +/// is SQLite `TEXT`, which is unbounded, and every `fault_code` field in the +/// message and service definitions is an unbounded `string`. `RosbagCapture` +/// is the one consumer with a limit of its own - it names a directory after +/// the code - and it bounds its own path component rather than relying on this +/// value. +constexpr size_t kMaxFaultCodeLength = 256; /// Validate fault_code format /// @param fault_code The fault code to validate diff --git a/src/ros2_medkit_fault_manager/src/rosbag_capture.cpp b/src/ros2_medkit_fault_manager/src/rosbag_capture.cpp index d68ae9a7d..d6ea982b6 100644 --- a/src/ros2_medkit_fault_manager/src/rosbag_capture.cpp +++ b/src/ros2_medkit_fault_manager/src/rosbag_capture.cpp @@ -19,8 +19,10 @@ #include #include +#include #include #include +#include #include #include #include @@ -932,6 +934,69 @@ std::string RosbagCapture::flush_to_bag(const std::string & fault_code) { } } +namespace { + +/// 64-bit FNV-1a of @p text as 16 lowercase hex digits. Used only to keep two +/// truncated bag directory names apart, never to identify a fault, so a +/// non-cryptographic digest is the right tool for it. +std::string fnv1a_hex(const std::string & text) { + uint64_t hash = 14695981039346656037ULL; + for (const char character : text) { + // `char` is signed here, so widen through `unsigned char` to hash the byte + // value rather than a sign-extended one. + hash ^= static_cast(static_cast(character)); + hash *= 1099511628211ULL; + } + std::ostringstream oss; + oss << std::hex << std::setw(16) << std::setfill('0') << hash; + return oss.str(); +} + +} // namespace + +std::string RosbagCapture::bag_directory_name(const std::string & fault_code, int64_t timestamp_ms) { + // One path component may be 255 bytes. rosbag2's storage plugins write the + // data file inside the directory as "_.", where the + // extension is the one the plugin produces - ".db3" or ".mcap"; "sqlite3" is + // a storage id, not a suffix, and no file is ever named that. The longest + // that reaches in practice is "_999.mcap", 9 bytes. + // + // That 9 is worst-case for *our* configuration, not for rosbag2 in general: + // `storage_preset_profile` is left unset (see the StorageOptions built in + // `flush_to_bag`), so sqlite3 runs journal_mode=MEMORY and writes no + // sidecars. Under rosbag2's `resilient` preset the WAL sidecar would make it + // "_999.db3-wal", 12 bytes - which is exactly the reserve and no more. So 12 + // is headroom against a wider split index today, and the margin that keeps a + // preset change from silently overrunning tomorrow. + constexpr size_t kNameMax = 255; + constexpr size_t kWriterSuffixBudget = 12; + // 16 hex digits of digest plus the '_' separating them from the kept prefix. + constexpr size_t kDigestFieldWidth = 17; + + const std::string prefix = "fault_"; + const std::string suffix = "_" + std::to_string(timestamp_ms); + const size_t fixed = prefix.size() + suffix.size() + kWriterSuffixBudget; + const size_t code_budget = kNameMax > fixed ? kNameMax - fixed : 0; + + if (fault_code.size() <= code_budget) { + return prefix + fault_code + suffix; + } + + // Truncating on its own would let two distinct codes sharing a long prefix + // name one directory, and nothing downstream would reject that: + // `rosbag_files.file_path` carries no UNIQUE constraint, and two rows + // pointing at one bag is a supported state rather than an error - it is how + // a burst of correlated faults shares a recording, which is what + // `path_shared_with_other_fault` exists to protect. So the collision would + // be written rather than refused, and the losing writer's failure is caught + // and logged inside `flush_to_bag` - the same silent loss this bound was + // added to remove. A digest of the whole code separates them: 64-bit FNV-1a + // is collision-resistant rather than collision-free, so this bounds the risk + // at roughly 2^-64 per pair rather than eliminating it outright. + const size_t kept = code_budget > kDigestFieldWidth ? code_budget - kDigestFieldWidth : 0; + return prefix + fault_code.substr(0, kept) + "_" + fnv1a_hex(fault_code) + suffix; +} + std::string RosbagCapture::generate_bag_path(const std::string & fault_code) const { std::string base_path; @@ -946,10 +1011,7 @@ std::string RosbagCapture::generate_bag_path(const std::string & fault_code) con auto now = std::chrono::system_clock::now(); auto timestamp = std::chrono::duration_cast(now.time_since_epoch()).count(); - std::ostringstream oss; - oss << base_path << "/fault_" << fault_code << "_" << timestamp; - - return oss.str(); + return base_path + "/" + bag_directory_name(fault_code, timestamp); } size_t RosbagCapture::calculate_bag_size(const std::string & bag_path) const { diff --git a/src/ros2_medkit_fault_manager/test/test_rosbag_capture.cpp b/src/ros2_medkit_fault_manager/test/test_rosbag_capture.cpp index 0d0c21c1a..bf1395cae 100644 --- a/src/ros2_medkit_fault_manager/test/test_rosbag_capture.cpp +++ b/src/ros2_medkit_fault_manager/test/test_rosbag_capture.cpp @@ -831,6 +831,7 @@ TEST_F(RosbagCaptureIntegrationTest, AttachmentCapDropsFaultsPastIt) { fill_buffer("/rosbag_cap_probe"); std::vector codes; + codes.reserve(34); for (int i = 0; i < 34; ++i) { codes.push_back("BURST_" + std::string(i < 10 ? "0" : "") + std::to_string(i)); } @@ -988,6 +989,63 @@ TEST_F(RosbagCaptureIntegrationTest, ConfirmedWithoutPrefailed) { capture.stop(); } +// The fault services accept a code of up to `kMaxFaultCodeLength` (256), and +// this names a directory after one. Those two limits answer to different +// things - one to the published API contract, one to `NAME_MAX` - so the name +// has to hold at the longest code the services will admit rather than assume +// the validator keeps it short. It did not before: at 256 the component ran to +// 276 bytes and the bag was silently never written, because the failure is +// caught and logged inside `flush_to_bag`. +TEST(RosbagBagDirectoryNameTest, StaysWithinNameMaxAtTheLongestAcceptedFaultCode) { + // Budgeted against a file rosbag2 actually creates. The storage plugins name + // the data file "_." with a `.db3` or `.mcap` extension - + // "sqlite3" is a storage id and never appears in a filename - so the longest + // that reaches in practice is "_999.mcap". + constexpr size_t kNameMax = 255; + const std::string longest_writer_suffix = "_999.mcap"; + constexpr int64_t kTimestampMs = 1785441426087; + + for (size_t length : {size_t{1}, size_t{128}, size_t{223}, size_t{224}, size_t{256}}) { + const std::string code(length, 'F'); + const std::string name = RosbagCapture::bag_directory_name(code, kTimestampMs); + EXPECT_LE(name.size() + longest_writer_suffix.size(), kNameMax) + << "component " << name.size() << " bytes at fault_code length " << length; + EXPECT_EQ(name.rfind("fault_", 0), 0u); + EXPECT_NE(name.find(std::to_string(kTimestampMs)), std::string::npos); + } +} + +// Below the budget the code is carried whole - the bound must not shorten +// every name, only the ones that would not fit. +TEST(RosbagBagDirectoryNameTest, KeepsAShortFaultCodeVerbatim) { + EXPECT_EQ(RosbagCapture::bag_directory_name("MOTOR_OVERHEAT", 1785441426087), "fault_MOTOR_OVERHEAT_1785441426087"); +} + +// The collision truncation introduces, pinned at the timestamp that makes it +// reachable. Two distinct codes agreeing on every kept byte must still name +// different directories: `rosbag_files.file_path` has no UNIQUE constraint and +// two rows sharing one bag is a supported state, so a collision would be +// written rather than refused, and the losing writer's failure is swallowed by +// `flush_to_bag`. Same millisecond on purpose - the timestamp cannot be what +// separates them here. +TEST(RosbagBagDirectoryNameTest, TruncatedCodesSharingEveryKeptByteStillDiffer) { + constexpr int64_t kSameTimestampMs = 1785441426087; + const std::string a(256, 'F'); + const std::string b = std::string(255, 'F') + "G"; + ASSERT_EQ(a.substr(0, 200), b.substr(0, 200)) << "the two codes must share the kept prefix for this to bite"; + + EXPECT_NE(RosbagCapture::bag_directory_name(a, kSameTimestampMs), + RosbagCapture::bag_directory_name(b, kSameTimestampMs)); +} + +// The same code must always name the same directory, or a lookup built from a +// remembered path would miss. +TEST(RosbagBagDirectoryNameTest, IsDeterministicForOneCode) { + const std::string code(256, 'F'); + EXPECT_EQ(RosbagCapture::bag_directory_name(code, 1785441426087), + RosbagCapture::bag_directory_name(code, 1785441426087)); +} + int main(int argc, char ** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/faults/fault_types.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/faults/fault_types.hpp index 820800766..695f77ecb 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/faults/fault_types.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/faults/fault_types.hpp @@ -14,6 +14,7 @@ #pragma once +#include #include #include @@ -21,13 +22,38 @@ namespace ros2_medkit_gateway { using json = nlohmann::json; +/// Which layer failed a fault-management call. Meaningful only when the +/// outcome's `success` is false. +/// +/// The distinction is structural, not textual. A transport knows for a fact +/// whether it obtained an answer from the fault manager, and that is the only +/// thing that separates a server-side failure from a client-side one: a fault +/// manager that answered and declined is healthy, and the request is what was +/// at fault, however the refusal happens to be worded. Reading the words +/// instead cannot make that call - the message is prose the fault manager is +/// free to change, and any wording a matcher did not anticipate is +/// indistinguishable from an outage. +enum class FaultFailure : uint8_t { + /// The fault manager answered and declined: no such fault, a code it will + /// not accept, or a fault outside the requested scope. A client error. + Declined, + /// No answer was obtained at all - the service was missing, uninitialised, + /// or timed out. The only genuinely server-side case. + Unavailable, +}; + /// Outcome of a fault-management operation that returns JSON. `data` carries /// the response body the handler will serve on success; remains empty on /// errors. +/// +/// `failure` defaults to `Declined` so that a producer which reports a failure +/// without classifying it cannot manufacture a server error: 503 has to be +/// asked for, and only a transport that failed to get an answer asks. struct FaultResult { bool success; json data; std::string error_message; + FaultFailure failure = FaultFailure::Declined; }; /// Neutral outcome of `get_fault_with_env`. `data` carries @@ -39,6 +65,7 @@ struct FaultWithEnvJsonResult { bool success; std::string error_message; json data; + FaultFailure failure = FaultFailure::Declined; }; } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/fault_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/fault_handlers.hpp index 28effa558..e7667f229 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/fault_handlers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/fault_handlers.hpp @@ -21,6 +21,7 @@ #include #include +#include "ros2_medkit_gateway/core/faults/fault_types.hpp" #include "ros2_medkit_gateway/dto/faults.hpp" #include "ros2_medkit_gateway/entity_freeze_frame_capture.hpp" #include "ros2_medkit_gateway/http/handlers/handler_context.hpp" @@ -177,6 +178,33 @@ class FaultHandlers { */ static bool fault_in_source_scope(const nlohmann::json & fault, const std::set & source_fqns); + /** + * @brief Map a failed fault-manager call onto an HTTP error. + * + * Only a call that never obtained an answer is the server's fault. Anything + * the fault manager itself answered - a code it does not hold, a code it + * refuses to accept - is a condition of the request and is reported as 404, + * which is what the fault routes document as well. + * + * The classification comes from `FaultFailure`, which the transport sets + * from whether it received a response. It was previously derived from the + * store's message text, and so any refusal worded in a way the matcher did + * not anticipate - the fault manager's own `fault_code` validation among + * them - was served to the client as 503. + * + * Public for direct unit testing; called by `get_fault` and `clear_fault`. + * + * @param failure Which layer failed, as reported by the transport. + * @param error_message The store's message, passed through as `details`. + * @param unavailable_summary Summary for the 503 case, per calling verb. + * @param id_field Entity id field name for the error parameters. + * @param entity_id Entity the request addressed. + * @param fault_code Fault code the request addressed. + */ + static ErrorInfo classify_fault_failure(FaultFailure failure, const std::string & error_message, + const std::string & unavailable_summary, const std::string & id_field, + const std::string & entity_id, const std::string & fault_code); + /** * @brief Merge zero-config entity freeze-frames into environment data. * diff --git a/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp b/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp index 0db3aabfa..a1c459496 100644 --- a/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp +++ b/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp @@ -956,6 +956,27 @@ nlohmann::json RouteRegistry::to_openapi_paths() const { // that never measured `config_id`, and the 512 was published on its // routes anyway. That check now exists. // + // "Unconditionally" means no branch inside the handler skips the + // measurement - not that it is the first thing the request meets. + // The five handlers behind these two templates do not agree on where + // it sits, so an over-long value on an *unknown* entity gets one of + // two answers depending on the verb: + // `get_configuration`, `get_fault`, `clear_fault` resolve the + // entity first -> 404 + // `set_configuration`, `delete_configuration` measure first + // -> 400 + // The entity-id check those last two run beforehand does not close + // the gap: `validate_entity_id` is format-only and never consults the + // cache. `clear_fault` also takes `validate_lock_access` before + // measuring, so a competing lock answers 409. + // + // None of that weakens the precondition - every route publishing a + // bound does reject an over-long value. Only the status a caller sees + // when it is *also* wrong about something else differs, and that + // split is an accident of handlers written at different times rather + // than a decision anyone recorded. It is deliberately not pinned by a + // test: pinning it would make an accident load-bearing. + // // Both rows are covered on every verb they publish to, so the // precondition is tested rather than asserted: // config_id - test_configuration_api.test.py 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 9f3e33c78..62212a4b3 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp @@ -212,6 +212,22 @@ dto::FaultDetailResult wrap_detail_result(json payload) { } // namespace +// Rationale and parameter contract are documented on the declaration in +// fault_handlers.hpp. In short: 503 is reserved for a call that never got an +// answer; anything the fault manager answered is a client error. +ErrorInfo FaultHandlers::classify_fault_failure(FaultFailure failure, const std::string & error_message, + const std::string & unavailable_summary, const std::string & id_field, + const std::string & entity_id, const std::string & fault_code) { + const json params{{"details", error_message}, {id_field, entity_id}, {"fault_code", fault_code}}; + switch (failure) { + case FaultFailure::Declined: + return make_error(404, ERR_RESOURCE_NOT_FOUND, "Fault not found", params); + case FaultFailure::Unavailable: + break; + } + return make_error(503, ERR_SERVICE_UNAVAILABLE, unavailable_summary, params); +} + bool FaultHandlers::fault_in_source_scope(const json & fault, const std::set & source_fqns) { // Thin wrapper preserving the public static API; the scope logic now lives in // the neutral core helper shared with the ROS 2 plugin-context fault path. @@ -769,15 +785,8 @@ http::Result FaultHandlers::get_fault(const http::TypedR auto result = fault_mgr->get_fault_with_env(fault_code, ""); if (!result.success) { - if (result.error_message.find("not found") != std::string::npos || - result.error_message.find("Fault not found") != std::string::npos) { - return tl::make_unexpected(make_error( - 404, ERR_RESOURCE_NOT_FOUND, "Fault not found", - json{{"details", result.error_message}, {entity_info.id_field, entity_id}, {"fault_code", fault_code}})); - } - return tl::make_unexpected(make_error( - 503, ERR_SERVICE_UNAVAILABLE, "Failed to get fault", - json{{"details", result.error_message}, {entity_info.id_field, entity_id}, {"fault_code", fault_code}})); + return tl::make_unexpected(classify_fault_failure(result.failure, result.error_message, "Failed to get fault", + entity_info.id_field, entity_id, fault_code)); } // Build SOVD-compliant response from the transport-supplied JSON shape. @@ -914,16 +923,9 @@ FaultHandlers::clear_fault(const http::TypedRequest & req) { // Verify the fault is in this entity's scope BEFORE clearing. auto get_result = fault_mgr->get_fault_with_env(fault_code, ""); if (!get_result.success) { - if (get_result.error_message.find("not found") != std::string::npos || - get_result.error_message.find("Fault not found") != std::string::npos) { - return tl::make_unexpected(make_error(404, ERR_RESOURCE_NOT_FOUND, "Fault not found", - json{{"details", get_result.error_message}, - {entity_info.id_field, entity_id}, - {"fault_code", fault_code}})); - } - return tl::make_unexpected(make_error( - 503, ERR_SERVICE_UNAVAILABLE, "Failed to clear fault", - json{{"details", get_result.error_message}, {entity_info.id_field, entity_id}, {"fault_code", fault_code}})); + return tl::make_unexpected(classify_fault_failure(get_result.failure, get_result.error_message, + "Failed to clear fault", entity_info.id_field, entity_id, + fault_code)); } const auto & cache = ctx_.node()->get_thread_safe_cache(); @@ -942,15 +944,8 @@ FaultHandlers::clear_fault(const http::TypedRequest & req) { auto result = fault_mgr->clear_fault(fault_code, /*skip_correlation_auto_clear=*/true); if (!result.success) { - if (result.error_message.find("not found") != std::string::npos || - result.error_message.find("Fault not found") != std::string::npos) { - return tl::make_unexpected(make_error( - 404, ERR_RESOURCE_NOT_FOUND, "Fault not found", - json{{"details", result.error_message}, {entity_info.id_field, entity_id}, {"fault_code", fault_code}})); - } - return tl::make_unexpected(make_error( - 503, ERR_SERVICE_UNAVAILABLE, "Failed to clear fault", - json{{"details", result.error_message}, {entity_info.id_field, entity_id}, {"fault_code", fault_code}})); + return tl::make_unexpected(classify_fault_failure(result.failure, result.error_message, "Failed to clear fault", + entity_info.id_field, entity_id, fault_code)); } return Outcome{http::NoContent{}}; } catch (const std::exception & e) { diff --git a/src/ros2_medkit_gateway/src/http/rest_server.cpp b/src/ros2_medkit_gateway/src/http/rest_server.cpp index c8109277e..82953fa60 100644 --- a/src/ros2_medkit_gateway/src/http/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/http/rest_server.cpp @@ -1001,7 +1001,9 @@ void RESTServer::setup_routes() { "to `error` still returns errors and above; and the answer is then capped at the configuration's " "`max_entries`, most recent kept - silently, with nothing on the response saying it was cut and no " "way to page past it. Both of those filter the answer rather than the buffer. A registered " - "LogProvider serves the query itself, and neither applies to it.") + "LogProvider serves the query itself and applies its own severity floor and cap instead - except " + "on an area or a component, where the entity's own logs and the namespace-prefix query are merged " + "and the union is re-capped at the configuration's `max_entries` whichever of the two produced it.") // LogHandlers::get_logs -> fan_out_collection. .fan_out_aware() // All three log routes answer 503 when no LogManager is attached, or diff --git a/src/ros2_medkit_gateway/src/ros2/transports/ros2_fault_service_transport.cpp b/src/ros2_medkit_gateway/src/ros2/transports/ros2_fault_service_transport.cpp index 655b564ee..0df9e25b5 100644 --- a/src/ros2_medkit_gateway/src/ros2/transports/ros2_fault_service_transport.cpp +++ b/src/ros2_medkit_gateway/src/ros2/transports/ros2_fault_service_transport.cpp @@ -163,6 +163,7 @@ FaultResult Ros2FaultServiceTransport::report_fault(const std::string & fault_co "ReportFault", result.error_message); if (!response) { result.success = false; + result.failure = FaultFailure::Unavailable; return result; } @@ -206,6 +207,7 @@ FaultResult Ros2FaultServiceTransport::list_faults(const std::string & source_id "ListFaults", result.error_message); if (!response) { result.success = false; + result.failure = FaultFailure::Unavailable; return result; } @@ -280,6 +282,7 @@ FaultWithEnvJsonResult Ros2FaultServiceTransport::get_fault_with_env(const std:: "GetFault", result.error_message); if (!response) { result.success = false; + result.failure = FaultFailure::Unavailable; return result; } @@ -318,6 +321,7 @@ FaultResult Ros2FaultServiceTransport::get_fault(const std::string & fault_code, FaultResult result; result.success = env_result.success; result.error_message = env_result.error_message; + result.failure = env_result.failure; if (env_result.success) { result.data = env_result.data["fault"]; @@ -338,6 +342,7 @@ FaultResult Ros2FaultServiceTransport::clear_fault(const std::string & fault_cod "ClearFault", result.error_message); if (!response) { result.success = false; + result.failure = FaultFailure::Unavailable; return result; } @@ -366,6 +371,7 @@ FaultResult Ros2FaultServiceTransport::get_snapshots(const std::string & fault_c "GetSnapshots", result.error_message); if (!response) { result.success = false; + result.failure = FaultFailure::Unavailable; return result; } @@ -399,6 +405,7 @@ FaultResult Ros2FaultServiceTransport::get_rosbag(const std::string & fault_code "GetRosbag", result.error_message); if (!response) { result.success = false; + result.failure = FaultFailure::Unavailable; return result; } @@ -427,6 +434,7 @@ FaultResult Ros2FaultServiceTransport::list_rosbags(const std::string & entity_f "ListRosbags", result.error_message); if (!response) { result.success = false; + result.failure = FaultFailure::Unavailable; return result; } diff --git a/src/ros2_medkit_gateway/test/test_fault_handlers.cpp b/src/ros2_medkit_gateway/test/test_fault_handlers.cpp index 08f15dbd5..9c7f08a91 100644 --- a/src/ros2_medkit_gateway/test/test_fault_handlers.cpp +++ b/src/ros2_medkit_gateway/test/test_fault_handlers.cpp @@ -657,3 +657,62 @@ TEST(FaultListItemSchema, UnknownSeverityLabelIsAcceptedByEnum) { ASSERT_TRUE(parsed.has_value()) << "UNKNOWN severity_label rejected by FaultListItem enum"; EXPECT_EQ(dto::JsonWriter::write(parsed.value()), wire); } + +// ============================================================================= +// classify_fault_failure - 404-vs-503 on a failed fault-manager call +// ============================================================================= +// +// The split has to rest on which layer failed, not on how the failure reads. +// It rested on a substring match for "not found" over the store's message, so +// a refusal the fault manager worded any other way - its own `fault_code` +// validation among them - was served as 503, telling the client the gateway +// had a problem when the request did. + +// A fault the store does not hold. The wording here is the fault manager's +// own, and the classification must not depend on it. +// @verifies REQ_INTEROP_013 +// @verifies REQ_INTEROP_015 +TEST(ClassifyFaultFailureTest, DeclinedIsAClientError) { + const auto err = + FaultHandlers::classify_fault_failure(ros2_medkit_gateway::FaultFailure::Declined, "Fault not found: NO_SUCH", + "Failed to get fault", "app_id", "lidar_sensor", "NO_SUCH"); + EXPECT_EQ(err.http_status, 404); + EXPECT_EQ(err.code, ros2_medkit_gateway::ERR_RESOURCE_NOT_FOUND); + EXPECT_EQ(err.params["details"], "Fault not found: NO_SUCH"); + EXPECT_EQ(err.params["app_id"], "lidar_sensor"); + EXPECT_EQ(err.params["fault_code"], "NO_SUCH"); +} + +// The regression case: a refusal that never contains "not found". Before the +// classification was typed, this fell through the substring match to 503. +// @verifies REQ_INTEROP_013 +// @verifies REQ_INTEROP_015 +TEST(ClassifyFaultFailureTest, ARefusalWordedAnyOtherWayIsStillAClientError) { + for (const char * message : {"fault_code exceeds maximum length of 256", + "fault_code contains invalid character '~'. Only alphanumeric, underscore, hyphen, " + "and dot are allowed", + "fault_code cannot contain '..'", "some wording nobody has written yet"}) { + const auto err = FaultHandlers::classify_fault_failure(ros2_medkit_gateway::FaultFailure::Declined, message, + "Failed to get fault", "app_id", "lidar_sensor", "F~F"); + EXPECT_EQ(err.http_status, 404) << "declined refusal reported as " << err.http_status + << " for message: " << message; + EXPECT_LT(err.http_status, 500) << "a refusal must never be a server error: " << message; + } +} + +// The other direction must keep working: a fault manager that never answered +// is a real server-side failure and has to stay 503, or an outage would be +// indistinguishable from a missing fault. +// @verifies REQ_INTEROP_013 +// @verifies REQ_INTEROP_015 +TEST(ClassifyFaultFailureTest, UnavailableIsAServerError) { + for (const char * message : + {"GetFault service not available", "GetFault service call timed out", "GetFault transport not initialised"}) { + const auto err = FaultHandlers::classify_fault_failure(ros2_medkit_gateway::FaultFailure::Unavailable, message, + "Failed to clear fault", "component_id", "host", "CODE"); + EXPECT_EQ(err.http_status, 503) << "message: " << message; + EXPECT_EQ(err.code, ros2_medkit_gateway::ERR_SERVICE_UNAVAILABLE); + EXPECT_EQ(err.message, "Failed to clear fault"); + EXPECT_EQ(err.params["details"], message); + } +} diff --git a/src/ros2_medkit_gateway/test/test_fault_manager.cpp b/src/ros2_medkit_gateway/test/test_fault_manager.cpp index 12094c88e..a2125a806 100644 --- a/src/ros2_medkit_gateway/test/test_fault_manager.cpp +++ b/src/ros2_medkit_gateway/test/test_fault_manager.cpp @@ -30,14 +30,17 @@ #include "ros2_medkit_gateway/ros2/transports/ros2_fault_service_transport.hpp" #include "ros2_medkit_gateway/trigger_fault_subscriber.hpp" #include "ros2_medkit_msgs/msg/fault_event.hpp" +#include "ros2_medkit_msgs/srv/get_fault.hpp" #include "ros2_medkit_msgs/srv/get_rosbag.hpp" #include "ros2_medkit_msgs/srv/get_snapshots.hpp" using namespace std::chrono_literals; +using ros2_medkit_gateway::FaultFailure; using ros2_medkit_gateway::FaultManager; using ros2_medkit_gateway::ResourceChange; using ros2_medkit_gateway::ResourceChangeNotifier; using ros2_medkit_gateway::TriggerFaultSubscriber; +using ros2_medkit_msgs::srv::GetFault; using ros2_medkit_msgs::srv::GetRosbag; using ros2_medkit_msgs::srv::GetSnapshots; @@ -629,6 +632,54 @@ TEST_F(FaultManagerTest, GetRosbagNotFound) { EXPECT_EQ(result.error_message, "No rosbag file available for fault"); } +// ============================================================================= +// FaultFailure - which layer failed, as the transport reports it +// ============================================================================= +// +// The handler's 404-vs-503 split reads this flag, so the transport is the one +// place that decides which of the two a caller sees. These pin both readings +// against the live transport, because a mapping that is only tested against a +// hand-set flag would not notice the transport ceasing to set it. + +// No fault manager on the graph: no answer was obtained, so this is the one +// case that is genuinely the server's failure and must stay 503-worthy. +// @verifies REQ_INTEROP_013 +TEST_F(FaultManagerTest, GetFaultWithNoServiceReportsUnavailable) { + FaultManager fault_manager(std::make_shared(node_.get())); + + // No service is created, so the call cannot obtain a response. + auto result = fault_manager.get_fault_with_env("TEST_FAULT"); + + EXPECT_FALSE(result.success); + EXPECT_EQ(result.failure, FaultFailure::Unavailable) + << "a fault manager that never answered must not be reported as a client error: " << result.error_message; +} + +// The fault manager answered and declined. It is reachable and healthy, so +// this is a condition of the request however the refusal is worded - the +// wording used here is its own `fault_code` validation message, which no +// substring match for "not found" would have caught. +// @verifies REQ_INTEROP_013 +TEST_F(FaultManagerTest, GetFaultRefusedByTheStoreReportsDeclined) { + auto service = node_->create_service( + "/fault_manager/get_fault", + [](const std::shared_ptr & /*request*/, const std::shared_ptr & response) { + response->success = false; + response->error_message = "fault_code contains invalid character '~'"; + }); + + start_spinning(); + FaultManager fault_manager(std::make_shared(node_.get())); + + auto result = fault_manager.get_fault_with_env("F~F"); + stop_spinning(); + + EXPECT_FALSE(result.success); + EXPECT_EQ(result.failure, FaultFailure::Declined) + << "a refusal from a reachable fault manager must not be reported as a server failure"; + EXPECT_EQ(result.error_message, "fault_code contains invalid character '~'"); +} + int main(int argc, char ** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/src/ros2_medkit_integration_tests/test/features/test_faults_api.test.py b/src/ros2_medkit_integration_tests/test/features/test_faults_api.test.py index be5a3ae19..db906d633 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_faults_api.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_faults_api.test.py @@ -168,18 +168,80 @@ def test_both_verbs_reject_an_oversized_fault_code(self): # Control: a code the route serves normally still reaches the store, so # the rejections above are the length gate rather than the route # refusing every long-ish code. - # - # Deliberately well short of 256 rather than at it. The gateway's gate - # is 256, but the fault manager applies a stricter 128 - # (`kMaxFaultCodeLength`, fault_manager_node.cpp:41) and the gateway - # turns that refusal into 503, because its 404-vs-503 split is a - # substring match for "not found" on the store's message. So 129..256 - # answers 503 today even though the document publishes 256 - recorded, - # not pinned here, because reconciling the two bounds is a contract - # decision spanning both nodes. inside = f'{self.BASE_URL}/apps/lidar_sensor/faults/{"F" * 64}' self.assertEqual(requests.get(inside, timeout=10).status_code, 404) + def test_both_verbs_serve_a_fault_code_up_to_the_published_bound(self): + """Every length the document admits reaches the store and answers 404. + + `maxLength: 256` is a promise that a code of 256 characters is a + well-formed request, so the only honest answer for one that names no + fault is 404. The bound is enforced in two nodes, and this pins that + they agree: the gateway gates at 256 (`fault_handlers.cpp`), and the + fault manager applies `kMaxFaultCodeLength` to the same value on the + GetFault and ClearFault services. When the two disagreed, the lengths + between the lower bound and 256 were refused by the fault manager and + surfaced as 503 - a value the published document calls valid answering + with a server error. + + 128 is driven alongside 129 deliberately: it was the fault manager's + old bound, so it passed while 129 did not, and keeping both makes a + regression to any lower bound visible as a split result rather than a + uniform failure. + + @verifies REQ_INTEROP_013 + @verifies REQ_INTEROP_015 + """ + for length in (128, 129, 255, 256): + url = f'{self.BASE_URL}/apps/lidar_sensor/faults/{"F" * length}' + for verb, call in ( + ('GET', lambda u=url: requests.get(u, timeout=10)), + ('DELETE', lambda u=url: requests.delete(u, timeout=10)), + ): + with self.subTest(length=length, verb=verb): + response = call() + self.assertEqual( + response.status_code, 404, + f'{verb} at length {length}: {response.status_code} {response.text}' + ) + + def test_a_refusal_from_the_fault_manager_is_not_a_server_error(self): + """A code the store declines answers 4xx, not 503. + + The gateway published no `pattern` for `{fault_code}`, only a + `maxLength`, but the fault manager restricts the character set to + alphanumerics, underscore, hyphen and dot. So a short code containing + anything else is admitted by the gateway, reaches the fault manager, + and comes back refused - a refusal the gateway has to classify. + + This is the same defect as the length disagreement but reached by a + different route, and it does not depend on any bound: the two nodes can + agree on `maxLength` exactly and this still fails. The classification + must therefore rest on which layer answered - the fault manager + declining a request is a condition of the request, whereas only a + fault manager that never answered at all is a server-side failure - + and not on reading the words in the message it returned. + + @verifies REQ_INTEROP_013 + @verifies REQ_INTEROP_015 + """ + # `~` is unreserved in a URL path, so it arrives at the handler intact, + # and it is outside the character set the fault manager accepts. + url = f'{self.BASE_URL}/apps/lidar_sensor/faults/F~F' + for verb, call in ( + ('GET', lambda: requests.get(url, timeout=10)), + ('DELETE', lambda: requests.delete(url, timeout=10)), + ): + with self.subTest(verb=verb): + response = call() + self.assertLess( + response.status_code, 500, + f'{verb}: a refusal from the fault manager reported as ' + f'{response.status_code}: {response.text}' + ) + self.assertEqual(response.status_code, 404, f'{verb}: {response.text}') + self.assertIn('error_code', response.json()) + def test_list_all_faults_globally(self): """GET /faults returns all faults across the system. diff --git a/src/ros2_medkit_msgs/README.md b/src/ros2_medkit_msgs/README.md index d18d7eeac..d8219d1cc 100644 --- a/src/ros2_medkit_msgs/README.md +++ b/src/ros2_medkit_msgs/README.md @@ -82,7 +82,7 @@ Report a fault event (FAILED or PASSED) to the FaultManager. **Request:** | Field | Type | Description | |-------|------|-------------| -| `fault_code` | string | Global identifier (UPPER_SNAKE_CASE, max 64 chars) | +| `fault_code` | string | Global identifier (UPPER_SNAKE_CASE, max 256 chars; alphanumerics, `_`, `-` and `.` only) | | `event_type` | uint8 | Event type: EVENT_FAILED (0) or EVENT_PASSED (1) | | `severity` | uint8 | Severity level (0-3, only for FAILED events) | | `description` | string | Human-readable description (only for FAILED events) | From 4b4789af7919855ad0660884ae59837f5f740112 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Wed, 5 Aug 2026 08:43:56 +0200 Subject: [PATCH 12/17] feat(gateway): fold the docs and plugin routes in, derive the permission table, project the sub-documents The /docs routes and the graph provider's routes were served but undocumented. Folding them in needed OperationDesc to carry tag, operationId and role, and a gateway-stamped marker for the coverage sweep, whose recorder attaches at the registry mount point and structurally cannot see a plugin route. The RBAC table is now generated from the route registrations rather than maintained as a literal, with a short residual list for what is mounted outside the registry. Enforcement did not move: check_authorization still fails closed where it always did, because docs, Swagger UI and plugin routes would otherwise lose their rule. The /docs sub-documents are projected from the real document instead of rebuilt by hand. The hand-written producers had been shipping dangling $refs - twelve in a component's document - so a client generating code from one got references to nothing. The cache now stores the serialized form rather than a parsed tree, cutting resident growth by about 40% and the per-hit copy by 70%. Also documents the derivation rule and the tier each mechanism reaches, and adds a check resolving every test the design docs cite against the test tree. --- docs/api/rest.rst | 161 +++- docs/tutorials/authentication.rst | 47 +- docs/tutorials/graph-provider.rst | 6 +- docs/tutorials/plugin-system.rst | 70 ++ src/ros2_medkit_gateway/CMakeLists.txt | 12 + src/ros2_medkit_gateway/README.md | 12 +- .../design/dto_contract.rst | 117 ++- src/ros2_medkit_gateway/design/index.rst | 1 + .../design/openapi_derivation.rst | 591 ++++++++++++ .../core/auth/auth_config.hpp | 27 +- .../core/auth/auth_manager.hpp | 25 + .../core/http/handlers/docs_handlers.hpp | 31 +- .../core/openapi/document_checks.hpp | 19 + .../core/openapi/route_descriptions.hpp | 95 ++ .../ros2_medkit_gateway/dto/entities.hpp | 13 +- .../http/detail/primitives.hpp | 33 +- .../http/detail/status_recorder.hpp | 9 +- .../scripts/check_doc_test_citations.py | 193 ++++ .../src/core/auth/auth_config.cpp | 646 +------------ .../src/core/auth/auth_manager.cpp | 17 +- .../src/core/http/detail/primitives.cpp | 8 + .../src/core/openapi/document_checks.cpp | 30 + .../src/core/openapi/route_registry.cpp | 273 +++++- .../src/http/handlers/discovery_handlers.cpp | 11 +- .../src/http/handlers/docs_handlers.cpp | 36 +- .../src/http/handlers/health_handlers.cpp | 16 +- .../src/http/rest_server.cpp | 268 +++++- .../src/openapi/capability_generator.cpp | 883 ++++++++++-------- .../src/openapi/capability_generator.hpp | 175 +++- .../src/openapi/openapi_spec_builder.cpp | 23 +- .../src/openapi/openapi_spec_builder.hpp | 15 +- .../src/openapi/path_builder.cpp | 397 -------- .../src/openapi/path_builder.hpp | 43 +- .../src/openapi/route_registry.hpp | 139 ++- .../test/test_auth_config.cpp | 159 +--- .../test/test_auth_manager.cpp | 97 ++ .../test/test_capability_generator.cpp | 266 +++++- .../test/test_discovery_handlers.cpp | 14 +- .../test/test_docs_handlers.cpp | 58 +- .../test/test_path_builder.cpp | 283 ------ .../test/test_route_descriptions.cpp | 68 ++ .../test/test_route_registry.cpp | 102 +- .../test/test_typed_route_registry.cpp | 46 +- .../ros2_medkit_test_utils/launch_helpers.py | 35 +- .../test/features/test_auth.test.py | 36 +- .../test/features/test_docs_endpoint.test.py | 44 + .../test_graph_provider_plugin.test.py | 11 + .../test/features/test_health.test.py | 27 + .../features/test_openapi_contract.test.py | 180 +++- .../test_openapi_error_coverage.test.py | 35 +- .../test/features/test_rbac_contract.test.py | 494 ++++++++++ .../ros2_medkit_graph_provider/README.md | 6 +- .../src/graph_provider_plugin_exports.cpp | 175 ++++ 53 files changed, 4508 insertions(+), 2070 deletions(-) create mode 100644 src/ros2_medkit_gateway/design/openapi_derivation.rst create mode 100644 src/ros2_medkit_gateway/scripts/check_doc_test_citations.py create mode 100644 src/ros2_medkit_integration_tests/test/features/test_rbac_contract.test.py diff --git a/docs/api/rest.rst b/docs/api/rest.rst index c50bb7906..a4bf4fb38 100644 --- a/docs/api/rest.rst +++ b/docs/api/rest.rst @@ -2398,10 +2398,56 @@ RFC 6749 clients default to. ``/auth/revoke`` accepts JSON only, and per RFC 7009 section 2.2 answers ``200`` whether or not the submitted token was valid - so it never returns ``401``. +These three endpoints are the only ones the middleware lets through +unauthenticated whatever ``require_auth_for`` says - a caller has to be able to +obtain a token before it has one. They are also the only operations the served +OpenAPI document publishes with an empty ``security: []`` requirement; every +other operation names the role the gateway's permission table grants for its +path, so ``GET /api/v1/docs`` is where a client reads which role an endpoint +needs. See :ref:`rest-role-required` below. + .. seealso:: :doc:`/tutorials/authentication` for configuration details. +.. _rest-role-required: + +Which role an endpoint needs +---------------------------- + +Every route declares its weakest permitted caller where it is registered, and +that one declaration produces both the entries the middleware matches against +and the ``security`` requirement published for the operation. The served +document is therefore the reference: read +``paths...security[0].bearerAuth[0]`` from +``GET /api/v1/docs``. + +The shape of the assignment: + +* ``viewer`` - every ``GET`` the gateway itself serves, including the SSE + streams, the bulk-data downloads and the capability descriptions. Routes a + plugin mounts are the exception and are ``admin`` whatever their method (see + below). +* ``operator`` - runtime writes: operation executions, clearing faults, + publishing data, locks, cyclic subscriptions, triggers, fault-trigger rules, + bulk-data upload and delete, starting and controlling script executions, and + the ``start`` / ``restart`` / ``force-restart`` lifecycle transitions. +* ``configurator`` - changes to how the system is configured: configuration + writes and resets, log configuration, script upload and delete, the + ``/updates`` write verbs (register, prepare, execute, automated, delete - + reading an update or its status is ``viewer``), and the ``shutdown`` / + ``force-shutdown`` transitions. +* ``admin`` - everything above, plus every route mounted outside the route + registry. That is what covers plugin-served routes, which no per-route + declaration describes; they publish ``admin`` and nothing weaker reaches + them. + +Enforcement fails closed - a path no entry matches is refused - so the +published role is what the gateway demands rather than a separate claim about +it. What is enforced at all is a deployment setting: with ``auth.enabled`` +false no role is published or checked, and with ``require_auth_for: write`` a +``GET`` is served without a token even though its operation names a role. + ``POST /api/v1/auth/authorize`` Authenticate with client credentials. @@ -3097,27 +3143,33 @@ use cases benefit. The SOVD spec defines resource collections only for apps and components. ros2_medkit extends this to areas and functions where aggregation makes practical sense. -The matrix below transcribes ``EntityCapabilities::for_type``. That drives the -paths in an entity's ``/docs`` sub-document, and the collection check in -``validate_collection_access_typed``. It is **not** where the ``capabilities`` -array of ``GET /{entity-type}/{id}`` comes from: that array is built from a -second, independent list, the ``CapabilityBuilder::Capability`` vector each -handler in ``discovery_handlers.cpp`` assembles. The two surfaces overlap but -are not the same set - the component array also carries ``status``, +The matrix below transcribes ``EntityCapabilities::for_type``, which drives the +collection check in ``validate_collection_access_typed``. It is **not** where +the ``capabilities`` array of ``GET /{entity-type}/{id}`` comes from: that array +is built from a second, independent list, the ``CapabilityBuilder::Capability`` +vector each handler in ``discovery_handlers.cpp`` assembles. The two surfaces +overlap but are not the same set - the component array also carries ``status``, ``subcomponents``, ``hosts`` and ``depends-on``, and the area array ``subareas``, ``contains`` and ``components``, none of which are resource collections and so none of which appear in the table. +Nor is it what an entity's ``/docs`` sub-document lists. That document is a +projection of the routes the gateway registers (see `Capability Description +(OpenAPI Docs)`_ below), so a collection appears there when a route answers it +and not otherwise - the table cannot make it appear or disappear. + The transcription is by hand. What is checked mechanically is the property the table exists to describe: ``test_openapi_contract::test_every_advertised_collection_is_served`` takes the first discovered entity of each type, follows every non-templated ``href`` in -its ``capabilities`` array **and** every path in its ``/docs`` sub-document -against a live gateway, and fails on a 404 - so it covers both surfaces, -including where they disagree. Its fixture discovers no areas, so the Areas -column below is covered by the ``EntityCapabilities`` unit tests instead, which -assert the per-type lists directly. ``501`` is a served answer, not a missing -one: see ``data-categories`` and ``data-groups`` below. +its ``capabilities`` array **and** every path in its ``/docs`` sub-document that +declares a ``GET`` against a live gateway, and fails on a 404 - so it covers +both surfaces, including where they disagree. Paths with no ``GET`` are skipped +because a 404 there says nothing: ``PUT /{type}/{id}/status/restart`` has no GET +to answer. Its fixture discovers no areas, so the Areas column below is covered +by the ``EntityCapabilities`` unit tests instead, which assert the per-type +lists directly. ``501`` is a served answer, not a missing one: see +``data-categories`` and ``data-groups`` below. Collections named by the SOVD standard that the gateway does **not** serve per entity - ``data-lists``, ``modes`` and ``communication-logs`` - are absent @@ -3219,8 +3271,8 @@ differently, which is worth stating rather than leaving to be discovered: - ``locks``: the routes are always registered for components and apps and answer ``501`` when there is no lock manager (``locking.enabled`` off). The ``capabilities`` entry and the ``locks`` URI field follow the lock manager; the - ``/docs`` sub-document lists ``/locks`` unconditionally, because - ``for_type(COMPONENT)`` does. + ``/docs`` sub-document lists ``/locks`` unconditionally, because registration + is unconditional and the sub-document reports registrations. - ``scripts``: the same shape. ``ScriptManager`` is constructed unconditionally, so all eight script routes are always registered for components and apps, and they answer ``501`` until a backend exists - either a plugin @@ -3313,26 +3365,60 @@ The gateway provides self-describing OpenAPI 3.1.0 capability descriptions at an of the API hierarchy. Append ``/docs`` to any valid path to receive a context-scoped OpenAPI spec describing the available operations at that level. +How much of that description is derived from the handlers rather than asserted +beside them - and, for each mechanism, what keeps the two from drifting apart - +is set out in :doc:`/design/ros2_medkit_gateway/openapi_derivation`. + +Every scoped spec is a **projection of the root document**: the paths at or +below the requested path, with the ids the request named substituted into the +templates and the ``in: path`` parameters those substitutions answered removed. +For a projected path, what a scoped spec says about an operation is what the +root spec says about it - status codes, schemas, roles and all - and a +collection appears in it exactly when a route answers that collection. + +The exception is the concrete data and operation item paths described below, +which are built from the entity cache rather than projected. They carry the ROS +2 payload schema, which is why they exist; because they are built rather than +projected, nothing reaches them *from* the registration, so what they say about +an operation is narrower than what the root spec says about the templated route +they sit beside. Measured on a component's ``/data`` spec: the projected +``GET /data/{data_id}`` declares ``200, 400, 404, 416, 500, 503`` and the +``PUT`` declares ``200, 400, 404, 409, 416, 500``, while a concrete +``/data/`` declares ``200, 400, 404, 500`` on both - no 416, no 409 on +the lock-guarded write, and with ``auth.enabled`` on no ``security`` +requirement either. Read the templated sibling beside them for the full outcome +set. + ``GET /api/v1/docs`` Returns the full OpenAPI spec for the gateway root, including all server-level endpoints, entity collections, and global resources. ``GET /api/v1/{entity-collection}/docs`` - Returns a spec scoped to the entity collection (e.g., ``/apps/docs``, - ``/components/docs``). Includes collection listing and detail endpoints. + The subtree under the collection (e.g. ``/apps/docs``, ``/components/docs``): + the listing, the entity detail template, and everything below it. ``GET /api/v1/{entity-type}/{entity-id}/docs`` - Returns a spec for a specific entity, including all resource collection - endpoints supported by that entity (data, operations, configurations, faults, - logs, bulk-data, cyclic-subscriptions, triggers). + The subtree under one entity, with its id substituted - the detail endpoint + and every resource route registered for that entity type. Templates deeper + than the entity (``{data_id}``, ``{fault_code}``) stay templated and keep + their parameters. ``GET /api/v1/{entity-type}/{entity-id}/{resource}/docs`` - Returns a spec for a specific resource collection, with detailed schemas - for each resource item. + The subtree under one resource collection. For ``data`` and ``operations`` + this also carries one concrete path per discovered topic / service / action, + whose payload schema is generated from the ROS 2 type - the one thing a route + registration cannot know, and the only part of any scoped spec that is not a + projection. + +Each scoped spec carries the ``components/schemas`` entries its own ``$ref`` +chains reach, not the full DTO set the root spec ships. **Features:** -- Specs include SOVD extensions (``x-sovd-version``, ``x-sovd-data-category``) +- Specs include SOVD extensions: ``x-sovd-version`` on every spec, and + ``x-sovd-data-category`` / ``x-sovd-name`` / + ``x-sovd-cyclic-subscription-supported`` on the concrete data and operation + item paths described above - Each operation declares exactly one success status, derived from the handler's C++ return type. The few operations whose handler can genuinely answer with one of several success shapes (``POST .../operations/{operation_id}/executions``, @@ -3340,10 +3426,31 @@ OpenAPI spec describing the available operations at that level. ``DELETE .../configurations``) carry ``x-medkit-alternates: true`` and list every alternative under its own status code. A generated client can therefore branch on status only where that marker is present. -- Entity-level specs reflect actual capabilities from the runtime entity cache -- Specs are cached per entity cache generation for performance -- Plugin-registered vendor routes appear in path-scoped specs when the requested - path matches a plugin route prefix (not in the root spec) +- The concrete data and operation item paths in an entity-level or + resource-level spec come from the runtime entity cache, so they change as the + ROS 2 graph does +- Specs are cached per entity cache generation for performance. The cache holds + each document serialized, not parsed, so what it costs in memory is close to + what the document costs on the wire; it is bounded both by entry count and by + total bytes, and is emptied whenever either bound is reached or the entity + cache generation changes. A document larger than the whole byte budget is + served but not cached. None of this is observable from a response: the two + ``/docs`` routes answer ``application/json`` with the same 2-space-indented + body whether it came from the cache or was just generated +- The two ``/docs`` routes are themselves in the root spec, as + ``getCapabilityDescription`` (``/docs``) and ``getScopedCapabilityDescription`` + (``/{entity_path}/docs``). The second one's ``entity_path`` parameter spans + several path segments - it is the whole prefix, e.g. ``apps/temp_sensor/data`` - + so a generated client must send its slashes unescaped. +- Routes mounted by a loaded plugin are in the root spec too, provided the plugin + exports ``describe_plugin_routes`` (see :doc:`/tutorials/plugin-system`). They + carry ``x-medkit-plugin-served: true``, and the tag each one declares is added + to the document's global tag list. A plugin route whose path lies under a + scoped path appears in that scoped spec as well, for the same reason a + registry route does - both are projected from the same merged set. Where a + plugin describes a path the registry already holds, the registry's description + is the one published. A plugin that does not export the symbol serves routes + that appear nowhere in any spec. **Configuration:** diff --git a/docs/tutorials/authentication.rst b/docs/tutorials/authentication.rst index 3c1431f21..301ab65bd 100644 --- a/docs/tutorials/authentication.rst +++ b/docs/tutorials/authentication.rst @@ -39,16 +39,23 @@ The gateway supports three authentication modes via the ``require_auth_for`` par Roles and Permissions --------------------- +Roles are cumulative: each one may do everything the row above it may do, plus +its own column. There is no inheritance in the stored table - the gateway +expands each route's declared role upward when it builds the table - but the +effect for a caller is the ladder below. + .. list-table:: - :widths: 20 15 15 20 15 15 + :widths: 18 14 14 14 20 20 :header-rows: 1 * - Role - Read (GET) - Data (PUT) - - Operations (POST) - - Config (PUT/DEL) - - Faults (DEL) + - Operations, faults, locks, subscriptions, triggers, bulk-data, + script runs, start/restart + - Configurations, log configuration, script upload/delete, updates, + shutdown + - Plugin-served routes * - ``viewer`` - ✅ - ❌ @@ -74,6 +81,38 @@ Roles and Permissions - ✅ - ✅ +Clearing faults is ``operator``, not ``admin`` - it is a runtime action, not a +change to how the system is configured. Tearing an entity down +(``PUT /{entity}/status/shutdown`` and ``force-shutdown``) is ``configurator``, +while bringing one up or restarting it is ``operator``. + +Where the table comes from +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Each route declares its own weakest caller at the point it is registered, and +that single declaration produces both halves of the contract: + +* the permission entries ``AuthManager::check_authorization`` matches against, + and +* the ``security`` requirement the served OpenAPI document publishes for that + operation - ``GET /api/v1/docs`` names the role each endpoint needs. + +Enforcement fails closed: a path no entry matches is refused, so an endpoint's +published role is the role the gateway actually demands rather than a separate +claim about it. + +Two consequences worth knowing: + +* **Plugin-served routes are ``admin``-only.** A plugin mounts its routes + directly on the HTTP server, outside the route registry, so no per-route + declaration describes them. They are covered only by the admin wildcards, and + the document publishes ``admin`` for them. +* **The document reflects this deployment, not the product.** With + ``auth.enabled`` false the gateway serves every endpoint unauthenticated and + the document publishes no roles at all. With it true, ``require_auth_for`` + still decides how much is checked - under ``write`` a GET is served without a + token even though its operation names the role the table would grant. + Basic Setup ----------- diff --git a/docs/tutorials/graph-provider.rst b/docs/tutorials/graph-provider.rst index 8e34255b9..46c8b4424 100644 --- a/docs/tutorials/graph-provider.rst +++ b/docs/tutorials/graph-provider.rst @@ -213,8 +213,10 @@ The Discovery Path curl http://localhost:8080/api/v1/functions | jq -2. **Read the Function's detail** and follow its capability href. Every - Function detail response carries an ``"x-medkit-graph"`` link: +2. **Read the Function's detail** and follow its capability href. A Function + detail response carries an ``"x-medkit-graph"`` link exactly while this + plugin is loaded - the key is absent on a gateway running without it, + because nothing would answer at that URI: .. code-block:: bash diff --git a/docs/tutorials/plugin-system.rst b/docs/tutorials/plugin-system.rst index e6c19677e..ea90df151 100644 --- a/docs/tutorials/plugin-system.rst +++ b/docs/tutorials/plugin-system.rst @@ -461,6 +461,73 @@ For entity-scoped endpoints, register a matching capability via ``register_capab or ``register_entity_capability()`` in ``set_context()`` so the endpoint appears in the entity's capabilities array in discovery responses. +Documenting a Plugin Route +-------------------------- + +``get_routes()`` mounts a route; it does not describe one. A plugin route is +mounted straight onto the HTTP server by ``PluginManager``, so the gateway's +``RouteRegistry`` - the source of the OpenAPI document - knows nothing about it, +and a client reading ``GET /api/v1/docs`` sees no trace of it. + +Export the optional C symbol ``describe_plugin_routes`` to fix that. The gateway +resolves it with ``dlsym`` for each loaded plugin, skipping any that does not +have it, and folds what it returns into the root document served at +``GET /api/v1/docs``: + +.. code-block:: cpp + + #include "ros2_medkit_gateway/core/openapi/route_descriptions.hpp" + + extern "C" GATEWAY_PLUGIN_EXPORT openapi::RouteDescriptions describe_plugin_routes() { + openapi::RouteDescriptionBuilder builder; + + openapi::OperationDesc op; + op.tag("Traces") + .operation_id("getAppTraces") + .requires_role("admin") + .description("What the endpoint does, and what a caller has to know about it.") + .path_param("app_id", "The app identifier") + .response(200, openapi::SchemaDesc::object() + .property("entity", openapi::SchemaDesc::string()) + .required({"entity"}), + "Traces for the app") + .error_response(404, "GenericError"); + + builder.add("/apps/{app_id}/x-medkit-traces") + .summary("Get app traces") + .get(std::move(op)); + + return builder.build(); + } + +The path key is the OpenAPI template a client fills in, not the cpp-httplib +regex ``get_routes()`` mounts - keep the two in step by hand, because nothing +checks that they agree. + +What the gateway adds, so a plugin need not: + +- ``x-medkit-plugin-served: true`` on every folded operation. It is what tells + the test suite that the emitted-status recorder is structurally unable to + observe this route, rather than that a run failed to reach it. +- ``416``, which cpp-httplib answers for an unparseable ``Range`` header before + routing, on every operation. +- A global tag entry for whatever tag the operation declares. + +What the plugin owns, and what the document contract requires of it: + +- A **unique** ``operationId``. It shares one namespace with every gateway + operation, and a collision turns the contract test red. +- A ``summary`` (on the path) and a ``description`` (on the operation). +- The role in ``requires_role()`` must be the one ``AuthConfig``'s permission + table grants for that path. Note that ``*`` in that table matches a single + path segment, so a vendor collection under ``/functions/{id}/...`` is not + covered by the ``viewer`` entries for ``/functions/*``. +- Every error status the handler can answer, as an ``error_response(...)`` + reference to ``GenericError`` - the body ``PluginResponse::send_error`` writes. + +``route_descriptions.hpp`` is header-only and free of ``httplib``, so including +it does not couple the plugin to the gateway's vendored copy. + Cyclic Subscription Extensions ------------------------------- @@ -819,6 +886,9 @@ each SOVD ``Function`` entity. It lives in a separate colcon package, (``healthy``, ``degraded``, or ``broken``). - Supports cyclic subscriptions on the ``x-medkit-graph`` collection so clients can stream live graph updates. +- Exports ``describe_plugin_routes``, so its endpoint and the shape of the graph + document appear in ``GET /api/v1/docs`` like any gateway route. It is the worked + example for `Documenting a Plugin Route`_. **Package layout** diff --git a/src/ros2_medkit_gateway/CMakeLists.txt b/src/ros2_medkit_gateway/CMakeLists.txt index 0e3e29a65..078e43b9b 100644 --- a/src/ros2_medkit_gateway/CMakeLists.txt +++ b/src/ros2_medkit_gateway/CMakeLists.txt @@ -379,6 +379,18 @@ if(BUILD_TESTING) ) set_tests_properties(gateway_error_codes_documented PROPERTIES LABELS "linter") + # ─── Design-document test citations ─────────────────────────────────────── + # Fails if a design document names a test that no longer exists. The docs + # say which mechanism is enforced by which check, and they say it by naming + # the check; a rename in the test tree falsifies every such sentence at once + # and nothing else would notice. + add_test( + NAME gateway_doc_test_citations + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/scripts/check_doc_test_citations.py" + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + ) + set_tests_properties(gateway_doc_test_citations PROPERTIES LABELS "linter") + # ─── gateway_core link-time smoke test ──────────────────────────────────── # Compiles a translation unit including a sampling of core/ headers and # links exclusively against gateway_core + GTest. No ament_target_dependencies diff --git a/src/ros2_medkit_gateway/README.md b/src/ros2_medkit_gateway/README.md index 7a85a778b..1b1edba9b 100644 --- a/src/ros2_medkit_gateway/README.md +++ b/src/ros2_medkit_gateway/README.md @@ -124,9 +124,14 @@ overloading the SOVD `/triggers` contract. ### API Documentation (OpenAPI) - `GET /api/v1/docs` - Full OpenAPI 3.1.0 specification -- `GET /api/v1/{entity_type}/{id}/docs` - Entity-scoped OpenAPI spec +- `GET /api/v1/{entity_path}/docs` - Scoped OpenAPI spec, where `{entity_path}` is any + entity or resource path (`apps`, `apps/temp_sensor`, `apps/temp_sensor/data`, ...) - `GET /api/v1/swagger-ui` - Interactive Swagger UI (requires build with `-DENABLE_SWAGGER_UI=ON`) +Both `/docs` routes are described in the document they serve. So are the routes a +loaded plugin mounts, provided it exports `describe_plugin_routes`; those carry +`x-medkit-plugin-served: true`. + ### Status and Lifecycle Endpoints - `GET /api/v1/apps/{app_id}/status` - Read app lifecycle status (`ready` or `notReady`) @@ -151,7 +156,10 @@ overloading the SOVD `/triggers` contract. - `GET /api/v1/{entity}/{id}/x-medkit-procfs` - Process info (procfs plugin) - `GET /api/v1/{entity}/{id}/x-medkit-systemd` - Systemd unit status - `GET /api/v1/{entity}/{id}/x-medkit-container` - Container runtime info -- `GET /api/v1/{entity}/{id}/x-medkit-graph` - ROS 2 graph details +- `GET /api/v1/functions/{function_id}/x-medkit-graph` - ROS 2 dataflow graph for a + Function. Functions only: the graph provider mounts the route on + `functions/([^/]+)/x-medkit-graph` and registers the capability for that entity type + alone. ### API Reference diff --git a/src/ros2_medkit_gateway/design/dto_contract.rst b/src/ros2_medkit_gateway/design/dto_contract.rst index bbcd40aa4..0994b13ef 100644 --- a/src/ros2_medkit_gateway/design/dto_contract.rst +++ b/src/ros2_medkit_gateway/design/dto_contract.rst @@ -37,6 +37,15 @@ by three template visitors to produce the wire JSON, the OpenAPI schema, and the request-body parser. Adding a field to the struct and its descriptor automatically updates all three outputs. +The same argument governs everything the document says about a *route*, not +just about a payload, and it does not always reach the same strength. +:doc:`openapi_derivation` states the rule that produced the mechanisms below - +if a fact can be derived from the handler it must not be declared separately, +and where it cannot the declaration lives at a seam that also does the work - +and records which enforcement tier each mechanism actually reaches. Read it +first if the question is "what stops this from going stale?" rather than "how +do I use it?". + Architecture ------------ @@ -91,7 +100,7 @@ erasure, and no separate code-generation step are needed. + multipart_upload(path, handler) + static_asset(path, handler) + docs_endpoint(path, handler) - + docs_subtree(regex, handler) + + docs_subtree(openapi_path, regex, handler) } class OpenApiSpecBuilder { @@ -371,6 +380,8 @@ plus, on POST / PUT / PATCH overloads, an already-parsed ``TBody``: // ... return result ... }); +.. _success-status-from-the-return-type: + Success Status Lives in the Return Type ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -674,9 +685,10 @@ Further ``RouteEntry`` knobs shape the published operation: Unlike everything else in this section, **this one is declared and not derived, and its test only checks half of it.** The header read that decides the 409 lives in ``HandlerContext::validate_lock_access``, which 12 handlers - across 6 files call, and the document is regenerated per ``/docs`` request - rather than captured at registration time - so a registration cannot see - through that call, and no accessor on ``TypedRequest`` changes that. + across 6 files call, and the document is built from the live route table when + ``/docs`` is served rather than captured at registration time - so a + registration cannot see through that call, and no accessor on + ``TypedRequest`` changes that. ``test_openapi_contract.test.py::test_lock_guarded_set_matches_the_handlers`` pins the marked set against ``EXPECTED_LOCK_GUARDED``, a hand-maintained literal committed next to the test. That catches the **document** drifting @@ -932,8 +944,14 @@ list: - anything cpp-httplib answers itself - 404/405 for an unrouted request, 413 over ``set_payload_max_length``, 416 for an **unparseable** ``Range`` (an unsatisfiable-but-parseable one yields 206, not 416); -- routes mounted straight onto the server rather than through the registry - (``/docs``, the Swagger UI subtree); +- routes mounted straight onto the server rather than through the registry - + the Swagger UI subtree in ``-DENABLE_SWAGGER_UI=ON`` builds, the status + recorder's own coverage endpoint, and anything a plugin mounts through + ``PluginManager::register_routes``. The two ``/docs`` routes are **not** in + this group: they are ``docs_endpoint`` / ``docs_subtree`` registrations, so + ``register_all`` mounts them and the recorder wraps them like any other + route. A plugin-served operation is excluded from the coverage assertion by + its ``x-medkit-plugin-served`` marker rather than by a list; - statuses on branches no test run drives - a provider that reports ``AccessDenied``, a fault store that cannot be read, an update already in flight. These are the ``errors({...})`` calls in ``rest_server.cpp`` that @@ -1069,9 +1087,14 @@ remain compile-time-checked at their boundary. at the given path. The handler returns ``Result``; this is the only built-in route allowed to use raw ``nlohmann::json`` as ``TResponse``, because the body is the spec itself. -- ``reg.docs_subtree(regex, handler)`` - catch-all for the Swagger UI subtree - (asset paths without a fixed shape). Hidden from the OpenAPI output so it - does not pollute the generated spec. +- ``reg.docs_subtree(openapi_path, regex, handler)`` - a route whose URI is a + cpp-httplib regex the OpenAPI path grammar cannot express. Its one caller + outside the unit tests is the ``/docs`` sub-document, mounted on + ``(.+)/docs$`` because the prefix it captures is a whole entity or resource + path. ``openapi_path`` + is what the document publishes and ``regex`` is what cpp-httplib matches; + they are separate arguments so the regex never reaches the document as a + path key. - ``reg.post_alternates(path, handler)`` / ``reg.del_alternates(path, handler)`` - register multi-shape responses. The active variant alternative is dispatched to its @@ -1311,6 +1334,17 @@ no visibility of either component block, so only the assembled document can answer the question. Its unit tests are ``test_schema_reachability``, which links ``gateway_core`` - the function touches no ROS type. +``openapi::referenced_schemas(subtree, pool)`` in the same header is the inverse +walk, and it exists for the scoped ``/docs`` documents. Those are a +projection of the root document's ``paths`` (``paths_under()`` filters by path +segment, ``strip_entity_path_parameter()`` removes the ``in: path`` parameter a +substituted id answered), so their operations ``$ref`` named schemas while the +document carries only what ``OpenApiSpecBuilder`` always emits. Shipping the +whole ``AllDtos`` pool on every entity page is the alternative; +``referenced_schemas`` ships the transitive closure the slice actually reaches. +``CapabilityGeneratorTest.SubDocumentCarriesTheSchemasItReferences`` walks every +``$ref`` in five scoped documents and fails on one that resolves to nothing. + Optional fields are now emitted as ``anyOf: [, {type: "null"}]`` (OpenAPI 3.1 idiom) so generated clients see ``T | null`` rather than ``T | undefined``. That matches the wire reality of the gateway: optional @@ -1427,13 +1461,70 @@ checklist plus the DTO steps above: ``reg.get`` / ``reg.post`` / ``reg.del`` / the matching alternates or escape-hatch helper. Use the dual-path pattern for entity types that share the same route shape. -5. Update ``handle_root`` endpoint list in ``health_handlers.cpp`` to mirror +5. Declare the route's weakest permitted caller with + ``.requires_role(UserRole::...)`` - see "Route Authorization" below. This is + not optional: ``validate_completeness()`` reports a route without it as an + error, because authorization fails closed and the route would answer 403 for + every role below ADMIN. +6. Update ``handle_root`` endpoint list in ``health_handlers.cpp`` to mirror the new route. -6. Add URI field to entity detail response if the new route is a resource +7. Add URI field to entity detail response if the new route is a resource collection. -7. Write a unit test using ``JsonWriter::write()`` and +8. Write a unit test using ``JsonWriter::write()`` and ``JsonReader::read()`` directly - no HTTP server needed. -8. Write an integration test that calls the live endpoint. +9. Write an integration test that calls the live endpoint. + +Route Authorization +------------------- + +A route's RBAC rule is a property of the route, so it is declared where the +route is registered: + +.. code-block:: cpp + + reg.put(...) + .tag("Configuration") + .requires_role(UserRole::CONFIGURATOR) + ... + +That one call feeds two consumers, which is the whole reason it lives on the +registration rather than in a table beside it: + +- ``RouteRegistry::route_permissions(api_prefix)`` turns it into the + ``":"`` entries ``AuthManager::check_authorization`` + matches against. ``RESTServer::setup_routes()`` merges those into the manager + before the server starts listening, together with + ``AuthConfig::residual_route_permissions()`` for the routes the registry never + sees (plugin routes, Swagger UI, the test-build status recorder). +- ``to_openapi_paths()`` publishes it as + ``security: [{bearerAuth: []}]`` on the operation - the same shape a + plugin's ``OperationDesc::requires_role`` emits. + ``CapabilityGenerator::generate_impl()`` strips every per-operation + requirement again when ``auth.enabled`` is false, once, over the assembled + document. + +Two translations happen inside ``route_permissions()``: + +- The pattern is derived from the route's **cpp-httplib regex**, not from its + OpenAPI path. ``([^/]+)`` becomes ``*`` and ``(.+)`` becomes ``**``, which is + what keeps the slash-spanning parameters (``{data_id}``, ``{config_id}``) and + the ``/docs`` catch-all reachable. Deriving from ``{param}`` + alone would make all three single-segment. +- Roles are expanded upward. ``AuthConfig`` has no inheritance - + ``check_authorization`` looks up exactly one role's set - so a route + declaring ``OPERATOR`` is written into OPERATOR, CONFIGURATOR and ADMIN. + +``public_route()`` is the only alternative to ``requires_role()``, and it is +legitimate only where the middleware exempts the path before the table is +consulted at all - today ``/auth/*``, which both ``AllAuthRequirementPolicy`` +and ``WriteOnlyAuthRequirementPolicy`` let through by prefix. It emits +``security: []`` and contributes no permission entry. + +The declaration is required on ``hidden()`` routes too. Hidden removes a route +from the document, not from the router: the request still arrives and still +meets the permission table. +``test_rbac_contract.test.py`` is the end-to-end check that the published role +and the enforced role are the same role. Collection Parametrisation --------------------------------------- diff --git a/src/ros2_medkit_gateway/design/index.rst b/src/ros2_medkit_gateway/design/index.rst index 639a72bd2..cded4e2ad 100644 --- a/src/ros2_medkit_gateway/design/index.rst +++ b/src/ros2_medkit_gateway/design/index.rst @@ -676,5 +676,6 @@ Additional Design Documents entity_cache_architecture hardening lifecycle + openapi_derivation plugin_entity_notifications ros2_subscription_architecture diff --git a/src/ros2_medkit_gateway/design/openapi_derivation.rst b/src/ros2_medkit_gateway/design/openapi_derivation.rst new file mode 100644 index 000000000..1a99d7eb4 --- /dev/null +++ b/src/ros2_medkit_gateway/design/openapi_derivation.rst @@ -0,0 +1,591 @@ +OpenAPI Derivation - The Rule and the Tiers +=========================================== + +The gateway serves its own OpenAPI document from ``/api/v1/docs``. That +document is generated by a running gateway from its own route table, not +shipped as a file beside it, so every statement in it is a statement this +process is making about itself - and every statement it gets wrong is one a +generated client will act on. + +This document is the frame: the one rule the mechanisms exist to serve, the +four enforcement tiers a mechanism can reach, and which tier each mechanism +actually reaches. The mechanisms themselves are documented next to the code +that implements them - :doc:`dto_contract` for the route builder and the DTO +layer, :doc:`/api/rest` for what a client sees, :doc:`/tutorials/authentication` +for roles. Nothing here restates them; a second copy of a mechanism's +description is the same defect at one remove. + +.. contents:: Table of Contents + :local: + :depth: 2 + +The rule +-------- + + **If a fact about a route can be derived from the handler, it must not be + declared separately. Where it cannot, the declaration lives at a seam that + also does the work.** + +Both halves matter, and the second is the one that is easy to skip. + +The first half is why a success status is a C++ return type rather than a +number typed beside the registration: ``Created`` is both what +the handler returns and what the document publishes, so there is no second +place for the two to disagree. Before that, operations advertised success +statuses their handler could not emit - not because anyone was careless, but +because the document and the handler were two artefacts with no mechanical +relationship between them. + +The second half covers everything the return type cannot carry. A feature gate +is not a type; a lock check is not a type; a role is not a type. The rule for +those is not "declare it" but *where* to declare it: on the call that already +does the thing. ``gated_on(available, unavailable)`` installs the availability +predicate **and** declares the status it answers with, in one call, because a +gate that is installed without being declared is exactly the state the document +was in. The same argument produces ``lock_guarded()`` (one call publishes the +header, the 409 and the marker) and ``requires_role()`` (one call feeds the +permission table and the ``security`` requirement). + +A declaration that sits beside the work rather than on it is a mirror, and +mirrors rot. That is the failure this design exists to remove, and it is the +reason the residual list at the end of this document is written down rather +than left to be rediscovered. + +The four enforcement tiers +-------------------------- + +.. list-table:: + :header-rows: 1 + :widths: 12 44 44 + + * - Tier + - Property + - What that costs an author who gets it wrong + * - **1 - compile-time** + - Declaration and behaviour are one expression. A forgotten site does not + compile. + - A build error, at the site. + * - **2 - mechanically test-enforced** + - A test derives the expected set from the code or from a run, and fails + on divergence. + - A red suite, naming the route. + * - **3 - declared once, at a seam that also does the work** + - One call both acts and declares, so the two cannot be added separately - + but a route that never calls it still compiles. + - Nothing, if the call is simply absent. Tier 3 needs a Tier 2 companion + to say which routes *should* have called it. + * - **4 - manual, presence checked** + - The content is human. Its absence is caught; its correctness is not. + - Nothing. A wrong description is as green as a right one. + +Tier 3 is the tier that is easiest to overstate. A decorator that both wraps +behaviour and declares it is materially more durable than a bare declaration +next to it, because the two cannot drift *once the call exists*. It says +nothing about a route that never made the call. Every Tier 3 mechanism below +therefore names what covers that second half - and where nothing does, it says +so. + +Where each mechanism sits +------------------------- + +Read the tier as the *weakest* link in the mechanism, not the strongest. Several +entries reach different tiers for different halves of what they claim, and those +are split into separate rows rather than averaged. + +.. list-table:: + :header-rows: 1 + :widths: 26 12 62 + + * - Mechanism + - Tier + - What holds it to the code + * - ``Created`` / ``Accepted``, ``status_payload_t`` + - 1 + - ``declare_derived_response`` reads the status from + ``dto_alternate_status`` and the schema from + ``status_payload_t``; ``write_success_body`` defaults from + the same trait. One type names the wire status, the declared status and + the body schema. + * - ``with_location`` - the obligation + - 1 + - The non-attachments overloads ``static_assert`` against + ``kStatusRequiresAttachments``: a return type fixing 201/202 + on an overload that gives the handler no header channel does not + compile. + * - ``with_location`` - the declaration + - 2 + - ``test_openapi_contract.test.py::test_every_created_or_accepted_declares_location`` + walks every operation's 201 and 202 and fails on a missing ``Location`` + header, with a non-zero-checked guard. + * - ``with_location`` - the call + - 4 + - Nothing forces a *pair-returning* handler to call it, and no check + enumerates the handlers that should. + ``test_openapi_contract.test.py::test_created_response_sends_the_location_it_declares`` + drives ``POST /apps/{app_id}/triggers`` and asserts both the header and + its absolute prefixed form; other 201/202 routes have a wire case of + their own - locks, fault-triggers, scripts, operation executions, + updates and bulk-data uploads among them. That is a set of hand-written + cases, not a rule over the 201/202 operations the document declares. + * - ``gated_on`` + - 3 + - The call installs the predicate and routes its status through + ``errors()``, so a gate cannot be added without its status. A route that + gates inside the handler lambda instead is invisible - which is the + state the trigger and update registrations were in. + * - Emitted-status recorder + - 2 + - ``test_openapi_error_coverage.test.py::test_every_emitted_status_is_declared`` + asserts **declared is a superset of observed** over a sweep derived from the served + document. It maintains no list, so a route added tomorrow is swept + tomorrow. + * - ``errors()`` - what a run reaches + - 2 + - The recorder, above. + * - ``errors()`` - what no run reaches + - 4 + - A provider reporting ``AccessDenied``, a fault store that cannot be + read. Declared by hand and marked at the call site. + * - ``lock_guarded`` - the contract + - 3 + - One call publishes the ``X-Client-Id`` header, the 409 and the + ``x-medkit-lock-guarded`` marker, so a route cannot publish two thirds + of it. + * - ``lock_guarded`` - the set + - 4 + - ``test_openapi_contract.test.py::test_lock_guarded_set_matches_the_handlers`` + pins the marked set against ``EXPECTED_LOCK_GUARDED``, a hand-maintained + literal. That catches the document drifting from the list, not the list + drifting from the handlers. + * - ``fan_out_aware`` + - 3 / 4 + - Same seam shape as ``lock_guarded``, with less behind it: no expected + set is pinned anywhere. + ``test_openapi_contract.test.py::test_no_fan_out_header_is_declared_as_a_string`` + checks the header's shape wherever it is declared, and that at least one + route declares it - it cannot see a route that should have and did not. + * - ``requires_role`` - the seam + - 3 + - One declaration feeds ``RouteRegistry::route_permissions()`` (what + ``AuthManager::check_authorization`` matches) and the operation's + ``security`` requirement. + * - ``requires_role`` - published vs enforced + - 2 + - ``test_rbac_contract.test.py::test_the_declared_role_is_admitted`` sends a + token of the published role to every non-SSE operation in the served + document and asserts the middleware admitted it; + ``test_rbac_contract.test.py::test_a_weaker_role_is_refused`` sends one + step down wherever there is a step, and asserts the count so a + derivation that granted everything to the weakest role leaves nothing to + check. + * - ``requires_role`` - presence + - 2 + - ``validate_completeness()`` reports a registration that declares neither + ``requires_role()`` nor ``public_route()`` as an **error**, including on + ``hidden()`` routes, and + ``test_openapi_contract.test.py::test_shipped_route_set_declares_complete_metadata`` + waits for a zero error count. + * - Lifecycle role policy + - 4 + - ``test_rbac_contract.test.py::test_lifecycle_transitions_publish_the_roles_the_policy_fixes`` + states the roles as a literal. The transition set and entity-type set are + read out of the document, so a new action fails rather than passing in + silence; only the roles are written down. + * - Residual permission list + - 4 + - ``AuthConfig::residual_route_permissions()``. See below. + * - ``success_schema`` on a plain JSON ``GET`` + - 2 + - ``test_openapi_response_drift.test.py::test_get_responses_match_declared_schema`` + validates a live body against the declared 200 schema. + * - ``success_schema`` anywhere else + - 4 + - Drift skips SSE-classified operations and non-JSON media, and covers no + ``POST`` or ``PUT`` at all. Each such use needs a wire assertion written + beside it or it rests on a reading. + * - Media types on a binary download + - 2 + - ``test_scenario_bulk_data_download.test.py::test_04_rosbag_media_type_is_named_in_the_document`` + and + ``test_scenario_bulk_data_upload.test.py::test_21_download_serves_a_media_type_the_document_declares`` + download real artifacts and assert the served ``Content-Type`` against + the document, distinguishing a named type from the ``*/*`` catch-all. + * - Operation ``description`` + - 4 + - ``test_openapi_contract.test.py::test_every_operation_has_a_description`` + gates presence on every operation. The words themselves are unchecked. + * - ``body_example`` + - 4 + - ``test_openapi_contract.test.py::test_non_trivial_request_bodies_carry_an_example`` + gates presence for the operations named in ``EXAMPLE_BODIES``, a + hand-maintained set, with a guard that fails if one of those operations + stops existing. + * - ``FieldConstraints`` + - 4 + - Read by ``SchemaWriter`` only. ``JsonReader`` does not enforce it, so a + published ``minimum`` is a claim about the handler. See below. + * - Path-parameter ``maxLength`` + - 4 / 2 + - The synthesiser table is hand-written; the two bounds it publishes are + driven on every verb that carries them by + ``test_configuration_api.test.py::test_06b_every_verb_rejects_an_oversized_config_id`` + and ``test_faults_api.test.py::test_both_verbs_reject_an_oversized_fault_code``. + * - ``/docs`` sub-documents + - n/a + - Not declared at all - a projection of the served routes. See + `Derivation with nothing left to declare`_. + +Tier 1: the type system +----------------------- + +One mechanism reaches it outright, and it is the one that mattered most: the +success status. ``dto_alternate_status`` maps the return type to a +status and ``status_payload_t`` unwraps it to the payload. Two +functions read that pair: the single-response registration entry points all +funnel through ``declare_derived_response``, and the variant-returning +``post_alternates`` / ``del_alternates`` helpers fold ``add_alternate_response`` +over each member of the variant. Writing ``.response(201, ...)`` beside a +handler that returns 200 is no longer possible to *mean* anything: the derived +response is already there, and a hand-attached 2xx would publish a second one. + +``with_location`` reaches Tier 1 for half of its contract. The registry declares +a ``Location`` header on every derived 201 and 202, because the return type +already fixed the status - so the obligation to send one is created by the same +type. The overloads that give a handler no way to set a header refuse that +return type at compile time rather than shipping a route which advertises a +header it structurally cannot send. What the type system cannot see is a +*pair-returning* handler that simply forgets the call, and no check derives the +set of handlers that owe one; that half is Tier 4 and the table above says so. + +Full detail: :ref:`success-status-from-the-return-type` in :doc:`dto_contract`. + +Tier 2: checked against a run +----------------------------- + +The recorder is the mechanism that notices what nobody wrote down. In test +builds only - gated on ``MEDKIT_STATUS_RECORDER``, which ``CMakeLists.txt`` sets +exactly when ``BUILD_TESTING`` is on - ``RouteRegistry::register_all`` wraps +every mounted handler in a scope that records the status that actually reached +``httplib::Response``, keyed by the route's own identity. The assertion is +**declared is a superset of observed**, and the sweep that drives it is derived +from the served document rather than from a list, so a route added tomorrow is +swept tomorrow without an edit. + +Three companion assertions stop it passing vacuously: the set of operations the +sweep leaves unreached must *equal* a stated literal, at least one observed +status must be outside the blanket 400/404/500 set, and a minimum number of +error-construction sites must have been reached. + +What a shipped gateway compiles depends on how it was configured, and the +honest statement is about the configuration rather than about every build. The +in-tree ``Dockerfile`` passes ``-DBUILD_TESTING=OFF``, so the published +container has ``make_error()`` byte-identical to what it was and +``register_all`` mounting the handler directly. A build that leaves +``BUILD_TESTING`` on serves the recorder's ``/api/v1/x-medkit-status-coverage`` +endpoint - which is why that endpoint is covered by the residual permission +list as ADMIN-only rather than left to fail closed by accident. + +The recorder is not the only Tier 2 mechanism, and the table above lists the +others: response drift for plain-JSON ``GET`` bodies, the RBAC probes, the media +types on binary downloads, and the start-up metadata report that +``test_openapi_contract.test.py::test_shipped_route_set_declares_complete_metadata`` +waits on. + +Full detail: :ref:`emitted-status-recorder` in :doc:`dto_contract`. + +Tier 3: seams that act and declare +---------------------------------- + +Four mechanisms are Tier 3, and each is one call that does two things: + +``gated_on(available, unavailable)`` + Installs the availability predicate on the route and declares + ``unavailable.http_status`` through ``errors()``. A gate written as an + inline ``if (!handlers_) return tl::unexpected(...)`` inside the handler + lambda is invisible to the generator - that was the state of the trigger + and update registrations before this existed. + +``lock_guarded()`` + Publishes the ``X-Client-Id`` request header (optional, deliberately), the + 409 the route answers when another client holds the lock, and the + ``x-medkit-lock-guarded`` operation extension. Three declarations in one + call because they are one contract. + +``fan_out_aware()`` + Publishes the ``X-Medkit-No-Fan-Out`` request header, as a bare string, + because the gateway tests ``has_header`` and never reads the value. + +``requires_role(role)`` + Produces the permission entry the enforcer matches against **and** the + ``security`` requirement the document publishes. + +One of the four has a Tier 2 companion covering the "did every route that should +have called it, call it?" half. The other three do not, for two different +reasons: + +* ``requires_role`` has one, twice over - ``validate_completeness()`` makes the + declaration mandatory and reports its absence as an error, and the RBAC + contract test probes the published role against the enforced one on every + non-SSE operation. +* ``gated_on`` does not need one in the same sense: the gate has no effect + unless the call is made, so a missing call is a missing *feature*, not a + missing declaration. +* ``lock_guarded`` and ``fan_out_aware`` have none. The fact they describe - + that the handler reads a request header, several call layers down in + ``HandlerContext::validate_lock_access`` or the fan-out helpers - is not + visible to a registration, and the document is built from the live route + table when ``/docs`` is served rather than captured at registration time, so + no accessor on ``TypedRequest`` would change that. ``lock_guarded`` at least has + ``EXPECTED_LOCK_GUARDED``, which catches the document drifting from the list + and not the list drifting from the handlers; ``fan_out_aware`` has no expected + set at all. Adding a lock check to a handler means editing that literal by + hand, and adding a fan-out read means remembering unaided. No build or test + failure will remind anyone in either case. + +Tier 4: what a person has to get right +-------------------------------------- + +Tier 4 is not a euphemism for unchecked. Presence is gated everywhere it can +be: an operation with no ``description`` fails, a route with no tag or no role +is reported at start-up, an ``EXAMPLE_BODIES`` entry naming an operation the +document lacks fails. What is not gated is whether the content is *true*. + +Three Tier 4 items are worth naming individually, because each is a value +written by hand that a reader could mistake for something derived. + +``FieldConstraints`` + ``SchemaWriter`` is the only reader. ``JsonReader`` does not validate + against these keywords, so publishing ``minimum: 1`` on + ``AcquireLockRequest.lock_expiration`` asserts that ``LockManager`` rejects + a non-positive expiration - and nothing ties the two together. The rule at + the call site is therefore "only declare a bound the handler enforces + unconditionally", and a bound that comes from configuration + (``locking.default_max_expiration``) belongs in the ``description`` + instead. Where a published bound is driven on the wire it is by a + hand-written case: + ``test_logging_api.test.py::test_app_put_logs_configuration_zero_max_entries_returns_400`` + is one. Others have none. + +``EXPECTED_LOCK_GUARDED`` + A literal committed next to the test, for the reason given above. + +``EXPECTED_LIFECYCLE_ROLES`` + Deliberately a literal, and the one place in the RBAC suite where that is a + feature rather than a compromise. Every other assertion there compares the + document with enforcement, and both come from one declaration - so changing + that declaration moves both sides together and no test notices. A value + derived from nothing is what turns the assertion into a statement about + policy. The transition set and the entity-type set beside it are read out of + the served document, so a sixth destructive action added as ``operator`` + fails rather than passing in silence. + +The derived permission table and its residual +--------------------------------------------- + +``RouteRegistry::route_permissions(api_prefix)`` walks the registrations and +emits the ``":"`` entries ``AuthManager::check_authorization`` +matches against. Patterns come from the route's cpp-httplib **regex**, not from +its OpenAPI path - ``([^/]+)`` becomes ``*`` and ``(.+)`` becomes ``**`` - which +is what keeps the slash-spanning parameters and the ``/docs`` +catch-all reachable. Roles are expanded upward, because ``AuthConfig`` stores no +inheritance and ``check_authorization`` looks up exactly one role's set. + +What the registry cannot see needs a residual, and +``AuthConfig::residual_route_permissions()`` is the whole of it: ADMIN's four +wildcards, ``GET`` / ``POST`` / ``PUT`` / ``DELETE`` on ``/api/v1/**``. Three +families of route are covered only by those: + +* routes a plugin mounts through ``PluginManager::register_routes``; +* the Swagger UI pages, in ``-DENABLE_SWAGGER_UI=ON`` builds; +* the emitted-status recorder's endpoint, in test builds. + +The residual is deliberately short and deliberately ADMIN-only. ``*`` stops at a +segment boundary, so no weaker role's entry reaches a plugin path such as +``/api/v1/functions//x-medkit-graph`` - only ``GET:/api/v1/**`` does, which +is why a plugin-served operation publishes ``admin`` as its role. Widening it +would hand that role every plugin route a deployment happens to load, sight +unseen. ``test_rbac_contract.test.py::test_a_plugin_route_stays_admin_only`` +drives that against a running gateway with the graph provider loaded. + +One deployment-shaped consequence, settled deliberately rather than by default: +the document is served **by a running gateway**, so with ``auth.enabled`` false +``CapabilityGenerator::generate_impl`` strips every per-operation ``security`` +requirement from the assembled document. Publishing a role on a deployment that +admits everyone would assert something the gateway does not honour. The scheme +definition stays either way, because a definition asserts nothing. + +Derivation with nothing left to declare +--------------------------------------- + +The ``/docs`` sub-documents are the limit case of the rule: not a +declaration held close to the work, but no declaration at all. + +Every scoped document is a projection of ``served_paths()`` - the registry's +own ``to_openapi_paths()`` merged with the paths loaded plugins describe - sliced +to the requested prefix, with the ids the caller named substituted into the +templates and the ``in: path`` parameters those substitutions answered removed. +What a scoped document says about an operation is therefore what the root +document says about it, because it *is* what the root document says about it. + +There is exactly one exception, and it exists because the fact it carries is not +in any registration: a concrete data or operation item path carries the ROS 2 +payload schema for one topic, service or action, which comes from the entity +cache. ``add_cache_derived_items`` is the whole of that exception, and because +those items are built rather than projected they are narrower than the templated +sibling beside them - :doc:`/api/rest` measures the difference. + +This replaced four hand-written producers - one per resolved path category - +and replacing them surfaced a defect in what they had been shipping. Their path +items referenced named schemas through ``SchemaBuilder::ref``, while none of the +four added a single schema to the document it built, so those ``$ref`` entries +resolved to nothing. The producers emitted something that looked like a document +and was not one. + +Both halves of that are now closed by construction. ``referenced_schemas`` +ships the transitive closure a slice reaches, rather than the whole ``AllDtos`` +pool on every entity page or - as before - nothing at all, and +``CapabilityGeneratorTest.SubDocumentCarriesTheSchemasItReferences`` fails on a +``$ref`` that resolves to nothing. And because a projected operation carries +whatever ``security`` its registration declared, ``build_subtree_document`` +registers the ``bearerAuth`` scheme those requirements name: an operation cannot +reference a scheme its own document does not define. The hand-written producers +had no such problem only because they published no ``security`` at all - they +advertised no roles for the same reason they advertised no schemas. + +The cost the projection made visible +------------------------------------ + +A projected sub-document is larger than the hand-written one it replaced, +because it carries everything the root document says rather than a summary. That +turned the document cache from a convenience into a memory question, and the +answer changed the cache rather than the projection. + +``CapabilityGenerator`` stores each document **serialized**, exactly as +``dump(2)`` produced it and exactly as the ``/docs`` routes write it. A parsed +DOM of the same document costs several times its serialized size resident - one +separately allocated node per value - and, when ``lookup_cache`` returned by +value, every hit deep-copied that DOM before serialising it again. +``generate_serialized`` is what serving code calls; ``generate`` parses on every +call and exists for tests that inspect structure. + +The cache is bounded twice, and both bounds are needed: + +* ``kDocsCacheMaxBytes`` (16 MiB) is the bound that matters, because an entry's + size is a function of how large the ROS 2 graph is, so an entry count alone + leaves the cache unbounded in bytes; +* ``kDocsCacheMaxEntries`` (256) bounds the per-entry hash node the map + allocates, which the byte budget does not account for. + +Eviction is clear-all, the key carries the entity cache generation so a graph +change invalidates everything, and a document larger than the whole byte budget +is served but not cached. None of this is observable from a response: both +``/docs`` routes answer ``application/json`` with the same body whether it came +from the cache or was just generated. + +What is deliberately not derived +-------------------------------- + +The rule has a boundary, and naming it is part of honouring it. Everything below +is left undeclared or hand-declared on purpose, so the next reader does not have +to re-derive which half is which. + +**Statuses no finite set describes.** + +* A plugin-clamped status. ``make_plugin_error`` in the data, fault, lifecycle + and operation handlers passes a provider-supplied status clamped only to + 400-599, and every value in that range is a status a plugin may pick. +* A healthy peer's status on a fan-out. The gateway copies it through verbatim. + No ``errors({...})`` describes "whatever the peer said", and choosing what the + document should promise is an aggregation-contract question, not a + documentation one. + +Their counterpart *is* declared, which is what makes the boundary a decision +rather than an omission: the statuses with a finite first-party range are +derived. ``handlers::parameter_error_statuses()`` runs the classifier over every +``ParameterErrorCode``, so a new enumerator widens the declaration with no edit +at any registration, and a switch with no ``default`` beside it makes +``-Werror=switch-enum`` fail the build if an enumerator is added without being +listed. The lock verbs have no enum, so their range is pinned behaviourally by +``LockManagerTest.extend_and_release_answer_only_400_403_404``. + +**Statuses no handler produces.** The rate limiter's 429 and the auth +middleware's 401 and 403 are answered ahead of routing. No return type can +describe them and no ``RouteEntry`` can carry their headers, so each is declared +once as a shared component response - ``Unauthorized`` with +``WWW-Authenticate``, ``Forbidden``, and ``RateLimited`` with ``Retry-After`` +and the ``X-RateLimit-*`` trio - and referenced on a route only while the +middleware that owns it is live. + +cpp-httplib's 416 for an unparseable ``Range`` is the fourth of that kind and +the only one gated on nothing: the parse happens in ``Server::process_request`` +before routing, on any path, including one that does not exist. It is therefore +declared on every operation rather than on the six download routes where sending +a ``Range`` is *useful*, and it references the ``GenericError`` body that +``RESTServer::setup_global_error_handlers`` fills the empty response with on the +way out. Reading only the vendored header would have published a body-less 416; +``test_openapi_contract.test.py::test_range_rejection_is_answered_on_a_route_that_declares_it`` +is what settled it, deliberately against ``/health`` rather than a download so +that the universality is the thing being proven. + +**Sets a registration cannot see.** ``EXPECTED_LOCK_GUARDED``, and the +``fan_out_aware`` set which is not written down anywhere, for the reason given +under Tier 3. + +**Prose.** Descriptions, examples and SOVD judgments are human. Presence is +gated; correctness is not. + +This document's own citations +----------------------------- + +The tables above are load-bearing precisely because they name checks. A test +renamed in the tree would falsify every sentence that cites it, at once, with +nothing to notice - which is the same defect shape this whole document is about. + +``scripts/check_doc_test_citations.py``, registered as the +``gateway_doc_test_citations`` linter test, resolves every test citation in +``src/ros2_medkit_gateway/design/*.rst`` and ``docs/api/*.rst`` against the test +tree. It recognises the three forms these documents use: a file-qualified +Python case, resolved against the ``def`` in that file; a GTest suite-and-case +pair, resolved against ``TEST`` / ``TEST_F`` / ``TEST_P``; and a bare test name, +resolved against a test source file, a Python case or a GTest case. The +direction is one-way - a cited test must exist; a test nothing cites is not a +defect - because only the first direction can make a document false. + +One consequence to know before writing about the checker itself: a literal +example of a citation *is* a citation as far as the parser is concerned, so +describe the forms rather than spelling a fictional one in double backticks. +That is not a weakness to work around - a parser that could tell an example +from a claim would be one that could be talked out of checking. + +Key files +--------- + +``src/openapi/route_registry.hpp`` + ``RouteEntry``'s fluent knobs (``gated_on``, ``lock_guarded``, + ``fan_out_aware``, ``requires_role``, ``errors``, ``only_status``, + ``success_schema``, ``body_example``, ``response_header``), the typed + registration entry points and their ``static_assert`` gates, and + ``declare_derived_response`` / ``declare_location_header``. + +``include/ros2_medkit_gateway/http/handler_result.hpp`` + ``Created``, ``Accepted``, ``NoContent``, ``ResponseAttachments`` and + ``with_location``. + +``include/ros2_medkit_gateway/http/alternate_status.hpp`` + ``dto_alternate_status``, ``status_payload_t`` and ``status_body``. + +``include/ros2_medkit_gateway/http/detail/status_recorder.hpp`` + The emitted-status recorder, behind ``MEDKIT_STATUS_RECORDER``. + +``include/ros2_medkit_gateway/dto/contract.hpp`` + ``FieldConstraints`` and the ``field()`` factories that carry it. + +``src/openapi/capability_generator.hpp`` / ``.cpp`` + The ``/docs`` projection, the serialized document cache and + its two bounds. + +``include/ros2_medkit_gateway/core/auth/auth_config.hpp`` + ``AuthConfig::residual_route_permissions()``. + +``scripts/check_doc_test_citations.py`` + The citation resolver described above. diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_config.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_config.hpp index 9a8150d17..b77acb99a 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_config.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_config.hpp @@ -40,6 +40,17 @@ enum class UserRole { ADMIN ///< Full access including auth management }; +/** + * @brief RBAC permission entries, keyed by role. + * + * Each entry is `":"`, where the pattern is matched by + * `AuthManager::matches_path`: `*` covers one path segment and `**` covers any + * number. There is no role inheritance - a role's set has to list everything + * that role may do, which is why the derivation in + * `RouteRegistry::route_permissions()` expands each declaration upward. + */ +using RoutePermissions = std::unordered_map>; + /** * @brief Authentication requirement level */ @@ -80,9 +91,19 @@ struct AuthConfig { // Pre-configured clients (for development/testing) std::vector clients; - // Role-to-permissions mapping (built-in defaults) - // Permissions are HTTP method + path patterns - static const std::unordered_map> & get_role_permissions(); + /// Permission entries for the routes the `RouteRegistry` does not hold. + /// + /// The gateway's own routes derive their entries from their registration + /// (`RouteRegistry::route_permissions()`). Three families of route are + /// mounted straight onto the cpp-httplib server instead and so cannot: + /// plugin routes (`PluginManager::register_routes`), the Swagger UI pages + /// (`-DENABLE_SWAGGER_UI=ON` builds only) and, in test builds, the emitted + /// status recorder. This is the whole residual list for them, and it is + /// deliberately short: ADMIN's four `**` entries, which is what has always + /// covered those paths and is why a plugin operation's published role reads + /// `admin`. Nothing weaker reaches them - a viewer token on a plugin route + /// is 403, before and after the derivation. + static const RoutePermissions & residual_route_permissions(); }; /** diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_manager.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_manager.hpp index b8aaaa953..648a678ee 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_manager.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_manager.hpp @@ -99,6 +99,26 @@ class AuthManager { */ AuthorizationResult check_authorization(UserRole role, const std::string & method, const std::string & path) const; + /** + * @brief Merge permission entries into the table check_authorization reads. + * + * The table starts empty and the check fails closed, so a manager nobody + * feeds authorizes nothing. That is deliberate: the entries belong to the + * route set actually mounted, and only the caller that mounts the routes + * knows what that is. `RESTServer::setup_routes()` merges two sources - the + * registry's derivation for the gateway's own routes and + * `AuthConfig::residual_route_permissions()` for the ones mounted outside it + * - before the HTTP server starts listening. + * + * Merging, not replacing, so those two calls compose. Not thread-safe, and + * not made so: every call happens on the constructing thread before + * `RESTServer::start()`, which is what publishes the table to the request + * threads that read it. + * + * @param permissions Entries to add, keyed by role + */ + void add_route_permissions(const RoutePermissions & permissions); + /** * @brief Check if authentication is required for a request * @param method HTTP method @@ -194,6 +214,11 @@ class AuthManager { AuthConfig config_; + // RBAC entries check_authorization matches against, populated via + // add_route_permissions() before the server starts listening. Empty until + // then, and an empty set authorizes nothing - see add_route_permissions(). + RoutePermissions permissions_; + // Auth requirement policy (created from config) std::unique_ptr auth_policy_; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/docs_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/docs_handlers.hpp index c051d3aac..c2986d3bf 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/docs_handlers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/docs_handlers.hpp @@ -21,7 +21,9 @@ #include #include +#include "ros2_medkit_gateway/http/handler_result.hpp" #include "ros2_medkit_gateway/http/handlers/handler_context.hpp" +#include "ros2_medkit_gateway/http/typed_router.hpp" namespace ros2_medkit_gateway { @@ -48,10 +50,18 @@ class DocsHandlers { const openapi::RouteRegistry * route_registry = nullptr); ~DocsHandlers(); - /// GET /docs - Root capability description - void handle_docs_root(const httplib::Request & req, httplib::Response & res); - - /// GET /{path}/docs - Context-scoped capability description + /// GET /docs - Root capability description. + /// + /// Answers with the document already serialized. The generator caches + /// documents in that form, so returning text is what lets a cache hit reach + /// the response without a parsed DOM being copied on the way. + http::Result handle_docs_root(http::TypedRequest req); + + /// GET /{entity_path}/docs - Context-scoped capability description. + /// + /// Raw rather than typed: the route is mounted on a `(.+)/docs` regex, and + /// the prefix it captures is a whole entity or resource path, which is not + /// a path parameter the typed router's `{param}` grammar can express. void handle_docs_any_path(const httplib::Request & req, httplib::Response & res); #ifdef ENABLE_SWAGGER_UI @@ -63,12 +73,13 @@ class DocsHandlers { #endif private: - /// Write a 200 JSON body using the framework primitive. DocsHandlers is - /// a friend of `FrameworkOrPluginAccess`; these helpers exist because the - /// `/docs` + `/docs` routes are registered as raw httplib handlers - /// (their `(.+)/docs$` regex shape does not map onto the typed router's - /// OpenAPI path-template grammar). - static void write_json(httplib::Response & res, const nlohmann::json & body); + /// Write a 200 body from an already-serialized JSON document using the + /// framework primitive. DocsHandlers is a friend of + /// `FrameworkOrPluginAccess`; these helpers exist because + /// `/docs` is a raw httplib handler (its `(.+)/docs$` regex + /// shape does not map onto the typed router's OpenAPI path-template + /// grammar) and, when Swagger UI is compiled in, so are its asset routes. + static void write_json_text(httplib::Response & res, const std::string & body); /// Write a SOVD GenericError response using the framework primitive. static void write_error(httplib::Response & res, int status, const std::string & code, const std::string & message); diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/openapi/document_checks.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/openapi/document_checks.hpp index b03a04121..90a305ecc 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/openapi/document_checks.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/openapi/document_checks.hpp @@ -14,6 +14,7 @@ #pragma once +#include #include #include #include @@ -36,5 +37,23 @@ namespace openapi { /// registry can read. Only the assembled document knows both halves. std::set unreachable_schemas(const nlohmann::json & document); +/// The entries of `pool` that a `$ref` chain rooted in `subtree` can reach, +/// resolving each hop through `pool` itself. +/// +/// The inverse of the walk above, and it exists for the sub-documents: a +/// `/docs` document publishes a slice of the API surface, and the +/// schemas its operations name have to travel with it - a `$ref` into a +/// `components/schemas` entry the document does not carry is a reference no +/// client can resolve. Shipping the whole pool instead would put every DTO the +/// gateway knows on every entity page. +/// +/// Only `components/schemas` references are followed. References into other +/// component sections - `#/components/responses/GenericError` and the +/// middleware-owned responses beside it - are emitted unconditionally by +/// `OpenApiSpecBuilder::build()`, so a sub-document carries them whether or not +/// an operation names one. +std::map referenced_schemas(const nlohmann::json & subtree, + const std::map & pool); + } // namespace openapi } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/openapi/route_descriptions.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/openapi/route_descriptions.hpp index ee220abec..e82ec5f70 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/openapi/route_descriptions.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/openapi/route_descriptions.hpp @@ -97,6 +97,32 @@ class SchemaDesc { return *this; } + /// Attach prose. Applied last in a chain, so it lands on the outermost + /// node - including the `anyOf` wrapper `or_null()` produces, which is + /// where a client reads it from. + SchemaDesc & description(const std::string & text) { + json_["description"] = text; + return *this; + } + + /// Constrain a string schema to a closed set of values. Only write this + /// where the emitter genuinely cannot produce anything else - an enum a + /// handler can step outside makes a generated client reject a real body. + SchemaDesc & enum_values(const std::vector & values) { + json_["enum"] = values; + return *this; + } + + /// Widen the schema to ` | null`, the OpenAPI 3.1 spelling of a + /// key that is always present and sometimes JSON `null`. Not the same as + /// leaving a key out of `required`: that says the key may be absent. + SchemaDesc & or_null() { + nlohmann::json inner = std::move(json_); + json_ = nlohmann::json::object(); + json_["anyOf"] = nlohmann::json::array({std::move(inner), nlohmann::json{{"type", "null"}}}); + return *this; + } + // Convert to JSON nlohmann::json to_json() const { return json_; @@ -118,6 +144,42 @@ class OperationDesc { return *this; } + /// Set the OpenAPI tag. The gateway declares every tag a folded plugin + /// operation uses in the document's global tag list, so a plugin may pick + /// its own name here without the document acquiring an undeclared tag. + OperationDesc & tag(std::string name) { + tag_ = std::move(name); + return *this; + } + + /// Set the operationId. It has to be unique across the whole document, not + /// just across this plugin: generated clients turn it into a method name, + /// and the gateway's own operationIds share the namespace. Prefixing with + /// the plugin's name is the way to stay out of their way. + OperationDesc & operation_id(std::string id) { + operation_id_ = std::move(id); + return *this; + } + + /// Declare the role a caller needs. Published as a security requirement + /// naming `bearerAuth` with the role as its scope - the same shape the + /// gateway's own routes use. The role must be the one `AuthConfig`'s + /// permission table actually grants for this path: the document is read as + /// a statement about the gateway's enforcement, not about what the plugin + /// would prefer. + OperationDesc & requires_role(std::string role) { + role_ = std::move(role); + security_declared_ = true; + return *this; + } + + /// Declare the operation reachable with no token at all (`security: []`). + OperationDesc & public_route() { + role_.clear(); + security_declared_ = true; + return *this; + } + // Add a path parameter OperationDesc & path_param(const std::string & name, const std::string & desc) { nlohmann::json param; @@ -159,14 +221,43 @@ class OperationDesc { return *this; } + /// Declare an error status as a reference to one of the document's shared + /// response components (`GenericError`, `Unauthorized`, ...). A plugin + /// handler's errors reach the wire through `PluginResponse::send_error`, + /// which writes the SOVD `GenericError` body, so `GenericError` is the + /// component that describes them - spelling the body out inline instead + /// would publish a second, drifting copy of it. + OperationDesc & error_response(int status_code, const std::string & component) { + responses_[std::to_string(status_code)] = nlohmann::json{{"$ref", "#/components/responses/" + component}}; + return *this; + } + // Convert to JSON nlohmann::json to_json() const { nlohmann::json j; + if (!tag_.empty()) { + j["tags"] = nlohmann::json::array({tag_}); + } + + if (!operation_id_.empty()) { + j["operationId"] = operation_id_; + } + if (!description_.empty()) { j["description"] = description_; } + if (security_declared_) { + if (role_.empty()) { + j["security"] = nlohmann::json::array(); + } else { + nlohmann::json requirement = nlohmann::json::object(); + requirement["bearerAuth"] = nlohmann::json::array({role_}); + j["security"] = nlohmann::json::array({std::move(requirement)}); + } + } + if (!parameters_.empty()) { j["parameters"] = parameters_; } @@ -185,6 +276,10 @@ class OperationDesc { private: std::string description_; + std::string tag_; + std::string operation_id_; + std::string role_; + bool security_declared_ = false; std::vector parameters_; SchemaDesc request_body_; bool has_request_body_ = false; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/entities.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/entities.hpp index 2df81d067..77ea2412f 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/entities.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/entities.hpp @@ -362,7 +362,7 @@ inline constexpr std::string_view dto_name = "FunctionListItem // Wire keys: // id, name, description?, translation_id?, tags?, // hosts, data, data-categories, data-groups, operations, configurations, -// faults, logs, bulk-data, x-medkit-graph, cyclic-subscriptions, triggers, +// faults, logs, bulk-data, x-medkit-graph?, cyclic-subscriptions, triggers, // capabilities (array of EntityCapability), // _links (open relation map - see kLinksDescription), // x-medkit @@ -383,9 +383,14 @@ struct FunctionDetail { std::string configurations; std::string faults; std::string logs; - std::string bulk_data; // wire key: "bulk-data" - std::string x_medkit_graph; // wire key: "x-medkit-graph" - std::string cyclic_subscriptions; // wire key: "cyclic-subscriptions" + std::string bulk_data; // wire key: "bulk-data" + // Optional, unlike the collections above it: `x-medkit-graph` is served by + // the graph-provider plugin, not by the gateway, so on a gateway without + // that plugin loaded there is no route behind the URI. Emitted only when + // the entity's capability list says a plugin serves it - a link to a 404 is + // worse than no link. + std::optional x_medkit_graph; // wire key: "x-medkit-graph" + std::string cyclic_subscriptions; // wire key: "cyclic-subscriptions" std::string triggers; // Free-form fields std::optional> capabilities; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/detail/primitives.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/detail/primitives.hpp index 2f9a71bad..2fd087070 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/detail/primitives.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/detail/primitives.hpp @@ -17,6 +17,7 @@ #include #include +#include #include "ros2_medkit_gateway/core/models/error_info.hpp" @@ -61,10 +62,11 @@ namespace detail { * `PluginResponse` (plugin shim that lets plugins emit responses without * going through HandlerContext), and the two remaining legacy raw-route * handlers that have not yet migrated to the typed router and so still - * write `httplib::Response` directly: `DocsHandlers` (the `/docs` + - * `/docs` per-path capability description routes registered outside - * the route registry because their regex shape does not map cleanly to the - * typed router's OpenAPI path-template grammar) and `SSEFaultHandler` (the + * write `httplib::Response` directly: `DocsHandlers` (the + * `/docs` scoped capability description, whose `(.+)/docs` + * prefix is a whole entity or resource path rather than a `{param}` segment + * the typed router can name; the plain `/docs` next to it is typed, and the + * Swagger UI routes serve non-JSON bodies) and `SSEFaultHandler` (the * legacy `handle_stream` entry kept for the in-process unit test fixture). * * Handler code cannot default-construct the token, so cannot call the @@ -126,6 +128,29 @@ constexpr int kKeepCurrentStatus = 0; void write_json_body(FrameworkOrPluginAccess token, httplib::Response & res, const nlohmann::json & body, int status = 200); +/** + * @brief Write an already-serialized JSON document. + * + * Same status sentinel and same `application/json` content type as + * `write_json_body`; the difference is only that the caller has the JSON + * text rather than a DOM. It exists for the `/docs` routes, which cache + * capability documents serialized so that serving one does not have to + * copy a parsed DOM (see `openapi::CapabilityGenerator`). + * + * `body` must be what `nlohmann::json::dump(2)` produces for the document, + * which is what `write_json_body` would have written - that equality is what + * lets the two writers be used interchangeably on the same route. Nothing + * here validates it; the caller owns that. + * + * @param token Framework access token (constructible only by friends). + * @param res HTTP response to mutate. + * @param body Serialized JSON document. + * @param status HTTP status code; pass `kKeepCurrentStatus` to leave + * `res.status` unchanged. Defaults to 200. + */ +void write_json_text(FrameworkOrPluginAccess token, httplib::Response & res, const std::string & body, + int status = 200); + /** * @brief Write a SOVD GenericError response. * diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/detail/status_recorder.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/detail/status_recorder.hpp index 603feefa4..c362a09a3 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/detail/status_recorder.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/detail/status_recorder.hpp @@ -72,7 +72,14 @@ * request line, 416 for an **unparseable** `Range` (an unsatisfiable but * parseable one yields 206, not 416). * - Routes registered straight onto the server rather than through the - * registry (`/docs`, the Swagger UI subtree, this endpoint). + * registry: the Swagger UI subtree, this endpoint, and every route a + * plugin mounts through `PluginManager::register_routes`. The last of + * those *is* in the served document - the gateway folds each plugin's + * `describe_plugin_routes()` output into it - so it marks them + * `x-medkit-plugin-served`, which is how + * `test_openapi_error_coverage` tells "unreachable by this recorder" from + * "unreached, and that is a defect". The two `/docs` routes used to be in + * this list and no longer are: they go through the registry. * - Any status on a code path the test run never drives. * * Those are declared by hand; the recorder's own output is what says which. diff --git a/src/ros2_medkit_gateway/scripts/check_doc_test_citations.py b/src/ros2_medkit_gateway/scripts/check_doc_test_citations.py new file mode 100644 index 000000000..c2568724e --- /dev/null +++ b/src/ros2_medkit_gateway/scripts/check_doc_test_citations.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +# Copyright 2026 bburda +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Every test a design document names by hand has to exist. + +The gateway's design docs say which mechanism is enforced by which check, and +they say it by naming the check. That naming is the load-bearing part: "pinned +by ``RouteRegistryTest.RouteDeclaredStatusWinsOverTheMiddlewareComponent``" is +the difference between a documented guarantee and an assertion about nothing. +A rename in the test tree falsifies every such sentence at once, silently, +because nothing links the prose to the test. + +This is that link. It reads the citations out of the documents and resolves +each against the test tree. + +Three citation forms are recognised, which is exactly what the documents use: + +* ``::`` or ``.test.py::`` - a Python + launch_testing case. Resolved by finding the file and the ``def``. +* ``Test.`` - a C++ GTest case. Resolved against + ``TEST``/``TEST_F``/``TEST_P`` in the C++ test sources. +* ``test_`` on its own - resolved against any of the three things that + name could be: a test source file, a Python case, or a GTest case. + +The direction is one-way on purpose. A cited test must exist; a test nothing +cites is not a defect. Only the first direction can make a document false. +""" + +import pathlib +import re +import sys + +REPO = pathlib.Path(__file__).resolve().parents[3] + +# Documents whose test citations are checked. The gateway design docs make the +# claims; ``docs/api`` repeats two of them for readers who never open a design +# doc, and a citation is just as load-bearing there. +DOC_ROOTS = ( + REPO / 'src/ros2_medkit_gateway/design', + REPO / 'docs/api', +) + +# Where a test can live. Any ``test`` directory under a package, at any depth - +# the gateway keeps unit tests in ``test/`` and the integration package keeps +# feature tests in ``test/features/``. +SOURCE_ROOT = REPO / 'src' + +# Below this many resolvable citations the parser, not the documents, is what +# broke. An earlier check on this branch passed by matching nothing at all; +# this is the guard against repeating that. +MIN_CITATIONS = 15 + +QUALIFIED = re.compile(r'``([A-Za-z_][A-Za-z0-9_]*?)(?:\.test\.py)?::(test_[A-Za-z0-9_]+)``') +GTEST = re.compile(r'``([A-Za-z][A-Za-z0-9_]*Test)\.([A-Za-z_][A-Za-z0-9_]*)``') +BARE = re.compile(r'``(test_[A-Za-z0-9_]+)``') + + +def documents(): + """Yield every RST file whose citations this check resolves.""" + for root in DOC_ROOTS: + for path in sorted(root.rglob('*.rst')): + yield path + + +def python_tests(): + """Return {file stem: set of test method names} for the Python tests.""" + cases = {} + for path in sorted(SOURCE_ROOT.rglob('*.test.py')): + if 'test' not in path.parts: + continue + stem = path.name[: -len('.test.py')] + text = path.read_text(encoding='utf-8') + cases[stem] = set(re.findall(r'^\s*def (test_[A-Za-z0-9_]+)\s*\(', text, re.M)) + return cases + + +GTEST_CASE = re.compile( + r'\bTEST(?:_F|_P)?\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*,\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)' +) + + +def gtest_cases(): + """Return the set of (suite, case) pairs declared in the C++ tests.""" + found = set() + for path in sorted(SOURCE_ROOT.rglob('*.cpp')): + if 'test' not in path.parts: + continue + for suite, case in GTEST_CASE.findall(path.read_text(encoding='utf-8')): + found.add((suite, case)) + return found + + +def test_file_stems(): + """Return every basename a test source file is known by.""" + stems = set() + for path in sorted(SOURCE_ROOT.rglob('*')): + if 'test' not in path.parts or not path.is_file(): + continue + if path.name.endswith('.test.py'): + stems.add(path.name[: -len('.test.py')]) + elif path.suffix in ('.cpp', '.py'): + stems.add(path.stem) + return stems + + +def main(): + py = python_tests() + gtests = gtest_cases() + stems = test_file_stems() + + # Positive controls on each corpus, before any of them is used to judge a + # document. A resolver that silently found nothing reports every citation + # as broken, which reads like a real failure and proves as little as one + # that matches everything. + if not py: + print('FAIL: found no Python test files - has the test layout moved?', file=sys.stderr) + return 1 + if not gtests: + print('FAIL: found no GTest cases - has the test layout moved?', file=sys.stderr) + return 1 + + checked = 0 + broken = [] + for doc in documents(): + rel = doc.relative_to(REPO) + text = doc.read_text(encoding='utf-8') + + for match in QUALIFIED.finditer(text): + stem, case = match.group(1), match.group(2) + checked += 1 + if stem not in py: + broken.append(f'{rel}: no test file `{stem}.test.py` for `{stem}::{case}`') + elif case not in py[stem]: + broken.append(f'{rel}: `{stem}.test.py` has no `{case}`') + + for match in GTEST.finditer(text): + suite, case = match.group(1), match.group(2) + checked += 1 + if (suite, case) not in gtests: + broken.append(f'{rel}: no GTest case `{suite}.{case}`') + + for match in BARE.finditer(text): + name = match.group(1) + checked += 1 + resolved = ( + name in stems + or any(name in cases for cases in py.values()) + or any(case == name for _, case in gtests) + ) + if not resolved: + broken.append(f'{rel}: `{name}` names no test file, Python case or GTest case') + + if checked < MIN_CITATIONS: + print( + f'FAIL: parsed only {checked} test citation(s) from ' + f'{len(list(documents()))} document(s) - the parser, not the docs, is broken.', + file=sys.stderr, + ) + return 1 + + if broken: + print( + f'FAIL: {len(broken)} document citation(s) name a test that does not exist:', + file=sys.stderr, + ) + for line in broken: + print(f' {line}', file=sys.stderr) + print( + '\nA renamed test leaves the sentence that cites it asserting nothing. ' + 'Update the citation, or restore the name.', + file=sys.stderr, + ) + return 1 + + print(f'OK: {checked} test citation(s) in the documents all resolve') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/src/ros2_medkit_gateway/src/core/auth/auth_config.cpp b/src/ros2_medkit_gateway/src/core/auth/auth_config.cpp index bf117928a..93de53553 100644 --- a/src/ros2_medkit_gateway/src/core/auth/auth_config.cpp +++ b/src/ros2_medkit_gateway/src/core/auth/auth_config.cpp @@ -19,629 +19,31 @@ namespace ros2_medkit_gateway { -const std::unordered_map> & AuthConfig::get_role_permissions() { - // Static permission map - built once - // Format: "HTTP_METHOD:/path/pattern" where * is wildcard +const RoutePermissions & AuthConfig::residual_route_permissions() { + // Format: "HTTP_METHOD:/path/pattern", where `*` is one path segment and + // `**` is any number of them (`AuthManager::matches_path`). + // + // The gateway's own routes are NOT here. Their entries are derived from the + // registration that mounts them - `RouteRegistry::route_permissions()` - and + // merged into the manager at startup by `RESTServer::setup_routes()`. What + // is left is the residual: the paths served by something the registry never + // sees, which is every route mounted straight onto the cpp-httplib server. + // + // ADMIN's four wildcards are that residual, and they are also the reason a + // plugin-served operation publishes `admin` as its role: `*` stops at a + // segment boundary, so no weaker role's entry reaches + // `/api/v1/functions//x-medkit-graph`, and only `GET:/api/v1/**` does. + // Widening this to a weaker role would hand that role every plugin route the + // deployment happens to load, sight unseen. + // // @verifies REQ_INTEROP_086 - static const std::unordered_map> permissions = { - {UserRole::VIEWER, - { - // Read-only access to all GET endpoints - "GET:/api/v1/health", - "GET:/api/v1/", - "GET:/api/v1/version-info", - // Discovery: entity collections and details - "GET:/api/v1/areas", - "GET:/api/v1/areas/*", - "GET:/api/v1/components", - "GET:/api/v1/components/*", - "GET:/api/v1/apps", - "GET:/api/v1/apps/*", - "GET:/api/v1/functions", - "GET:/api/v1/functions/*", - // Discovery: relationship endpoints - "GET:/api/v1/areas/*/components", - "GET:/api/v1/areas/*/subareas", - "GET:/api/v1/areas/*/contains", - "GET:/api/v1/components/*/subcomponents", - "GET:/api/v1/components/*/hosts", - "GET:/api/v1/components/*/depends-on", - "GET:/api/v1/apps/*/depends-on", - "GET:/api/v1/apps/*/is-located-on", - "GET:/api/v1/apps/*/belongs-to", - "GET:/api/v1/functions/*/hosts", - // Data: all entity types - "GET:/api/v1/components/*/data", - "GET:/api/v1/components/*/data/*", - "GET:/api/v1/apps/*/data", - "GET:/api/v1/apps/*/data/*", - "GET:/api/v1/areas/*/data", - "GET:/api/v1/areas/*/data/*", - "GET:/api/v1/functions/*/data", - "GET:/api/v1/functions/*/data/*", - // Data categories and groups: all entity types - "GET:/api/v1/components/*/data-categories", - "GET:/api/v1/components/*/data-groups", - "GET:/api/v1/apps/*/data-categories", - "GET:/api/v1/apps/*/data-groups", - "GET:/api/v1/areas/*/data-categories", - "GET:/api/v1/areas/*/data-groups", - "GET:/api/v1/functions/*/data-categories", - "GET:/api/v1/functions/*/data-groups", - // Operations: all entity types - "GET:/api/v1/components/*/operations", - "GET:/api/v1/components/*/operations/*", - "GET:/api/v1/components/*/operations/*/executions", - "GET:/api/v1/components/*/operations/*/executions/*", - "GET:/api/v1/apps/*/operations", - "GET:/api/v1/apps/*/operations/*", - "GET:/api/v1/apps/*/operations/*/executions", - "GET:/api/v1/apps/*/operations/*/executions/*", - "GET:/api/v1/areas/*/operations", - "GET:/api/v1/areas/*/operations/*", - "GET:/api/v1/areas/*/operations/*/executions", - "GET:/api/v1/areas/*/operations/*/executions/*", - "GET:/api/v1/functions/*/operations", - "GET:/api/v1/functions/*/operations/*", - "GET:/api/v1/functions/*/operations/*/executions", - "GET:/api/v1/functions/*/operations/*/executions/*", - // Configurations: all entity types - "GET:/api/v1/components/*/configurations", - "GET:/api/v1/components/*/configurations/*", - "GET:/api/v1/apps/*/configurations", - "GET:/api/v1/apps/*/configurations/*", - "GET:/api/v1/areas/*/configurations", - "GET:/api/v1/areas/*/configurations/*", - "GET:/api/v1/functions/*/configurations", - "GET:/api/v1/functions/*/configurations/*", - // Faults: per-entity (all entity types) - "GET:/api/v1/components/*/faults", - "GET:/api/v1/components/*/faults/*", - "GET:/api/v1/apps/*/faults", - "GET:/api/v1/apps/*/faults/*", - "GET:/api/v1/areas/*/faults", - "GET:/api/v1/areas/*/faults/*", - "GET:/api/v1/functions/*/faults", - "GET:/api/v1/functions/*/faults/*", - // Faults: global - "GET:/api/v1/faults", - "GET:/api/v1/faults/stream", - // Logs: all entity types - "GET:/api/v1/components/*/logs", - "GET:/api/v1/components/*/logs/configuration", - "GET:/api/v1/apps/*/logs", - "GET:/api/v1/apps/*/logs/configuration", - "GET:/api/v1/areas/*/logs", - "GET:/api/v1/areas/*/logs/configuration", - "GET:/api/v1/functions/*/logs", - "GET:/api/v1/functions/*/logs/configuration", - // Bulk data: all entity types (read-only) - "GET:/api/v1/components/*/bulk-data", - "GET:/api/v1/components/*/bulk-data/*", - "GET:/api/v1/components/*/bulk-data/*/*", - "GET:/api/v1/apps/*/bulk-data", - "GET:/api/v1/apps/*/bulk-data/*", - "GET:/api/v1/apps/*/bulk-data/*/*", - "GET:/api/v1/areas/*/bulk-data", - "GET:/api/v1/areas/*/bulk-data/*", - "GET:/api/v1/areas/*/bulk-data/*/*", - "GET:/api/v1/functions/*/bulk-data", - "GET:/api/v1/functions/*/bulk-data/*", - "GET:/api/v1/functions/*/bulk-data/*/*", - // Bulk data: nested entities (subareas, subcomponents) - "GET:/api/v1/areas/*/subareas/*/bulk-data", - "GET:/api/v1/areas/*/subareas/*/bulk-data/*", - "GET:/api/v1/areas/*/subareas/*/bulk-data/*/*", - "GET:/api/v1/components/*/subcomponents/*/bulk-data", - "GET:/api/v1/components/*/subcomponents/*/bulk-data/*", - "GET:/api/v1/components/*/subcomponents/*/bulk-data/*/*", - // Cyclic subscriptions: apps, components, functions (read-only) - "GET:/api/v1/components/*/cyclic-subscriptions", - "GET:/api/v1/components/*/cyclic-subscriptions/*", - "GET:/api/v1/components/*/cyclic-subscriptions/*/events", - "GET:/api/v1/apps/*/cyclic-subscriptions", - "GET:/api/v1/apps/*/cyclic-subscriptions/*", - "GET:/api/v1/apps/*/cyclic-subscriptions/*/events", - "GET:/api/v1/functions/*/cyclic-subscriptions", - "GET:/api/v1/functions/*/cyclic-subscriptions/*", - "GET:/api/v1/functions/*/cyclic-subscriptions/*/events", - // Locks: components and apps (read-only) - "GET:/api/v1/components/*/locks", - "GET:/api/v1/components/*/locks/*", - "GET:/api/v1/apps/*/locks", - "GET:/api/v1/apps/*/locks/*", - // Updates (read-only) - "GET:/api/v1/updates", - "GET:/api/v1/updates/*", - "GET:/api/v1/updates/*/status", - // Scripts: read-only (list, details, execution status) - "GET:/api/v1/components/*/scripts", - "GET:/api/v1/components/*/scripts/*", - "GET:/api/v1/apps/*/scripts", - "GET:/api/v1/apps/*/scripts/*", - "GET:/api/v1/components/*/scripts/*/executions/*", - "GET:/api/v1/apps/*/scripts/*/executions/*", - // Status: apps and components (read-only) - "GET:/api/v1/apps/*/status", - "GET:/api/v1/components/*/status", - // Docs - "GET:/api/v1/docs", - }}, - {UserRole::OPERATOR, - { - // Everything VIEWER can do, plus: - // Read-only access (inherited from VIEWER) - "GET:/api/v1/health", - "GET:/api/v1/", - "GET:/api/v1/version-info", - // Discovery: entity collections and details - "GET:/api/v1/areas", - "GET:/api/v1/areas/*", - "GET:/api/v1/components", - "GET:/api/v1/components/*", - "GET:/api/v1/apps", - "GET:/api/v1/apps/*", - "GET:/api/v1/functions", - "GET:/api/v1/functions/*", - // Discovery: relationship endpoints - "GET:/api/v1/areas/*/components", - "GET:/api/v1/areas/*/subareas", - "GET:/api/v1/areas/*/contains", - "GET:/api/v1/components/*/subcomponents", - "GET:/api/v1/components/*/hosts", - "GET:/api/v1/components/*/depends-on", - "GET:/api/v1/apps/*/depends-on", - "GET:/api/v1/apps/*/is-located-on", - "GET:/api/v1/apps/*/belongs-to", - "GET:/api/v1/functions/*/hosts", - // Data: all entity types (read) - "GET:/api/v1/components/*/data", - "GET:/api/v1/components/*/data/*", - "GET:/api/v1/apps/*/data", - "GET:/api/v1/apps/*/data/*", - "GET:/api/v1/areas/*/data", - "GET:/api/v1/areas/*/data/*", - "GET:/api/v1/functions/*/data", - "GET:/api/v1/functions/*/data/*", - // Data categories and groups: all entity types - "GET:/api/v1/components/*/data-categories", - "GET:/api/v1/components/*/data-groups", - "GET:/api/v1/apps/*/data-categories", - "GET:/api/v1/apps/*/data-groups", - "GET:/api/v1/areas/*/data-categories", - "GET:/api/v1/areas/*/data-groups", - "GET:/api/v1/functions/*/data-categories", - "GET:/api/v1/functions/*/data-groups", - // Operations: all entity types (read) - "GET:/api/v1/components/*/operations", - "GET:/api/v1/components/*/operations/*", - "GET:/api/v1/components/*/operations/*/executions", - "GET:/api/v1/components/*/operations/*/executions/*", - "GET:/api/v1/apps/*/operations", - "GET:/api/v1/apps/*/operations/*", - "GET:/api/v1/apps/*/operations/*/executions", - "GET:/api/v1/apps/*/operations/*/executions/*", - "GET:/api/v1/areas/*/operations", - "GET:/api/v1/areas/*/operations/*", - "GET:/api/v1/areas/*/operations/*/executions", - "GET:/api/v1/areas/*/operations/*/executions/*", - "GET:/api/v1/functions/*/operations", - "GET:/api/v1/functions/*/operations/*", - "GET:/api/v1/functions/*/operations/*/executions", - "GET:/api/v1/functions/*/operations/*/executions/*", - // Configurations: all entity types (read-only) - "GET:/api/v1/components/*/configurations", - "GET:/api/v1/components/*/configurations/*", - "GET:/api/v1/apps/*/configurations", - "GET:/api/v1/apps/*/configurations/*", - "GET:/api/v1/areas/*/configurations", - "GET:/api/v1/areas/*/configurations/*", - "GET:/api/v1/functions/*/configurations", - "GET:/api/v1/functions/*/configurations/*", - // Faults: per-entity (all entity types, read) - "GET:/api/v1/components/*/faults", - "GET:/api/v1/components/*/faults/*", - "GET:/api/v1/apps/*/faults", - "GET:/api/v1/apps/*/faults/*", - "GET:/api/v1/areas/*/faults", - "GET:/api/v1/areas/*/faults/*", - "GET:/api/v1/functions/*/faults", - "GET:/api/v1/functions/*/faults/*", - // Faults: global - "GET:/api/v1/faults", - "GET:/api/v1/faults/stream", - // Logs: all entity types (read) - "GET:/api/v1/components/*/logs", - "GET:/api/v1/components/*/logs/configuration", - "GET:/api/v1/apps/*/logs", - "GET:/api/v1/apps/*/logs/configuration", - "GET:/api/v1/areas/*/logs", - "GET:/api/v1/areas/*/logs/configuration", - "GET:/api/v1/functions/*/logs", - "GET:/api/v1/functions/*/logs/configuration", - // Bulk data: all entity types (read-only) - "GET:/api/v1/components/*/bulk-data", - "GET:/api/v1/components/*/bulk-data/*", - "GET:/api/v1/components/*/bulk-data/*/*", - "GET:/api/v1/apps/*/bulk-data", - "GET:/api/v1/apps/*/bulk-data/*", - "GET:/api/v1/apps/*/bulk-data/*/*", - "GET:/api/v1/areas/*/bulk-data", - "GET:/api/v1/areas/*/bulk-data/*", - "GET:/api/v1/areas/*/bulk-data/*/*", - "GET:/api/v1/functions/*/bulk-data", - "GET:/api/v1/functions/*/bulk-data/*", - "GET:/api/v1/functions/*/bulk-data/*/*", - // Bulk data: nested entities (subareas, subcomponents) - "GET:/api/v1/areas/*/subareas/*/bulk-data", - "GET:/api/v1/areas/*/subareas/*/bulk-data/*", - "GET:/api/v1/areas/*/subareas/*/bulk-data/*/*", - "GET:/api/v1/components/*/subcomponents/*/bulk-data", - "GET:/api/v1/components/*/subcomponents/*/bulk-data/*", - "GET:/api/v1/components/*/subcomponents/*/bulk-data/*/*", - // Cyclic subscriptions: apps, components, functions (read-only) - "GET:/api/v1/components/*/cyclic-subscriptions", - "GET:/api/v1/components/*/cyclic-subscriptions/*", - "GET:/api/v1/components/*/cyclic-subscriptions/*/events", - "GET:/api/v1/apps/*/cyclic-subscriptions", - "GET:/api/v1/apps/*/cyclic-subscriptions/*", - "GET:/api/v1/apps/*/cyclic-subscriptions/*/events", - "GET:/api/v1/functions/*/cyclic-subscriptions", - "GET:/api/v1/functions/*/cyclic-subscriptions/*", - "GET:/api/v1/functions/*/cyclic-subscriptions/*/events", - // Locks: components and apps (read-only) - "GET:/api/v1/components/*/locks", - "GET:/api/v1/components/*/locks/*", - "GET:/api/v1/apps/*/locks", - "GET:/api/v1/apps/*/locks/*", - // Updates (read-only) - "GET:/api/v1/updates", - "GET:/api/v1/updates/*", - "GET:/api/v1/updates/*/status", - // Scripts: read (inherited from VIEWER) - "GET:/api/v1/components/*/scripts", - "GET:/api/v1/components/*/scripts/*", - "GET:/api/v1/apps/*/scripts", - "GET:/api/v1/apps/*/scripts/*", - "GET:/api/v1/components/*/scripts/*/executions/*", - "GET:/api/v1/apps/*/scripts/*/executions/*", - // Status: apps and components (read-only, inherited from VIEWER) - "GET:/api/v1/apps/*/status", - "GET:/api/v1/components/*/status", - // Docs - "GET:/api/v1/docs", - // --- Operator-specific write permissions --- - // Trigger operations: all entity types (POST) - "POST:/api/v1/components/*/operations/*/executions", - "POST:/api/v1/apps/*/operations/*/executions", - "POST:/api/v1/areas/*/operations/*/executions", - "POST:/api/v1/functions/*/operations/*/executions", - // Update operations - stop capability: all entity types (PUT) - "PUT:/api/v1/components/*/operations/*/executions/*", - "PUT:/api/v1/apps/*/operations/*/executions/*", - "PUT:/api/v1/areas/*/operations/*/executions/*", - "PUT:/api/v1/functions/*/operations/*/executions/*", - // Cancel actions: all entity types (DELETE on executions) - "DELETE:/api/v1/components/*/operations/*/executions/*", - "DELETE:/api/v1/apps/*/operations/*/executions/*", - "DELETE:/api/v1/areas/*/operations/*/executions/*", - "DELETE:/api/v1/functions/*/operations/*/executions/*", - // Clear faults: all entity types (DELETE on faults) - "DELETE:/api/v1/components/*/faults/*", - "DELETE:/api/v1/apps/*/faults/*", - "DELETE:/api/v1/areas/*/faults/*", - "DELETE:/api/v1/functions/*/faults/*", - // Clear all faults: per-entity and global - "DELETE:/api/v1/components/*/faults", - "DELETE:/api/v1/apps/*/faults", - "DELETE:/api/v1/areas/*/faults", - "DELETE:/api/v1/functions/*/faults", - "DELETE:/api/v1/faults", - // Publish data to topics: all entity types (PUT) - "PUT:/api/v1/components/*/data/*", - "PUT:/api/v1/apps/*/data/*", - "PUT:/api/v1/areas/*/data/*", - "PUT:/api/v1/functions/*/data/*", - // Cyclic subscriptions: create/update/delete (apps, components, functions) - "POST:/api/v1/components/*/cyclic-subscriptions", - "POST:/api/v1/apps/*/cyclic-subscriptions", - "POST:/api/v1/functions/*/cyclic-subscriptions", - "PUT:/api/v1/components/*/cyclic-subscriptions/*", - "PUT:/api/v1/apps/*/cyclic-subscriptions/*", - "PUT:/api/v1/functions/*/cyclic-subscriptions/*", - "DELETE:/api/v1/components/*/cyclic-subscriptions/*", - "DELETE:/api/v1/apps/*/cyclic-subscriptions/*", - "DELETE:/api/v1/functions/*/cyclic-subscriptions/*", - // Locks: acquire/extend/release (components and apps) - "POST:/api/v1/components/*/locks", - "POST:/api/v1/apps/*/locks", - "PUT:/api/v1/components/*/locks/*", - "PUT:/api/v1/apps/*/locks/*", - "DELETE:/api/v1/components/*/locks/*", - "DELETE:/api/v1/apps/*/locks/*", - // Bulk data: upload/delete (apps and components only) - "POST:/api/v1/components/*/bulk-data/*", - "POST:/api/v1/apps/*/bulk-data/*", - "DELETE:/api/v1/components/*/bulk-data/*/*", - "DELETE:/api/v1/apps/*/bulk-data/*/*", - // Scripts: start/control/delete executions - "POST:/api/v1/components/*/scripts/*/executions", - "POST:/api/v1/apps/*/scripts/*/executions", - "PUT:/api/v1/components/*/scripts/*/executions/*", - "PUT:/api/v1/apps/*/scripts/*/executions/*", - "DELETE:/api/v1/components/*/scripts/*/executions/*", - "DELETE:/api/v1/apps/*/scripts/*/executions/*", - // Lifecycle control (non-destructive): apps and components. - // shutdown / force-shutdown tear an entity down and are gated - // behind CONFIGURATOR, so OPERATOR gets only start/restart/force-restart. - "PUT:/api/v1/apps/*/status/start", - "PUT:/api/v1/apps/*/status/restart", - "PUT:/api/v1/apps/*/status/force-restart", - "PUT:/api/v1/components/*/status/start", - "PUT:/api/v1/components/*/status/restart", - "PUT:/api/v1/components/*/status/force-restart", - }}, - {UserRole::CONFIGURATOR, - { - // Everything OPERATOR can do, plus: - // Inherited from OPERATOR - read-only access - "GET:/api/v1/health", - "GET:/api/v1/", - "GET:/api/v1/version-info", - // Discovery: entity collections and details - "GET:/api/v1/areas", - "GET:/api/v1/areas/*", - "GET:/api/v1/components", - "GET:/api/v1/components/*", - "GET:/api/v1/apps", - "GET:/api/v1/apps/*", - "GET:/api/v1/functions", - "GET:/api/v1/functions/*", - // Discovery: relationship endpoints - "GET:/api/v1/areas/*/components", - "GET:/api/v1/areas/*/subareas", - "GET:/api/v1/areas/*/contains", - "GET:/api/v1/components/*/subcomponents", - "GET:/api/v1/components/*/hosts", - "GET:/api/v1/components/*/depends-on", - "GET:/api/v1/apps/*/depends-on", - "GET:/api/v1/apps/*/is-located-on", - "GET:/api/v1/apps/*/belongs-to", - "GET:/api/v1/functions/*/hosts", - // Data: all entity types (read) - "GET:/api/v1/components/*/data", - "GET:/api/v1/components/*/data/*", - "GET:/api/v1/apps/*/data", - "GET:/api/v1/apps/*/data/*", - "GET:/api/v1/areas/*/data", - "GET:/api/v1/areas/*/data/*", - "GET:/api/v1/functions/*/data", - "GET:/api/v1/functions/*/data/*", - // Data categories and groups: all entity types - "GET:/api/v1/components/*/data-categories", - "GET:/api/v1/components/*/data-groups", - "GET:/api/v1/apps/*/data-categories", - "GET:/api/v1/apps/*/data-groups", - "GET:/api/v1/areas/*/data-categories", - "GET:/api/v1/areas/*/data-groups", - "GET:/api/v1/functions/*/data-categories", - "GET:/api/v1/functions/*/data-groups", - // Operations: all entity types (read) - "GET:/api/v1/components/*/operations", - "GET:/api/v1/components/*/operations/*", - "GET:/api/v1/components/*/operations/*/executions", - "GET:/api/v1/components/*/operations/*/executions/*", - "GET:/api/v1/apps/*/operations", - "GET:/api/v1/apps/*/operations/*", - "GET:/api/v1/apps/*/operations/*/executions", - "GET:/api/v1/apps/*/operations/*/executions/*", - "GET:/api/v1/areas/*/operations", - "GET:/api/v1/areas/*/operations/*", - "GET:/api/v1/areas/*/operations/*/executions", - "GET:/api/v1/areas/*/operations/*/executions/*", - "GET:/api/v1/functions/*/operations", - "GET:/api/v1/functions/*/operations/*", - "GET:/api/v1/functions/*/operations/*/executions", - "GET:/api/v1/functions/*/operations/*/executions/*", - // Configurations: all entity types (read) - "GET:/api/v1/components/*/configurations", - "GET:/api/v1/components/*/configurations/*", - "GET:/api/v1/apps/*/configurations", - "GET:/api/v1/apps/*/configurations/*", - "GET:/api/v1/areas/*/configurations", - "GET:/api/v1/areas/*/configurations/*", - "GET:/api/v1/functions/*/configurations", - "GET:/api/v1/functions/*/configurations/*", - // Faults: per-entity (all entity types, read) - "GET:/api/v1/components/*/faults", - "GET:/api/v1/components/*/faults/*", - "GET:/api/v1/apps/*/faults", - "GET:/api/v1/apps/*/faults/*", - "GET:/api/v1/areas/*/faults", - "GET:/api/v1/areas/*/faults/*", - "GET:/api/v1/functions/*/faults", - "GET:/api/v1/functions/*/faults/*", - // Faults: global - "GET:/api/v1/faults", - "GET:/api/v1/faults/stream", - // Logs: all entity types (read) - "GET:/api/v1/components/*/logs", - "GET:/api/v1/components/*/logs/configuration", - "GET:/api/v1/apps/*/logs", - "GET:/api/v1/apps/*/logs/configuration", - "GET:/api/v1/areas/*/logs", - "GET:/api/v1/areas/*/logs/configuration", - "GET:/api/v1/functions/*/logs", - "GET:/api/v1/functions/*/logs/configuration", - // Bulk data: all entity types (read-only) - "GET:/api/v1/components/*/bulk-data", - "GET:/api/v1/components/*/bulk-data/*", - "GET:/api/v1/components/*/bulk-data/*/*", - "GET:/api/v1/apps/*/bulk-data", - "GET:/api/v1/apps/*/bulk-data/*", - "GET:/api/v1/apps/*/bulk-data/*/*", - "GET:/api/v1/areas/*/bulk-data", - "GET:/api/v1/areas/*/bulk-data/*", - "GET:/api/v1/areas/*/bulk-data/*/*", - "GET:/api/v1/functions/*/bulk-data", - "GET:/api/v1/functions/*/bulk-data/*", - "GET:/api/v1/functions/*/bulk-data/*/*", - // Bulk data: nested entities (subareas, subcomponents) - "GET:/api/v1/areas/*/subareas/*/bulk-data", - "GET:/api/v1/areas/*/subareas/*/bulk-data/*", - "GET:/api/v1/areas/*/subareas/*/bulk-data/*/*", - "GET:/api/v1/components/*/subcomponents/*/bulk-data", - "GET:/api/v1/components/*/subcomponents/*/bulk-data/*", - "GET:/api/v1/components/*/subcomponents/*/bulk-data/*/*", - // Cyclic subscriptions: apps, components, functions (read-only) - "GET:/api/v1/components/*/cyclic-subscriptions", - "GET:/api/v1/components/*/cyclic-subscriptions/*", - "GET:/api/v1/components/*/cyclic-subscriptions/*/events", - "GET:/api/v1/apps/*/cyclic-subscriptions", - "GET:/api/v1/apps/*/cyclic-subscriptions/*", - "GET:/api/v1/apps/*/cyclic-subscriptions/*/events", - "GET:/api/v1/functions/*/cyclic-subscriptions", - "GET:/api/v1/functions/*/cyclic-subscriptions/*", - "GET:/api/v1/functions/*/cyclic-subscriptions/*/events", - // Locks: components and apps (read-only) - "GET:/api/v1/components/*/locks", - "GET:/api/v1/components/*/locks/*", - "GET:/api/v1/apps/*/locks", - "GET:/api/v1/apps/*/locks/*", - // Updates (read-only) - "GET:/api/v1/updates", - "GET:/api/v1/updates/*", - "GET:/api/v1/updates/*/status", - // Scripts: read (inherited from VIEWER) - "GET:/api/v1/components/*/scripts", - "GET:/api/v1/components/*/scripts/*", - "GET:/api/v1/apps/*/scripts", - "GET:/api/v1/apps/*/scripts/*", - "GET:/api/v1/components/*/scripts/*/executions/*", - "GET:/api/v1/apps/*/scripts/*/executions/*", - // Status: apps and components (read-only, inherited from VIEWER) - "GET:/api/v1/apps/*/status", - "GET:/api/v1/components/*/status", - // Docs - "GET:/api/v1/docs", - // Inherited from OPERATOR - write permissions - // Trigger operations: all entity types (POST) - "POST:/api/v1/components/*/operations/*/executions", - "POST:/api/v1/apps/*/operations/*/executions", - "POST:/api/v1/areas/*/operations/*/executions", - "POST:/api/v1/functions/*/operations/*/executions", - // Update operations: all entity types (PUT) - "PUT:/api/v1/components/*/operations/*/executions/*", - "PUT:/api/v1/apps/*/operations/*/executions/*", - "PUT:/api/v1/areas/*/operations/*/executions/*", - "PUT:/api/v1/functions/*/operations/*/executions/*", - // Cancel actions: all entity types (DELETE) - "DELETE:/api/v1/components/*/operations/*/executions/*", - "DELETE:/api/v1/apps/*/operations/*/executions/*", - "DELETE:/api/v1/areas/*/operations/*/executions/*", - "DELETE:/api/v1/functions/*/operations/*/executions/*", - // Clear faults: all entity types - "DELETE:/api/v1/components/*/faults/*", - "DELETE:/api/v1/apps/*/faults/*", - "DELETE:/api/v1/areas/*/faults/*", - "DELETE:/api/v1/functions/*/faults/*", - // Clear all faults: per-entity and global - "DELETE:/api/v1/components/*/faults", - "DELETE:/api/v1/apps/*/faults", - "DELETE:/api/v1/areas/*/faults", - "DELETE:/api/v1/functions/*/faults", - "DELETE:/api/v1/faults", - // Publish data: all entity types (PUT) - "PUT:/api/v1/components/*/data/*", - "PUT:/api/v1/apps/*/data/*", - "PUT:/api/v1/areas/*/data/*", - "PUT:/api/v1/functions/*/data/*", - // Cyclic subscriptions: create/update/delete (apps, components, functions) - "POST:/api/v1/components/*/cyclic-subscriptions", - "POST:/api/v1/apps/*/cyclic-subscriptions", - "POST:/api/v1/functions/*/cyclic-subscriptions", - "PUT:/api/v1/components/*/cyclic-subscriptions/*", - "PUT:/api/v1/apps/*/cyclic-subscriptions/*", - "PUT:/api/v1/functions/*/cyclic-subscriptions/*", - "DELETE:/api/v1/components/*/cyclic-subscriptions/*", - "DELETE:/api/v1/apps/*/cyclic-subscriptions/*", - "DELETE:/api/v1/functions/*/cyclic-subscriptions/*", - // Locks: acquire/extend/release (components and apps) - "POST:/api/v1/components/*/locks", - "POST:/api/v1/apps/*/locks", - "PUT:/api/v1/components/*/locks/*", - "PUT:/api/v1/apps/*/locks/*", - "DELETE:/api/v1/components/*/locks/*", - "DELETE:/api/v1/apps/*/locks/*", - // Bulk data: upload/delete (apps and components only) - "POST:/api/v1/components/*/bulk-data/*", - "POST:/api/v1/apps/*/bulk-data/*", - "DELETE:/api/v1/components/*/bulk-data/*/*", - "DELETE:/api/v1/apps/*/bulk-data/*/*", - // Scripts: start/control/delete executions (inherited from OPERATOR) - "POST:/api/v1/components/*/scripts/*/executions", - "POST:/api/v1/apps/*/scripts/*/executions", - "PUT:/api/v1/components/*/scripts/*/executions/*", - "PUT:/api/v1/apps/*/scripts/*/executions/*", - "DELETE:/api/v1/components/*/scripts/*/executions/*", - "DELETE:/api/v1/apps/*/scripts/*/executions/*", - // Lifecycle control: apps and components, including the destructive - // shutdown / force-shutdown teardown transitions (CONFIGURATOR and above). - "PUT:/api/v1/apps/*/status/start", - "PUT:/api/v1/apps/*/status/restart", - "PUT:/api/v1/apps/*/status/force-restart", - "PUT:/api/v1/apps/*/status/shutdown", - "PUT:/api/v1/apps/*/status/force-shutdown", - "PUT:/api/v1/components/*/status/start", - "PUT:/api/v1/components/*/status/restart", - "PUT:/api/v1/components/*/status/force-restart", - "PUT:/api/v1/components/*/status/shutdown", - "PUT:/api/v1/components/*/status/force-shutdown", - // --- Configurator-specific write permissions --- - // Modify configurations: all entity types (PUT) - "PUT:/api/v1/components/*/configurations/*", - "PUT:/api/v1/apps/*/configurations/*", - "PUT:/api/v1/areas/*/configurations/*", - "PUT:/api/v1/functions/*/configurations/*", - // Reset configurations: all entity types (DELETE) - "DELETE:/api/v1/components/*/configurations", - "DELETE:/api/v1/components/*/configurations/*", - "DELETE:/api/v1/apps/*/configurations", - "DELETE:/api/v1/apps/*/configurations/*", - "DELETE:/api/v1/areas/*/configurations", - "DELETE:/api/v1/areas/*/configurations/*", - "DELETE:/api/v1/functions/*/configurations", - "DELETE:/api/v1/functions/*/configurations/*", - // Log configuration: all entity types (PUT) - "PUT:/api/v1/components/*/logs/configuration", - "PUT:/api/v1/apps/*/logs/configuration", - "PUT:/api/v1/areas/*/logs/configuration", - "PUT:/api/v1/functions/*/logs/configuration", - // Scripts: upload/delete scripts - "POST:/api/v1/components/*/scripts", - "POST:/api/v1/apps/*/scripts", - "DELETE:/api/v1/components/*/scripts/*", - "DELETE:/api/v1/apps/*/scripts/*", - // Updates: register/prepare/execute/automated/delete - "POST:/api/v1/updates", - "PUT:/api/v1/updates/*/prepare", - "PUT:/api/v1/updates/*/execute", - "PUT:/api/v1/updates/*/automated", - "DELETE:/api/v1/updates/*", - }}, - {UserRole::ADMIN, - { - // Full access - all endpoints including auth - // ** matches any number of path segments - "GET:/api/v1/**", - "POST:/api/v1/**", - "PUT:/api/v1/**", - "DELETE:/api/v1/**", - // Auth endpoints are always accessible to admin - "POST:/api/v1/auth/authorize", - "POST:/api/v1/auth/token", - "POST:/api/v1/auth/revoke", - }}}; + static const RoutePermissions permissions = {{UserRole::ADMIN, + { + "GET:/api/v1/**", + "POST:/api/v1/**", + "PUT:/api/v1/**", + "DELETE:/api/v1/**", + }}}; return permissions; } diff --git a/src/ros2_medkit_gateway/src/core/auth/auth_manager.cpp b/src/ros2_medkit_gateway/src/core/auth/auth_manager.cpp index a0e313d8a..51ac0f626 100644 --- a/src/ros2_medkit_gateway/src/core/auth/auth_manager.cpp +++ b/src/ros2_medkit_gateway/src/core/auth/auth_manager.cpp @@ -263,15 +263,24 @@ TokenValidationResult AuthManager::validate_token(const std::string & token, Tok return result; } +void AuthManager::add_route_permissions(const RoutePermissions & permissions) { + for (const auto & [role, entries] : permissions) { + permissions_[role].insert(entries.begin(), entries.end()); + } +} + AuthorizationResult AuthManager::check_authorization(UserRole role, const std::string & method, const std::string & path) const { AuthorizationResult result; - const auto & role_permissions = AuthConfig::get_role_permissions(); - auto it = role_permissions.find(role); - if (it == role_permissions.end()) { + auto it = permissions_.find(role); + if (it == permissions_.end()) { result.authorized = false; - result.error = "Unknown role"; + // Not "unknown role" any more: every enumerator is a role the gateway + // knows, and reaching here means the table was never given entries for it + // (see add_route_permissions). Saying "unknown role" would send a reader + // hunting for a typo in the token instead of a gap in the table. + result.error = "No permissions are configured for this role"; return result; } diff --git a/src/ros2_medkit_gateway/src/core/http/detail/primitives.cpp b/src/ros2_medkit_gateway/src/core/http/detail/primitives.cpp index 5b10b801e..f48dffa60 100644 --- a/src/ros2_medkit_gateway/src/core/http/detail/primitives.cpp +++ b/src/ros2_medkit_gateway/src/core/http/detail/primitives.cpp @@ -47,6 +47,14 @@ void write_json_body(FrameworkOrPluginAccess /*token*/, httplib::Response & res, res.set_content(body.dump(2), kContentTypeJson); } +void write_json_text(FrameworkOrPluginAccess /*token*/, httplib::Response & res, const std::string & body, int status) { + // Same sentinel contract as write_json_body above. + if (status != 0) { + res.status = status; + } + res.set_content(body, kContentTypeJson); +} + void write_generic_error(FrameworkOrPluginAccess /*token*/, httplib::Response & res, const ErrorInfo & err) { res.status = clamp_error_status(err.http_status); diff --git a/src/ros2_medkit_gateway/src/core/openapi/document_checks.cpp b/src/ros2_medkit_gateway/src/core/openapi/document_checks.cpp index 986ddcbbe..ca5cfd970 100644 --- a/src/ros2_medkit_gateway/src/core/openapi/document_checks.cpp +++ b/src/ros2_medkit_gateway/src/core/openapi/document_checks.cpp @@ -14,7 +14,9 @@ #include "ros2_medkit_gateway/core/openapi/document_checks.hpp" +#include #include +#include #include namespace ros2_medkit_gateway { @@ -126,5 +128,33 @@ std::set unreachable_schemas(const nlohmann::json & document) { return unreachable; } +std::map referenced_schemas(const nlohmann::json & subtree, + const std::map & pool) { + std::vector frontier; + collect_refs(subtree, frontier); + + std::map reached; + std::set seen; + while (!frontier.empty()) { + const std::string ref = std::move(frontier.back()); + frontier.pop_back(); + if (!seen.insert(ref).second) { + continue; + } + std::string section; + std::string name; + if (!split_ref(ref, section, name) || section != "schemas") { + continue; + } + const auto entry = pool.find(name); + if (entry == pool.end()) { + continue; // dangling $ref - a different defect, reported by its own test + } + reached.emplace(name, entry->second); + collect_refs(entry->second, frontier); + } + return reached; +} + } // namespace openapi } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp b/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp index a1c459496..64ef822f1 100644 --- a/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp +++ b/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp @@ -446,6 +446,18 @@ RouteEntry & RouteEntry::error_renderer(ErrorRenderer renderer) { return *this; } +RouteEntry & RouteEntry::requires_role(UserRole role) { + required_role_ = role; + role_declared_ = true; + return *this; +} + +RouteEntry & RouteEntry::public_route() { + required_role_.reset(); + role_declared_ = true; + return *this; +} + // ----------------------------------------------------------------------------- // RouteRegistry route registration // ----------------------------------------------------------------------------- @@ -730,7 +742,7 @@ RouteEntry & RouteRegistry::static_asset(const std::string & openapi_path, } RouteEntry & RouteRegistry::docs_endpoint(const std::string & openapi_path, - std::function(http::TypedRequest)> handler) { + std::function(http::TypedRequest)> handler) { auto renderer = std::make_shared(ErrorRenderer::kSovdGenericError); auto gate = std::make_shared>(); HandlerFn fn = [handler = std::move(handler), renderer, gate](const httplib::Request & req, httplib::Response & res) { @@ -745,20 +757,26 @@ RouteEntry & RouteRegistry::docs_endpoint(const std::string & openapi_path, write_typed_error(res, outcome.error(), renderer); return; } - http::detail::write_json_body(http::detail::FrameworkOrPluginAccess{}, res, outcome.value(), 200); + http::detail::write_json_text(http::detail::FrameworkOrPluginAccess{}, res, outcome.value(), 200); }; auto & entry = add_route("get", openapi_path, std::move(fn)); entry.error_renderer_ = renderer; entry.gate_ = gate; entry.response(200, "OpenAPI specification document", nlohmann::json{{"type", "object"}, {"additionalProperties", true}}); - entry.hidden(); // The docs spec endpoint describes itself externally. return entry; } -RouteEntry & RouteRegistry::docs_subtree(const std::string & regex_pattern, HandlerFn handler) { - auto & entry = add_raw_route("get", regex_pattern, regex_pattern, std::move(handler)); - entry.hidden(); +RouteEntry & RouteRegistry::docs_subtree(const std::string & openapi_path, const std::string & regex_pattern, + HandlerFn handler) { + auto & entry = add_raw_route("get", openapi_path, regex_pattern, std::move(handler)); + // Attached here, not at the call site, for the same reason `docs_endpoint` + // does it: a success status is a property of the registration, and a call + // site that hand-attaches its own 2xx is the drift this design forbids + // (design/dto_contract.rst). The body is the OpenAPI document itself, which + // is why this helper exists rather than a typed `get`. + entry.response(200, "OpenAPI specification document scoped to the requested path", + nlohmann::json{{"type", "object"}, {"additionalProperties", true}}); return entry; } @@ -1269,16 +1287,232 @@ nlohmann::json RouteRegistry::to_openapi_paths() const { operation["operationId"] = op_id; } + // The role declared at the registration, in the shape a plugin's + // `OperationDesc::requires_role` already emits - one scheme, the role as + // its scope. `public_route()` emits the empty requirement list, which is + // how OpenAPI says "this operation overrides the document-level + // requirement and needs no token"; without it the `/auth/*` endpoints + // would inherit a token requirement that is precisely what a caller uses + // them to obtain. + // + // Emitted unconditionally here, and removed again by + // `CapabilityGenerator::generate_impl` when `auth.enabled` is off. That + // split is deliberate: whether the running gateway honours a role is a + // property of the deployment, not of the registration, and answering it + // once over the finished document is what keeps every producer - registry, + // plugin fold, sub-documents - on one rule. + if (route.role_declared_) { + if (route.required_role_.has_value()) { + operation["security"] = + nlohmann::json::array({{{"bearerAuth", nlohmann::json::array({role_to_string(*route.required_role_)})}}}); + } else { + operation["security"] = nlohmann::json::array(); + } + } + paths[route.path_][route.method_] = std::move(operation); } return paths; } +// ----------------------------------------------------------------------------- +// Projections over an emitted `paths` object +// ----------------------------------------------------------------------------- + +nlohmann::json paths_under(const nlohmann::json & paths, const std::string & prefix) { + nlohmann::json subtree = nlohmann::json::object(); + if (!paths.is_object()) { + return subtree; + } + for (const auto & [key, item] : paths.items()) { + const bool same = key == prefix; + // The segment boundary: the next character after the prefix must be the + // separator, or `/data` swallows `/data-groups`. + const bool beneath = + key.size() > prefix.size() && key.compare(0, prefix.size(), prefix) == 0 && key[prefix.size()] == '/'; + if (same || beneath) { + subtree[key] = item; + } + } + return subtree; +} + +void strip_entity_path_parameter(nlohmann::json & path_item, const std::string & param_name) { + if (!path_item.is_object()) { + return; + } + for (auto & operation : path_item) { + // A path item can carry non-operation members (a path-level `summary`, a + // vendor extension). Only an object can hold `parameters`. + if (!operation.is_object() || !operation.contains("parameters") || !operation["parameters"].is_array()) { + continue; + } + // Rebuilt rather than erased in place: nlohmann's array `erase` takes an + // iterator and invalidates the ones past it, so removing while iterating + // needs an index dance this does not. + // + // Members are read through `contains()` + `operator[]` to match the + // convention the parameter scan in `to_openapi_paths()` set, and for the + // reason recorded there - GCC inlines nlohmann's iterator dereference into + // a -Wnull-dereference this build treats as an error. Whether it would fire + // on this particular loop was not tested; following the convention costs + // nothing. + nlohmann::json kept = nlohmann::json::array(); + for (const auto & param : operation["parameters"]) { + const bool answered_by_the_substitution = param.is_object() && param.contains("in") && param["in"] == "path" && + param.contains("name") && param["name"] == param_name; + if (!answered_by_the_substitution) { + kept.push_back(param); + } + } + if (kept.empty()) { + // An empty `parameters` array is legal but says nothing; dropping it + // keeps a concrete entity's operation looking like what it is. + operation.erase("parameters"); + } else { + operation["parameters"] = std::move(kept); + } + } +} + // ----------------------------------------------------------------------------- // tags - collect unique tags // ----------------------------------------------------------------------------- +namespace { + +/// Rewrite a route's cpp-httplib regex as an `AuthManager::matches_path` +/// pattern. +/// +/// The regexes `to_regex_path()` builds contain exactly two constructs beyond +/// literal text - `([^/]+)` for a segment and `(.+)` for a slash-spanning tail +/// - plus the `/?$` anchor it appends. `add_raw_route()` supplies its own +/// regex, and the one caller (`docs_subtree`) uses the same two constructs with +/// a bare `$`. Both anchors are handled; anything else left over is reported by +/// the caller rather than guessed at, because a pattern that silently drops a +/// metacharacter would grant a path nobody wrote down. +/// +/// The optional trailing slash the `/?` anchor accepts is deliberately NOT +/// mirrored into a second pattern, and the reason is behaviour rather than +/// cost: `GET /api/v1/health/` was 403 for a viewer before this derivation and +/// still is, so mirroring the slash would be a widening nobody asked for. +/// +/// It would also double a set that this change already grew. The old table +/// held seven entries for ADMIN - the four `**` wildcards plus three +/// `POST:/api/v1/auth/*` literals that `POST:/api/v1/**` already covered, and +/// that the middleware exempts by prefix before the table is consulted at all - +/// and a literal list for everyone else; the derived one carries an entry per +/// route per granted role, and `check_authorization` scans a role's whole set, +/// compiling a `std::regex` per pattern, on any request the exact-match lookup +/// misses. Doubling it again would be paid on every such request. Neither +/// reason on its own would settle it; together they do. +/// +/// The one exception is the root route, whose regex IS the anchor: it gets both +/// forms, because `/api/v1` without the slash is otherwise unreachable - it was +/// refused for every role, ADMIN included, before this derivation. +std::optional regex_to_permission_pattern(const std::string & regex_path) { + std::string body = regex_path; + if (body.size() >= 3 && body.compare(body.size() - 3, 3, "/?$") == 0) { + body.erase(body.size() - 3); + } else if (!body.empty() && body.back() == '$') { + body.pop_back(); + } + + std::string pattern; + pattern.reserve(body.size()); + for (size_t i = 0; i < body.size();) { + if (body.compare(i, 7, "([^/]+)") == 0) { + pattern += '*'; + i += 7; + continue; + } + if (body.compare(i, 4, "(.+)") == 0) { + pattern += "**"; + i += 4; + continue; + } + // Any regex metacharacter that is not one of the two known captures means + // the route's URI was written in a form this translation does not model. + if (std::string("()[]{}.+*?^$|\\").find(body[i]) != std::string::npos) { + return std::nullopt; + } + pattern += body[i]; + ++i; + } + return pattern; +} + +/// Every role at or above `role`. `UserRole` is declared weakest-first, so the +/// tail of the enumerator list is exactly "this role and stronger" - the +/// expansion `AuthConfig`'s lack of inheritance forces. +std::vector roles_at_or_above(UserRole role) { + static const std::array kAscending = {UserRole::VIEWER, UserRole::OPERATOR, UserRole::CONFIGURATOR, + UserRole::ADMIN}; + std::vector out; + bool reached = false; + for (UserRole candidate : kAscending) { + reached = reached || candidate == role; + if (reached) { + out.push_back(candidate); + } + } + return out; +} + +} // namespace + +RoutePermissions RouteRegistry::route_permissions(const std::string & api_prefix) const { + RoutePermissions permissions; + // Every route, `hidden()` included. Hidden keeps a route out of the document, + // not out of the router: the request still arrives and still meets this + // table. The two hidden 405 stubs (bulk-data writes on areas and functions) + // are the case in point - without an entry the caller is told "forbidden" + // where the truth is "this entity type cannot host uploads". + for (const auto & route : routes_) { + // Skipped: a `public_route()` (the middleware answers it before the table + // is consulted, so an entry would be dead weight on a set that is scanned + // per request) and a route that declared nothing at all (which + // validate_completeness() reports as an error). + if (!route.role_declared_ || !route.required_role_.has_value()) { + continue; + } + auto pattern = regex_to_permission_pattern(route.regex_path_); + if (!pattern.has_value()) { + continue; // Reported by validate_completeness(). + } + std::string method_upper = route.method_; + std::transform(method_upper.begin(), method_upper.end(), method_upper.begin(), [](unsigned char c) { + return std::toupper(c); + }); + + // Built once per route rather than once per granted role: the entry is the + // same string in every role's set, and a route declaring VIEWER lands in + // four of them. + std::string entry; + entry.reserve(method_upper.size() + api_prefix.size() + pattern->size() + 2); + entry += method_upper; + entry += ':'; + entry += api_prefix; + entry += *pattern; + + std::vector entries; + entries.reserve(2); + if (pattern->empty()) { + // The root route: its regex is the anchor alone, so the entry above ends + // at the bare prefix. Both spellings are reachable URIs. + std::string with_slash = entry; + with_slash += '/'; + entries.push_back(std::move(with_slash)); + } + entries.push_back(std::move(entry)); + for (UserRole granted : roles_at_or_above(*route.required_role_)) { + permissions[granted].insert(entries.begin(), entries.end()); + } + } + return permissions; +} + std::vector RouteRegistry::to_endpoint_list(const std::string & api_prefix) const { std::vector endpoints; endpoints.reserve(routes_.size()); @@ -1315,17 +1549,34 @@ std::vector RouteRegistry::validate_completeness() const { std::vector issues; for (const auto & route : routes_) { - // Hidden routes are excluded from OpenAPI - skip validation - if (route.hidden_) { - continue; - } - std::string method_upper = route.method_; std::transform(method_upper.begin(), method_upper.end(), method_upper.begin(), [](unsigned char c) { return std::toupper(c); }); std::string route_id = method_upper + " " + route.path_; + // Checked ahead of the hidden-route skip, and that ordering is the whole + // value of the check. `hidden()` removes a route from the document, not + // from the router: the request still arrives, the middleware still consults + // the permission table, and a route with no declaration has no entry there. + // Fail-closed enforcement turns that into a 403 nobody wrote down - which + // no test of the document could ever see, because the route is not in it. + if (!route.role_declared_) { + issues.push_back({ValidationIssue::Severity::kError, route_id, + "No requires_role() or public_route() on the registration; with fail-closed " + "authorization the route answers 403 for every role below ADMIN"}); + } else if (route.required_role_.has_value() && !regex_to_permission_pattern(route.regex_path_).has_value()) { + issues.push_back({ValidationIssue::Severity::kError, route_id, + "The route's URI pattern '" + route.regex_path_ + + "' cannot be expressed as a permission pattern, so the declared role grants " + "nothing and the route answers 403 for every role below ADMIN"}); + } + + // Hidden routes are excluded from OpenAPI - skip the document checks + if (route.hidden_) { + continue; + } + // Every route must have a tag if (route.tag_.empty()) { issues.push_back({ValidationIssue::Severity::kError, route_id, "Missing tag"}); 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 2a939d418..776eaa361 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp @@ -1558,7 +1558,6 @@ http::Result DiscoveryHandlers::get_function(const http::Ty detail.faults = base_uri + "/faults"; detail.logs = base_uri + "/logs"; detail.bulk_data = base_uri + "/bulk-data"; - detail.x_medkit_graph = base_uri + "/x-medkit-graph"; detail.cyclic_subscriptions = base_uri + "/cyclic-subscriptions"; detail.triggers = base_uri + "/triggers"; @@ -1571,6 +1570,16 @@ http::Result DiscoveryHandlers::get_function(const http::Ty append_plugin_capabilities(func_caps, "functions", func.id, SovdEntityType::FUNCTION, ctx_.node()); detail.capabilities = func_caps; + // Read off the capability list rather than set unconditionally: nothing in + // the gateway serves `x-medkit-graph`. The route exists only while a + // plugin registers the capability (the graph provider does so for every + // Function in `set_context`), and `append_plugin_capabilities` above is + // where that registration becomes visible here. Set unconditionally, this + // URI answered 404 on every gateway running without the plugin. + if (has_capability(func_caps, "x-medkit-graph")) { + detail.x_medkit_graph = base_uri + "/x-medkit-graph"; + } + LinksBuilder links; links.self("/api/v1/functions/" + func.id).collection("/api/v1/functions"); auto links_json = links.build(); diff --git a/src/ros2_medkit_gateway/src/http/handlers/docs_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/docs_handlers.cpp index 7f748644e..0dd427ab2 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/docs_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/docs_handlers.cpp @@ -15,6 +15,7 @@ #include "ros2_medkit_gateway/core/http/handlers/docs_handlers.hpp" #include +#include #include "../../openapi/capability_generator.hpp" #include "ros2_medkit_gateway/core/http/error_codes.hpp" @@ -29,8 +30,23 @@ namespace ros2_medkit_gateway { namespace handlers { -void DocsHandlers::write_json(httplib::Response & res, const nlohmann::json & body) { - http::detail::write_json_body(http::detail::FrameworkOrPluginAccess{}, res, body); +namespace { + +/// The error a typed docs handler returns. Same three fields the raw +/// `write_error` below fills in, so the two `/docs` routes answer with the +/// same body whichever way they are mounted. +ErrorInfo make_docs_error(int status, const std::string & code, const std::string & message) { + ErrorInfo err; + err.code = code; + err.message = message; + err.http_status = status; + return err; +} + +} // namespace + +void DocsHandlers::write_json_text(httplib::Response & res, const std::string & body) { + http::detail::write_json_text(http::detail::FrameworkOrPluginAccess{}, res, body); } void DocsHandlers::write_error(httplib::Response & res, int status, const std::string & code, @@ -59,18 +75,16 @@ DocsHandlers::DocsHandlers(HandlerContext & ctx, GatewayNode & node, PluginManag DocsHandlers::~DocsHandlers() = default; -void DocsHandlers::handle_docs_root(const httplib::Request & /*req*/, httplib::Response & res) { +http::Result DocsHandlers::handle_docs_root(http::TypedRequest /*req*/) { if (!docs_enabled_) { - DocsHandlers::write_error(res, 501, ERR_NOT_IMPLEMENTED, "Capability description is disabled"); - return; + return tl::unexpected(make_docs_error(501, ERR_NOT_IMPLEMENTED, "Capability description is disabled")); } - auto spec = generator_->generate("/"); + auto spec = generator_->generate_serialized("/"); if (!spec) { - DocsHandlers::write_error(res, 500, ERR_INTERNAL_ERROR, "Failed to generate capability description"); - return; + return tl::unexpected(make_docs_error(500, ERR_INTERNAL_ERROR, "Failed to generate capability description")); } - DocsHandlers::write_json(res, *spec); + return std::move(*spec); } void DocsHandlers::handle_docs_any_path(const httplib::Request & req, httplib::Response & res) { @@ -80,13 +94,13 @@ void DocsHandlers::handle_docs_any_path(const httplib::Request & req, httplib::R } auto base_path = req.matches[1].str(); - auto spec = generator_->generate(base_path); + auto spec = generator_->generate_serialized(base_path); if (!spec) { DocsHandlers::write_error(res, 404, ERR_RESOURCE_NOT_FOUND, "No capability description available for the requested path"); return; } - DocsHandlers::write_json(res, *spec); + DocsHandlers::write_json_text(res, *spec); } #ifdef ENABLE_SWAGGER_UI 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 8eb91f987..226dc9699 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp @@ -164,7 +164,14 @@ http::Result HealthHandlers::get_root(const http::TypedReques } } - // Read docs.enabled parameter (defaults to true) +#ifdef ENABLE_SWAGGER_UI + // The two `/docs` routes are in the registry, so `to_endpoint_list` above + // already lists them; a hand-written entry here would list each twice, and + // under a second spelling of the path parameter at that. Swagger UI is + // still mounted straight onto the server, so it is still added by hand - + // and it is the only endpoint `docs.enabled` now hides from this list. The + // `/docs` routes stay listed when the capability is off, because they stay + // mounted and answer 501: this list says what is mounted, not what is on. bool docs_enabled = true; if (ctx_.node()) { try { @@ -173,15 +180,10 @@ http::Result HealthHandlers::get_root(const http::TypedReques // Parameter may not be declared - default to true } } - - // Add docs endpoints (not in registry - registered directly with server) if (docs_enabled) { - endpoints.push_back("GET " + std::string(API_BASE_PATH) + "/docs"); - endpoints.push_back("GET " + std::string(API_BASE_PATH) + "/{entity-path}/docs"); -#ifdef ENABLE_SWAGGER_UI endpoints.push_back("GET " + std::string(API_BASE_PATH) + "/swagger-ui"); -#endif } +#endif const auto & auth_config = ctx_.auth_config(); const auto & tls_config = ctx_.tls_config(); diff --git a/src/ros2_medkit_gateway/src/http/rest_server.cpp b/src/ros2_medkit_gateway/src/http/rest_server.cpp index 82953fa60..4a9160784 100644 --- a/src/ros2_medkit_gateway/src/http/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/http/rest_server.cpp @@ -205,9 +205,53 @@ void RESTServer::setup_pre_routing_handler() { return; } + // Answer from the pre-routing handler without stranding the request body. + // + // cpp-httplib runs this handler at the top of `Server::routing()`, *before* + // it reads the payload off the socket, and treats `Handled` as "response is + // complete" - it writes the answer and goes straight back to waiting for the + // next request on the same keep-alive connection. The unread payload is still + // sitting there, so that next request gets parsed starting at somebody's JSON + // body, fails the request-line parse and is answered 400. The client sees a + // rejection on a request it got right, blamed on the wrong request; that is + // how a viewer's 403 on a POST turned into a 400 on the following probe when + // this file's RBAC contract test first ran. + // + // RFC 9112 section 9.6 is explicit about the case: a server that responds + // before reading the whole body signals `Connection: close`. Conforming + // clients then retire the connection rather than reuse a desynchronised one. + // + // That is the whole fix available from here, and it is a signal to the + // client rather than an actual close. Three limits, all of them real: + // + // * the handler has no access to the stream, so it cannot drain the body + // itself; + // * it cannot close the connection either. `connection_closed` is decided + // from the *request* headers before routing (`httplib.h`, Server:: + // process_request), so a response header does not reach it and the + // server loop goes on waiting for another request on the socket; + // * the response therefore carries `Connection: close` *and* the + // `Keep-Alive: timeout=..., max=...` cpp-httplib adds whenever + // `close_connection` is false (httplib.h, write_response_core). The two + // contradict each other on the wire. `Connection: close` wins for any + // client that reads it, which is what makes this work in practice, but + // a client that honours the `Keep-Alive` instead still reuses the socket + // and still sees the spurious 400. + // + // Closing the gap properly means reading the body before the pre-routing + // handler runs, which is cpp-httplib's decision, not ours. + auto handled = [](const httplib::Request & req, httplib::Response & res) { + const std::string content_length = req.get_header_value("Content-Length"); + const bool carries_body = (!content_length.empty() && content_length != "0") || req.has_header("Transfer-Encoding"); + if (carries_body) { + res.set_header("Connection", "close"); + } + return httplib::Server::HandlerResponse::Handled; + }; + // Set up pre-routing handler for CORS and Authentication // This handler runs before any route handler - srv->set_pre_routing_handler([this](const httplib::Request & req, httplib::Response & res) { + srv->set_pre_routing_handler([this, handled](const httplib::Request & req, httplib::Response & res) { // 1. Handle CORS (existing logic) if (cors_config_.enabled) { std::string origin = req.get_header_value("Origin"); @@ -232,7 +276,7 @@ void RESTServer::setup_pre_routing_handler() { } else { res.status = 403; } - return httplib::Server::HandlerResponse::Handled; + return handled(req, res); } } @@ -242,7 +286,7 @@ void RESTServer::setup_pre_routing_handler() { RateLimiter::apply_headers(rl_result, res); if (!rl_result.allowed) { RateLimiter::apply_rejection(rl_result, res); - return httplib::Server::HandlerResponse::Handled; + return handled(req, res); } } @@ -256,7 +300,7 @@ void RESTServer::setup_pre_routing_handler() { if (!result.allowed) { AuthMiddleware::apply_to_response(result, res); - return httplib::Server::HandlerResponse::Handled; + return handled(req, res); } } @@ -318,15 +362,83 @@ void RESTServer::setup_routes() { throw std::runtime_error("No server instance available for route setup"); } - // === Docs routes - MUST be before data/config item routes to avoid (.+) capture collision === - // These use special regex patterns that don't map cleanly to OpenAPI {param} style, - // so they are registered directly with the server rather than through the route registry. - srv->Get(api_path("/docs"), [this](const httplib::Request & req, httplib::Response & res) { - docs_handlers_->handle_docs_root(req, res); - }); - srv->Get((api_path("") + R"((.+)/docs$)"), [this](const httplib::Request & req, httplib::Response & res) { - docs_handlers_->handle_docs_any_path(req, res); - }); + // === Docs routes === + // + // Added to the registry ahead of every other route, and that is load-bearing + // rather than tidy: cpp-httplib matches in registration order and + // `RouteRegistry::register_all` preserves the order routes were added, so + // going first is what keeps `/apps/x/data/some/topic/docs` reaching the docs + // handler instead of being swallowed by the data-item route's trailing + // `(.+)`. + // + // The three routes mounted straight onto the server further down still + // precede these, because `register_all` runs at the end of this function. + // Of their patterns only `/swagger-ui/([^/]+)` can also match a `/docs` + // request - on the single URI `/api/v1/swagger-ui/docs`, and only in a build + // configured with `-DENABLE_SWAGGER_UI=ON`. + // + // Through the registry, not straight onto the server, so the document + // contains the endpoint that serves it. `/docs` needs the raw + // escape hatch because its `(.+)` prefix is a whole entity or resource path + // rather than a `{param}` segment - see `docs_subtree`. + route_registry_ + ->docs_endpoint("/docs", + [this](http::TypedRequest req) -> http::Result { + return docs_handlers_->handle_docs_root(req); + }) + .tag("Server") + .requires_role(UserRole::VIEWER) + .summary("Capability description") + .description( + "Returns the OpenAPI 3.1 document describing every endpoint this gateway serves, including the routes " + "loaded plugins mount. This is the SOVD capability description for the server as a whole; append `/docs` " + "to any entity or resource path for the sub-document scoped to it.") + .operation_id("getCapabilityDescription") + // 501 when `docs.enabled` is false - the routes stay mounted and report + // the capability is off rather than vanishing into a 404. + .errors({501}); + + route_registry_ + // No `api_path(...)` on the regex: `register_all` prepends the API + // prefix to every route's pattern, so writing it here would mount the + // route at `/api/v1/api/v1/...`. + ->docs_subtree("/{entity_path}/docs", R"((.+)/docs$)", + [this](const httplib::Request & req, httplib::Response & res) { + docs_handlers_->handle_docs_any_path(req, res); + }) + .tag("Server") + // VIEWER, and because the pattern this derives is a catch-all, it grants + // a viewer *any* GET path ending in `/docs` - `/api/v1/a/b/c/d/docs` + // included. That is wider than the entity paths the handler resolves, and + // it is deliberately left alone rather than narrowed to the four entity + // types. + // + // Narrowing would put the permission below the route. This route's regex + // really does match any `.../docs`, so a tighter entry would answer 403 + // on paths the handler serves - the precise defect deriving the pattern + // from the regex exists to prevent - and would turn today's honest 404 on + // a nonsense prefix into a 403. + // + // Nor does the breadth expose a plugin route ending in `/docs`: this + // route is mounted by `register_all` at the end of `setup_routes()`, + // while `PluginManager::register_routes` runs after it, and cpp-httplib + // matches in registration order. Such a plugin route is already shadowed + // at the router and never served at all - a routing problem, not a + // permission one, and one narrowing this entry would not fix. + .requires_role(UserRole::VIEWER) + .summary("Scoped capability description") + .description( + "Returns the OpenAPI 3.1 document scoped to one entity, resource collection or resource - the SOVD " + "context-specific capability description. 404 when the prefix names nothing this gateway serves.") + .operation_id("getScopedCapabilityDescription") + .path_param("entity_path", + "The entity or resource path the description is scoped to, without a leading slash - for example " + "`apps`, `apps/temp_sensor` or `apps/temp_sensor/data`. It is a whole path rather than one segment, " + "so its slashes must be sent unescaped.") + // No `.response(200, ...)` here: `docs_subtree` attaches it, the way + // `docs_endpoint` does. Success statuses come from the registration, not + // from the call site - see design/dto_contract.rst. + .errors({501}); #ifdef MEDKIT_STATUS_RECORDER // Test builds only (BUILD_TESTING; see CMakeLists.txt). Serves what the @@ -395,6 +507,7 @@ void RESTServer::setup_routes() { res.set_content(nlohmann::json{{"items", items}}.dump(2), "application/json"); }) .tag("FaultTriggers") + .requires_role(UserRole::VIEWER) .summary("List fault-trigger rules") .description( "Threshold rules on the app's discovered data points; each fires a fault on cross " @@ -454,6 +567,7 @@ void RESTServer::setup_routes() { res.set_content(FaultTriggerEngine::rule_to_json(*created).dump(2), "application/json"); }) .tag("FaultTriggers") + .requires_role(UserRole::OPERATOR) .summary("Create a fault-trigger rule") .description( "Body: data_name, operator (>, <, >=, <=, ==), threshold, fault_code, severity " @@ -495,6 +609,7 @@ void RESTServer::setup_routes() { res.status = 204; }) .tag("FaultTriggers") + .requires_role(UserRole::OPERATOR) .summary("Delete a fault-trigger rule") .description( "Removes the rule; a currently-asserted fault from it is cleared " @@ -528,6 +643,7 @@ void RESTServer::setup_routes() { return health_handlers_->get_health(req); }) .tag("Server") + .requires_role(UserRole::VIEWER) .summary("Health check") .description("Returns gateway health status.") .operation_id("getHealth"); @@ -537,6 +653,7 @@ void RESTServer::setup_routes() { return health_handlers_->get_root(req); }) .tag("Server") + .requires_role(UserRole::VIEWER) .summary("API overview") .description("Returns gateway metadata, available endpoints, and capabilities.") .operation_id("getRoot"); @@ -546,6 +663,7 @@ void RESTServer::setup_routes() { return health_handlers_->get_version_info(req); }) .tag("Server") + .requires_role(UserRole::VIEWER) .summary("SOVD version information") .description("Returns SOVD specification version and vendor info.") // HealthHandlers::get_version_info -> merge_peer_items (peer vendor blocks). @@ -563,6 +681,7 @@ void RESTServer::setup_routes() { return discovery_handlers_->get_areas(req); }) .tag("Discovery") + .requires_role(UserRole::VIEWER) .summary("List areas") .description("Lists all discovered areas in the system.") .operation_id("listAreas"); @@ -573,6 +692,7 @@ void RESTServer::setup_routes() { return discovery_handlers_->get_apps(req); }) .tag("Discovery") + .requires_role(UserRole::VIEWER) .summary("List apps") .description("Lists all discovered apps (ROS 2 nodes) in the system.") .operation_id("listApps"); @@ -583,6 +703,7 @@ void RESTServer::setup_routes() { return discovery_handlers_->get_components(req); }) .tag("Discovery") + .requires_role(UserRole::VIEWER) .summary("List components") .description("Lists all discovered components in the system.") .operation_id("listComponents"); @@ -593,6 +714,7 @@ void RESTServer::setup_routes() { return discovery_handlers_->get_functions(req); }) .tag("Discovery") + .requires_role(UserRole::VIEWER) .summary("List functions") .description("Lists all discovered functions in the system.") .operation_id("listFunctions"); @@ -643,6 +765,7 @@ void RESTServer::setup_routes() { return data_handlers_->get_data_item(req); }) .tag("Data") + .requires_role(UserRole::VIEWER) .summary(std::string("Get data item for ") + et.singular) .description(std::string("Returns the latest value from a ROS 2 topic for this ") + et.singular + ".") // DataHandlers::get_data_item answers 503 when topic sampling is not @@ -656,6 +779,7 @@ void RESTServer::setup_routes() { return data_handlers_->put_data_item(req); }) .tag("Data") + .requires_role(UserRole::OPERATOR) .summary(std::string("Write data item for ") + et.singular) .description(std::string("Publishes a value to a ROS 2 topic on this ") + et.singular + ".") .request_body("Data value to write", SB::ref("DataWriteRequest")) @@ -671,6 +795,7 @@ void RESTServer::setup_routes() { return data_handlers_->data_categories(req); }) .tag("Data") + .requires_role(UserRole::VIEWER) .summary(std::string("List data categories for ") + et.singular) .description(std::string("Lists available data categories for this ") + et.singular + ".") .only_status(501, "Data categories are not implemented for ROS 2") @@ -682,6 +807,7 @@ void RESTServer::setup_routes() { return data_handlers_->data_groups(req); }) .tag("Data") + .requires_role(UserRole::VIEWER) .summary(std::string("List data groups for ") + et.singular) .description(std::string("Lists available data groups for this ") + et.singular + ".") .only_status(501, "Data groups are not implemented for ROS 2") @@ -698,6 +824,7 @@ void RESTServer::setup_routes() { return data_handlers_->list_data(req); }) .tag("Data") + .requires_role(UserRole::VIEWER) .summary(std::string("List data items for ") + et.singular) .description(std::string("Lists all data items (ROS 2 topics) available on this ") + et.singular + ".") // DataHandlers::list_data -> fan_out_collection. @@ -721,6 +848,7 @@ void RESTServer::setup_routes() { return operation_handlers_->list_operations(req); }) .tag("Operations") + .requires_role(UserRole::VIEWER) .summary(std::string("List operations for ") + et.singular) .description(std::string("Lists all ROS 2 services and actions available on this ") + et.singular + ".") // OperationHandlers::list_operations -> fan_out_collection. @@ -732,6 +860,7 @@ void RESTServer::setup_routes() { return operation_handlers_->get_operation(req); }) .tag("Operations") + .requires_role(UserRole::VIEWER) .summary(std::string("Get operation details for ") + et.singular) .description(std::string("Returns operation details including request/response schema for this ") + et.singular + ".") @@ -749,6 +878,7 @@ void RESTServer::setup_routes() { return operation_handlers_->create_execution(req, std::move(body)); }}) .tag("Operations") + .requires_role(UserRole::OPERATOR) .summary(std::string("Start operation execution for ") + et.singular) .description("Starts a new execution. Returns 200 for synchronous, 202 for asynchronous operations.") // `parameters` is what the handler reads first for both branches (the @@ -767,6 +897,7 @@ void RESTServer::setup_routes() { return operation_handlers_->list_executions(req); }) .tag("Operations") + .requires_role(UserRole::VIEWER) .summary(std::string("List operation executions for ") + et.singular) .description(std::string("Lists all executions of an operation on this ") + et.singular + ".") .operation_id(std::string("list") + capitalize(et.singular) + "Executions"); @@ -776,6 +907,7 @@ void RESTServer::setup_routes() { return operation_handlers_->get_execution(req); }) .tag("Operations") + .requires_role(UserRole::VIEWER) .summary(std::string("Get execution status for ") + et.singular) .description("Returns the current status and result of a specific execution.") .operation_id(std::string("get") + capitalize(et.singular) + "Execution"); @@ -789,6 +921,7 @@ void RESTServer::setup_routes() { return operation_handlers_->update_execution(req, body); }}) .tag("Operations") + .requires_role(UserRole::OPERATOR) .summary(std::string("Update execution for ") + et.singular) .description("Sends a control command to a running execution.") .success_description("Accepted (asynchronous control)") @@ -801,6 +934,7 @@ void RESTServer::setup_routes() { return operation_handlers_->cancel_execution(req); }) .tag("Operations") + .requires_role(UserRole::OPERATOR) .summary(std::string("Cancel execution for ") + et.singular) .description("Cancels a running execution.") // OperationHandlers::cancel_execution -> validate_lock_access("operations"). @@ -825,6 +959,7 @@ void RESTServer::setup_routes() { return config_handlers_->list_configurations(req); }) .tag("Configuration") + .requires_role(UserRole::VIEWER) .summary(std::string("List configurations for ") + et.singular) .description(std::string("Lists all ROS 2 node parameters for this ") + et.singular + ".") // ConfigHandlers::list_configurations -> fan_out_collection. @@ -843,6 +978,7 @@ void RESTServer::setup_routes() { return config_handlers_->get_configuration(req); }) .tag("Configuration") + .requires_role(UserRole::VIEWER) .summary(std::string("Get specific configuration for ") + et.singular) .description(std::string("Returns a specific ROS 2 node parameter for this ") + et.singular + ".") // Parameter failures reach the wire through `classify_parameter_error`, @@ -861,6 +997,7 @@ void RESTServer::setup_routes() { return config_handlers_->set_configuration(req, std::move(body)); }) .tag("Configuration") + .requires_role(UserRole::CONFIGURATOR) .summary(std::string("Set configuration for ") + et.singular) .description(std::string("Sets a ROS 2 node parameter value for this ") + et.singular + ".") // `data` is the preferred key; `value` is the legacy alias the handler @@ -882,6 +1019,7 @@ void RESTServer::setup_routes() { return config_handlers_->delete_configuration(req); }) .tag("Configuration") + .requires_role(UserRole::CONFIGURATOR) .summary(std::string("Delete configuration for ") + et.singular) .description(std::string("Resets a configuration parameter to its default for this ") + et.singular + ".") // ConfigHandlers::delete_configuration -> validate_lock_access("configurations"). @@ -904,6 +1042,7 @@ void RESTServer::setup_routes() { return config_handlers_->delete_all_configurations(req); }}) .tag("Configuration") + .requires_role(UserRole::CONFIGURATOR) .summary(std::string("Delete all configurations for ") + et.singular) .description(std::string("Resets all configuration parameters for this ") + et.singular + ".") // ConfigHandlers::delete_all_configurations -> validate_lock_access("configurations"). @@ -927,6 +1066,7 @@ void RESTServer::setup_routes() { return fault_handlers_->list_faults(req); }) .tag("Faults") + .requires_role(UserRole::VIEWER) .summary(std::string("List faults for ") + et.singular) .description(std::string("Returns all active faults reported by this ") + et.singular + ".") // FaultHandlers::list_faults -> merge_peer_items. @@ -943,6 +1083,7 @@ void RESTServer::setup_routes() { return fault_handlers_->get_fault(req); }) .tag("Faults") + .requires_role(UserRole::VIEWER) .summary(std::string("Get specific fault for ") + et.singular) .description("Returns fault details including SOVD status, environment data, and rosbag snapshots.") // 503 when the fault store cannot be read - same branch as the list @@ -957,6 +1098,7 @@ void RESTServer::setup_routes() { return fault_handlers_->clear_fault(req); }}) .tag("Faults") + .requires_role(UserRole::OPERATOR) .summary(std::string("Clear fault for ") + et.singular) .description(std::string("Clears a specific fault for this ") + et.singular + ".") // FaultHandlers::clear_fault -> validate_lock_access("faults"). @@ -971,6 +1113,7 @@ void RESTServer::setup_routes() { return fault_handlers_->clear_all_faults(req); }) .tag("Faults") + .requires_role(UserRole::OPERATOR) .summary(std::string("Clear all faults for ") + et.singular) .description(std::string("Clears all faults for this ") + et.singular + ".") // FaultHandlers::clear_all_faults -> validate_lock_access("faults"). @@ -992,6 +1135,7 @@ void RESTServer::setup_routes() { return log_handlers_->get_logs(req); }) .tag("Logs") + .requires_role(UserRole::VIEWER) .summary(std::string("Query log entries for ") + et.singular) .description( std::string("Queries application log entries for this ") + et.singular + @@ -1018,6 +1162,7 @@ void RESTServer::setup_routes() { return log_handlers_->get_logs_configuration(req); }) .tag("Logs") + .requires_role(UserRole::VIEWER) .summary(std::string("Get log configuration for ") + et.singular) .description(std::string("Returns the log filter configuration for this ") + et.singular + ".") .errors({503}) // No LogManager attached - see the list route above. @@ -1029,6 +1174,7 @@ void RESTServer::setup_routes() { return log_handlers_->put_logs_configuration(req, std::move(body)); }) .tag("Logs") + .requires_role(UserRole::CONFIGURATOR) .summary(std::string("Update log configuration for ") + et.singular) .description(std::string("Updates the log severity filter and max entries for this ") + et.singular + ".") // LogHandlers::put_logs_configuration -> validate_lock_access("logs"). @@ -1052,6 +1198,7 @@ void RESTServer::setup_routes() { return bulkdata_handlers_->list_categories(req); }) .tag("Bulk Data") + .requires_role(UserRole::VIEWER) .summary(std::string("List bulk-data categories for ") + et.singular) .description(std::string("Lists bulk-data categories (e.g., rosbag snapshots) for this ") + et.singular + ".") .operation_id(std::string("list") + capitalize(et.singular) + "BulkDataCategories"); @@ -1062,6 +1209,7 @@ void RESTServer::setup_routes() { return bulkdata_handlers_->list_descriptors(req); }) .tag("Bulk Data") + .requires_role(UserRole::VIEWER) .summary(std::string("List bulk-data descriptors for ") + et.singular) .description(std::string("Lists downloadable files in a bulk-data category for this ") + et.singular + ".") .operation_id(std::string("list") + capitalize(et.singular) + "BulkDataDescriptors"); @@ -1073,6 +1221,7 @@ void RESTServer::setup_routes() { }, handlers::BulkDataHandlers::download_media_types()) .tag("Bulk Data") + .requires_role(UserRole::VIEWER) .summary(std::string("Download bulk-data file for ") + et.singular) .description("Downloads a bulk-data file (binary content).") .operation_id(std::string("download") + capitalize(et.singular) + "BulkData"); @@ -1087,6 +1236,7 @@ void RESTServer::setup_routes() { return bulkdata_handlers_->upload(req, body); }) .tag("Bulk Data") + .requires_role(UserRole::OPERATOR) .summary(std::string("Upload bulk-data for ") + et.singular) .description(std::string("Uploads a file to a bulk-data category for this ") + et.singular + ".") // Part names read off BulkDataHandlers::upload. `file` is the only @@ -1116,6 +1266,7 @@ void RESTServer::setup_routes() { return bulkdata_handlers_->remove(req); }) .tag("Bulk Data") + .requires_role(UserRole::OPERATOR) .summary(std::string("Delete bulk-data file for ") + et.singular) .description(std::string("Deletes a bulk-data file for this ") + et.singular + ".") // BulkDataHandlers::remove -> validate_lock_access("bulk-data"). @@ -1136,6 +1287,7 @@ void RESTServer::setup_routes() { return tl::unexpected(std::move(err)); }) .tag("Bulk Data") + .requires_role(UserRole::OPERATOR) .summary(std::string("Upload bulk-data for ") + et.singular + " (not supported)") .description("Bulk data upload is not supported for this entity type.") .response(405, "Method not allowed") @@ -1150,6 +1302,7 @@ void RESTServer::setup_routes() { return tl::unexpected(std::move(err)); }) .tag("Bulk Data") + .requires_role(UserRole::OPERATOR) .summary(std::string("Delete bulk-data file for ") + et.singular + " (not supported)") .description("Bulk data deletion is not supported for this entity type.") .response(405, "Method not allowed") @@ -1185,6 +1338,7 @@ void RESTServer::setup_routes() { return trigger_handlers_->sse_trigger_events(req); }) .tag("Triggers") + .requires_role(UserRole::VIEWER) .summary(std::string("SSE events stream for trigger on ") + et.singular) .description(std::string("Server-Sent Events stream for trigger notifications on this ") + et.singular + ". Each frame's `data:` field is a TriggerEventFrame. An idle stream sends " @@ -1206,6 +1360,7 @@ void RESTServer::setup_routes() { return trigger_handlers_->post_trigger(req, std::move(body)); }) .tag("Triggers") + .requires_role(UserRole::OPERATOR) .summary(std::string("Create trigger for ") + et.singular) .description(std::string("Creates a new event trigger for this ") + et.singular + ".") .body_example(nlohmann::json{ @@ -1229,6 +1384,7 @@ void RESTServer::setup_routes() { return trigger_handlers_->get_triggers(req); }) .tag("Triggers") + .requires_role(UserRole::VIEWER) .summary(std::string("List triggers for ") + et.singular) .description(std::string("Lists all triggers configured for this ") + et.singular + ".") .gated_on(triggers_available, triggers_unavailable) @@ -1239,6 +1395,7 @@ void RESTServer::setup_routes() { return trigger_handlers_->get_trigger(req); }) .tag("Triggers") + .requires_role(UserRole::VIEWER) .summary(std::string("Get trigger for ") + et.singular) .description(std::string("Returns details of a specific trigger on this ") + et.singular + ".") .gated_on(triggers_available, triggers_unavailable) @@ -1250,6 +1407,7 @@ void RESTServer::setup_routes() { return trigger_handlers_->put_trigger(req, body); }) .tag("Triggers") + .requires_role(UserRole::OPERATOR) .summary(std::string("Update trigger for ") + et.singular) .description(std::string("Updates a trigger configuration on this ") + et.singular + ".") .gated_on(triggers_available, triggers_unavailable) @@ -1260,6 +1418,7 @@ void RESTServer::setup_routes() { return trigger_handlers_->del_trigger(req); }) .tag("Triggers") + .requires_role(UserRole::OPERATOR) .summary(std::string("Delete trigger for ") + et.singular) .description(std::string("Deletes a trigger from this ") + et.singular + ".") .gated_on(triggers_available, triggers_unavailable) @@ -1284,6 +1443,7 @@ void RESTServer::setup_routes() { return cyclic_sub_handlers_->sse_subscription_events(req); }) .tag("Subscriptions") + .requires_role(UserRole::VIEWER) .summary(std::string("SSE events stream for cyclic subscription on ") + et.singular) .description(std::string("Server-Sent Events stream for subscription data on this ") + et.singular + ". Each frame's `data:` field is a SubscriptionEventFrame carrying either the sample " @@ -1303,6 +1463,7 @@ void RESTServer::setup_routes() { return cyclic_sub_handlers_->post_subscription(req, std::move(body)); }) .tag("Subscriptions") + .requires_role(UserRole::OPERATOR) .summary(std::string("Create cyclic subscription for ") + et.singular) .description(std::string("Creates a new cyclic data subscription for this ") + et.singular + ".") .success_description("Subscription created") @@ -1318,6 +1479,7 @@ void RESTServer::setup_routes() { return cyclic_sub_handlers_->get_subscriptions(req); }) .tag("Subscriptions") + .requires_role(UserRole::VIEWER) .summary(std::string("List cyclic subscriptions for ") + et.singular) .description(std::string("Lists all cyclic subscriptions for this ") + et.singular + ".") .operation_id(std::string("list") + capitalize(et.singular) + "Subscriptions"); @@ -1327,6 +1489,7 @@ void RESTServer::setup_routes() { return cyclic_sub_handlers_->get_subscription(req); }) .tag("Subscriptions") + .requires_role(UserRole::VIEWER) .summary(std::string("Get cyclic subscription for ") + et.singular) .description(std::string("Returns details of a specific subscription on this ") + et.singular + ".") .operation_id(std::string("get") + capitalize(et.singular) + "Subscription"); @@ -1338,6 +1501,7 @@ void RESTServer::setup_routes() { return cyclic_sub_handlers_->put_subscription(req, std::move(body)); }) .tag("Subscriptions") + .requires_role(UserRole::OPERATOR) .summary(std::string("Update cyclic subscription for ") + et.singular) .description(std::string("Updates a subscription configuration on this ") + et.singular + ".") .operation_id(std::string("update") + capitalize(et.singular) + "Subscription"); @@ -1347,6 +1511,7 @@ void RESTServer::setup_routes() { return cyclic_sub_handlers_->del_subscription(req); }) .tag("Subscriptions") + .requires_role(UserRole::OPERATOR) .summary(std::string("Delete cyclic subscription for ") + et.singular) .description(std::string("Deletes a cyclic subscription from this ") + et.singular + ".") .operation_id(std::string("delete") + capitalize(et.singular) + "Subscription"); @@ -1369,6 +1534,7 @@ void RESTServer::setup_routes() { return lock_handlers_->post_lock(req, std::move(body)); }) .tag("Locking") + .requires_role(UserRole::OPERATOR) .summary(std::string("Acquire lock on ") + et.singular) .description( std::string("Acquires an exclusive lock on this ") + et.singular + @@ -1394,6 +1560,7 @@ void RESTServer::setup_routes() { return lock_handlers_->get_locks(req); }) .tag("Locking") + .requires_role(UserRole::VIEWER) .summary(std::string("List locks on ") + et.singular) .description(std::string("Lists all active locks on this ") + et.singular + ".") .header_param("X-Client-Id", "When provided, the 'owned' field indicates whether this client owns the lock", @@ -1406,6 +1573,7 @@ void RESTServer::setup_routes() { return lock_handlers_->get_lock(req); }) .tag("Locking") + .requires_role(UserRole::VIEWER) .summary(std::string("Get lock details for ") + et.singular) .description(std::string("Returns details of a specific lock on this ") + et.singular + ".") .header_param("X-Client-Id", "When provided, the 'owned' field indicates whether this client owns the lock", @@ -1419,6 +1587,7 @@ void RESTServer::setup_routes() { return lock_handlers_->put_lock(req, body); }) .tag("Locking") + .requires_role(UserRole::OPERATOR) .summary(std::string("Extend lock on ") + et.singular) .description(std::string("Extends the expiration of a lock on this ") + et.singular + ".") .header_param("X-Client-Id", "Unique client identifier for lock ownership", true, client_id_schema) @@ -1436,6 +1605,7 @@ void RESTServer::setup_routes() { return lock_handlers_->del_lock(req); }) .tag("Locking") + .requires_role(UserRole::OPERATOR) .summary(std::string("Release lock on ") + et.singular) .description(std::string("Releases a lock on this ") + et.singular + ".") .header_param("X-Client-Id", "Unique client identifier for lock ownership", true, client_id_schema) @@ -1497,6 +1667,7 @@ void RESTServer::setup_routes() { return script_handlers_->upload_script(req, body); }) .tag("Scripts") + .requires_role(UserRole::CONFIGURATOR) .summary(std::string("Upload diagnostic script for ") + et.singular) .description(std::string("Uploads a diagnostic script for this ") + et.singular + ".") // Part names read off ScriptHandlers::upload_script. `file` is the @@ -1522,6 +1693,7 @@ void RESTServer::setup_routes() { return script_handlers_->list_scripts(req); }) .tag("Scripts") + .requires_role(UserRole::VIEWER) .summary(std::string("List scripts for ") + et.singular) .description(std::string("Lists all diagnostic scripts for this ") + et.singular + ".") // DefaultScriptProvider::list_scripts returns no backend error @@ -1533,6 +1705,7 @@ void RESTServer::setup_routes() { return script_handlers_->get_script(req); }) .tag("Scripts") + .requires_role(UserRole::VIEWER) .summary(std::string("Get script metadata for ") + et.singular) .description(std::string("Returns metadata of a specific script for this ") + et.singular + ".") // DefaultScriptProvider::get_script -> NotFound / Internal only @@ -1544,6 +1717,7 @@ void RESTServer::setup_routes() { return script_handlers_->delete_script(req); }) .tag("Scripts") + .requires_role(UserRole::CONFIGURATOR) .summary(std::string("Delete script for ") + et.singular) .description(std::string("Deletes a diagnostic script from this ") + et.singular + ".") // DefaultScriptProvider::delete_script -> ManagedScript / AlreadyRunning @@ -1557,6 +1731,7 @@ void RESTServer::setup_routes() { return script_handlers_->start_execution(req); }) .tag("Scripts") + .requires_role(UserRole::OPERATOR) .summary(std::string("Start script execution for ") + et.singular) .description(std::string("Starts execution of a diagnostic script on this ") + et.singular + ".") // The handler parses this body by hand (framework escape hatch) so it @@ -1580,6 +1755,7 @@ void RESTServer::setup_routes() { return script_handlers_->get_execution(req); }) .tag("Scripts") + .requires_role(UserRole::VIEWER) .summary(std::string("Get execution status for ") + et.singular) .description("Returns the current status of a script execution.") // DefaultScriptProvider::get_script -> NotFound / Internal only @@ -1593,6 +1769,7 @@ void RESTServer::setup_routes() { return script_handlers_->control_execution(req, body); }) .tag("Scripts") + .requires_role(UserRole::OPERATOR) .summary(std::string("Terminate script execution for ") + et.singular) .description("Sends a control command (e.g., terminate) to a running script execution.") // DefaultScriptProvider::control_execution -> NotRunning @@ -1604,6 +1781,7 @@ void RESTServer::setup_routes() { return script_handlers_->delete_execution(req); }) .tag("Scripts") + .requires_role(UserRole::OPERATOR) .summary(std::string("Remove completed execution for ") + et.singular) .description("Removes a completed script execution record.") // DefaultScriptProvider::delete_execution -> AlreadyRunning @@ -1619,6 +1797,7 @@ void RESTServer::setup_routes() { return discovery_handlers_->get_area_components(req); }) .tag("Discovery") + .requires_role(UserRole::VIEWER) .summary("List components in area") .description("Lists components belonging to this area.") .operation_id("listAreaComponents"); @@ -1629,6 +1808,7 @@ void RESTServer::setup_routes() { return discovery_handlers_->get_subareas(req); }) .tag("Discovery") + .requires_role(UserRole::VIEWER) .summary("List subareas") .description("Lists subareas within this area.") .operation_id("listSubareas"); @@ -1639,6 +1819,7 @@ void RESTServer::setup_routes() { return discovery_handlers_->get_area_contains(req); }) .tag("Discovery") + .requires_role(UserRole::VIEWER) .summary("List entities contained in area") // Components only, not "all entities": the handler walks the area and // its descendant subareas collecting `get_components_for_area` and @@ -1658,6 +1839,7 @@ void RESTServer::setup_routes() { return discovery_handlers_->get_subcomponents(req); }) .tag("Discovery") + .requires_role(UserRole::VIEWER) .summary("List subcomponents") .description("Lists subcomponents of this component.") .operation_id("listSubcomponents"); @@ -1668,6 +1850,7 @@ void RESTServer::setup_routes() { return discovery_handlers_->get_component_hosts(req); }) .tag("Discovery") + .requires_role(UserRole::VIEWER) .summary("List component hosts") .description("Lists apps hosted by this component.") .operation_id("listComponentHosts"); @@ -1678,6 +1861,7 @@ void RESTServer::setup_routes() { return discovery_handlers_->get_component_depends_on(req); }) .tag("Discovery") + .requires_role(UserRole::VIEWER) .summary("List component dependencies") .description("Lists components this component depends on.") .operation_id("listComponentDependencies"); @@ -1690,6 +1874,7 @@ void RESTServer::setup_routes() { return discovery_handlers_->get_app_is_located_on(req); }) .tag("Discovery") + .requires_role(UserRole::VIEWER) .summary("Get app host component") .description("Returns the component hosting this app as a single-element collection.") .operation_id("getAppHost"); @@ -1700,6 +1885,7 @@ void RESTServer::setup_routes() { return discovery_handlers_->get_app_belongs_to(req); }) .tag("Discovery") + .requires_role(UserRole::VIEWER) .summary("Get app parent area") .description( "Returns the area this app belongs to via its parent component, as a 0-or-1 element " @@ -1712,6 +1898,7 @@ void RESTServer::setup_routes() { return discovery_handlers_->get_app_depends_on(req); }) .tag("Discovery") + .requires_role(UserRole::VIEWER) .summary("List app dependencies") .description("Lists apps this app depends on.") .operation_id("listAppDependencies"); @@ -1724,6 +1911,7 @@ void RESTServer::setup_routes() { return discovery_handlers_->get_function_hosts(req); }) .tag("Discovery") + .requires_role(UserRole::VIEWER) .summary("List function hosts") // The handler resolves the function's host ids through // `cache.get_app(...)` and returns AppListItem with `/api/v1/apps/` @@ -1743,6 +1931,7 @@ void RESTServer::setup_routes() { return discovery_handlers_->get_area(req); }) .tag("Discovery") + .requires_role(UserRole::VIEWER) .summary(std::string("Get ") + et.singular + " details") .description(std::string("Returns ") + et.singular + " details with capabilities and resource collection URIs.") @@ -1753,6 +1942,7 @@ void RESTServer::setup_routes() { return discovery_handlers_->get_component(req); }) .tag("Discovery") + .requires_role(UserRole::VIEWER) .summary(std::string("Get ") + et.singular + " details") .description(std::string("Returns ") + et.singular + " details with capabilities and resource collection URIs.") @@ -1763,6 +1953,7 @@ void RESTServer::setup_routes() { return discovery_handlers_->get_app(req); }) .tag("Discovery") + .requires_role(UserRole::VIEWER) .summary(std::string("Get ") + et.singular + " details") .description(std::string("Returns ") + et.singular + " details with capabilities and resource collection URIs.") @@ -1773,6 +1964,7 @@ void RESTServer::setup_routes() { return discovery_handlers_->get_function(req); }) .tag("Discovery") + .requires_role(UserRole::VIEWER) .summary(std::string("Get ") + et.singular + " details") .description(std::string("Returns ") + et.singular + " details with capabilities and resource collection URIs.") @@ -1792,6 +1984,7 @@ void RESTServer::setup_routes() { return bulkdata_handlers_->list_categories(req); }) .tag("Bulk Data") + .requires_role(UserRole::VIEWER) .summary("List bulk-data categories for subarea") .description("Lists bulk-data categories for a subarea.") .operation_id("listSubareaBulkDataCategories"); @@ -1802,6 +1995,7 @@ void RESTServer::setup_routes() { return bulkdata_handlers_->list_descriptors(req); }) .tag("Bulk Data") + .requires_role(UserRole::VIEWER) .summary("List bulk-data descriptors for subarea") .description("Lists bulk-data descriptors for a subarea.") .operation_id("listSubareaBulkDataDescriptors"); @@ -1813,6 +2007,7 @@ void RESTServer::setup_routes() { }, handlers::BulkDataHandlers::download_media_types()) .tag("Bulk Data") + .requires_role(UserRole::VIEWER) .summary("Download bulk-data file for subarea") .description("Downloads a bulk-data file for a subarea.") .operation_id("downloadSubareaBulkData"); @@ -1823,6 +2018,7 @@ void RESTServer::setup_routes() { return bulkdata_handlers_->list_categories(req); }) .tag("Bulk Data") + .requires_role(UserRole::VIEWER) .summary("List bulk-data categories for subcomponent") .description("Lists bulk-data categories for a subcomponent.") .operation_id("listSubcomponentBulkDataCategories"); @@ -1833,6 +2029,7 @@ void RESTServer::setup_routes() { return bulkdata_handlers_->list_descriptors(req); }) .tag("Bulk Data") + .requires_role(UserRole::VIEWER) .summary("List bulk-data descriptors for subcomponent") .description("Lists bulk-data descriptors for a subcomponent.") .operation_id("listSubcomponentBulkDataDescriptors"); @@ -1844,6 +2041,7 @@ void RESTServer::setup_routes() { }, handlers::BulkDataHandlers::download_media_types()) .tag("Bulk Data") + .requires_role(UserRole::VIEWER) .summary("Download bulk-data file for subcomponent") .description("Downloads a bulk-data file for a subcomponent.") .operation_id("downloadSubcomponentBulkData"); @@ -1866,6 +2064,7 @@ void RESTServer::setup_routes() { return sse_fault_handler_->sse_stream(req); }) .tag("Faults") + .requires_role(UserRole::VIEWER) .summary("Stream fault events (SSE)") .description( "Server-Sent Events stream for real-time fault notifications. Each frame's `data:` field is a " @@ -1889,6 +2088,7 @@ void RESTServer::setup_routes() { return fault_handlers_->list_all_faults(req); }) .tag("Faults") + .requires_role(UserRole::VIEWER) .summary("List all faults globally") .description("Retrieve all faults across the system.") // The handler's return type is the opaque `FaultListResult` so the fault @@ -1912,6 +2112,7 @@ void RESTServer::setup_routes() { return fault_handlers_->clear_all_faults_global(req); }) .tag("Faults") + .requires_role(UserRole::OPERATOR) .summary("Clear all faults globally") // "Across the entire system" was wrong twice over: the request never // leaves this gateway, and an omitted `status` clears two of the four @@ -1981,6 +2182,7 @@ void RESTServer::setup_routes() { return update_handlers_->get_updates(req); }) .tag("Updates") + .requires_role(UserRole::VIEWER) .summary("List software updates") .description("Lists all registered software updates.") .gated_on(updates_available, kUpdate501) @@ -1994,6 +2196,7 @@ void RESTServer::setup_routes() { return update_handlers_->post_update(req, std::move(body)); }) .tag("Updates") + .requires_role(UserRole::CONFIGURATOR) .summary("Register a software update") .description("Registers a new software update descriptor.") .success_description("Update registered") @@ -2005,6 +2208,7 @@ void RESTServer::setup_routes() { return update_handlers_->get_status(req); }) .tag("Updates") + .requires_role(UserRole::VIEWER) .summary("Get update status") .description("Returns the current status and progress of an update.") .gated_on(updates_available, kUpdate501) @@ -2017,6 +2221,7 @@ void RESTServer::setup_routes() { return update_handlers_->put_prepare(req); }) .tag("Updates") + .requires_role(UserRole::CONFIGURATOR) .summary("Prepare update for execution") .description("Prepares an update for execution (downloads, validates).") .success_description("Update preparation started") @@ -2035,6 +2240,7 @@ void RESTServer::setup_routes() { return update_handlers_->put_execute(req); }) .tag("Updates") + .requires_role(UserRole::CONFIGURATOR) .summary("Execute update") .description("Starts executing a prepared update.") .success_description("Update execution started") @@ -2053,6 +2259,7 @@ void RESTServer::setup_routes() { return update_handlers_->put_automated(req); }) .tag("Updates") + .requires_role(UserRole::CONFIGURATOR) .summary("Run automated update") .description("Runs a fully automated update (prepare + execute).") .success_description("Automated update started") @@ -2069,6 +2276,7 @@ void RESTServer::setup_routes() { return update_handlers_->get_update(req); }) .tag("Updates") + .requires_role(UserRole::VIEWER) .summary("Get update details") .description("Returns details of a specific update.") .gated_on(updates_available, kUpdate501) @@ -2079,6 +2287,7 @@ void RESTServer::setup_routes() { return update_handlers_->del_update(req); }) .tag("Updates") + .requires_role(UserRole::CONFIGURATOR) .summary("Delete update") .description("Removes an update registration.") .gated_on(updates_available, kUpdate501) @@ -2102,6 +2311,7 @@ void RESTServer::setup_routes() { return auth_handlers_->post_authorize(req); }) .tag("Authentication") + .public_route() .summary("Authorize client") .description("Authenticate and obtain authorization tokens.") .request_body("Client credentials", SB::ref("AuthCredentials")) @@ -2121,6 +2331,7 @@ void RESTServer::setup_routes() { return auth_handlers_->post_token(req); }) .tag("Authentication") + .public_route() .summary("Obtain access token") .description("Exchange credentials or refresh token for a JWT access token.") .request_body("Token request credentials", SB::ref("AuthCredentials")) @@ -2136,6 +2347,7 @@ void RESTServer::setup_routes() { return auth_handlers_->post_revoke(req); }) .tag("Authentication") + .public_route() .summary("Revoke token") .description("Revoke an access or refresh token.") // No `.accepts(...)` and no `.errors({401})`, unlike the two above: @@ -2156,6 +2368,14 @@ void RESTServer::setup_routes() { for (const auto & action : {"start", "restart", "force-restart", "shutdown", "force-shutdown"}) { std::string action_str = action; + // The one registration in this file whose role is not fixed at the call + // site: `shutdown` and `force-shutdown` tear the entity down and stay + // behind CONFIGURATOR, while start/restart/force-restart bring it back + // and are OPERATOR's. Written as a condition on the loop variable rather + // than as two registrations, because everything else about the five + // routes is identical and splitting them would invite the copies to + // drift. + const bool destructive_transition = action_str == "shutdown" || action_str == "force-shutdown"; // Capitalise action for operation ID: "force-restart" -> "ForceRestart" std::string action_cap; bool cap_next = true; @@ -2174,6 +2394,7 @@ void RESTServer::setup_routes() { return lifecycle_handlers_->handle_transition(req, action_str); }) .tag("Lifecycle") + .requires_role(destructive_transition ? UserRole::CONFIGURATOR : UserRole::OPERATOR) .summary(std::string("Request lifecycle transition '") + action + "'") .description(std::string("Asks the entity's LifecycleProvider to perform the '") + action + "' transition. The 202 says the request was accepted, not that the transition finished: it " @@ -2201,6 +2422,7 @@ void RESTServer::setup_routes() { return lifecycle_handlers_->handle_get_status(req); }) .tag("Lifecycle") + .requires_role(UserRole::VIEWER) .summary(std::string("Get ") + et_lc.second + " lifecycle status") .description( "Reports whether the entity is `ready` or `notReady`, and which lifecycle transitions can be " @@ -2220,6 +2442,26 @@ void RESTServer::setup_routes() { // Register all routes with cpp-httplib route_registry_->register_all(*srv, API_BASE_PATH); + // The RBAC table, derived from the registrations just made rather than + // restated in a second file that has to be kept in step with them. Two + // sources, merged, and the split is the honest one: + // + // * the registry's derivation covers every route mounted above, each entry + // carrying the role its `requires_role(...)` declared, expanded to that + // role and every stronger one; + // * `residual_route_permissions()` covers what the registry never sees - + // the plugin routes `PluginManager::register_routes` mounts below, the + // Swagger UI pages, and in test builds the status recorder. + // + // Done here, not in the AuthManager constructor: the manager is built before + // the routes exist. Done before `start()`, which is the only thread-safety + // this needs - every request thread that reads the table is created by the + // listen call that follows. + if (auth_manager_) { + auth_manager_->add_route_permissions(route_registry_->route_permissions(API_BASE_PATH)); + auth_manager_->add_route_permissions(AuthConfig::residual_route_permissions()); + } + report_route_metadata_issues(); } diff --git a/src/ros2_medkit_gateway/src/openapi/capability_generator.cpp b/src/ros2_medkit_gateway/src/openapi/capability_generator.cpp index b3fbd56da..e5ce199d5 100644 --- a/src/ros2_medkit_gateway/src/openapi/capability_generator.cpp +++ b/src/ros2_medkit_gateway/src/openapi/capability_generator.cpp @@ -14,15 +14,17 @@ #include "capability_generator.hpp" +#include #include +#include #include +#include #include #include "openapi_spec_builder.hpp" #include "path_builder.hpp" #include "ros2_medkit_gateway/core/http/http_utils.hpp" -#include "ros2_medkit_gateway/core/models/entity_capabilities.hpp" #include "ros2_medkit_gateway/core/models/entity_types.hpp" #include "ros2_medkit_gateway/core/openapi/document_checks.hpp" #include "ros2_medkit_gateway/core/plugins/plugin_manager.hpp" @@ -33,14 +35,64 @@ namespace ros2_medkit_gateway { namespace openapi { +namespace { + +/// The one bearer-token scheme any document that mentions security refers to. +/// The description is part of the definition because the name alone overstates +/// what a given gateway does: `auth.enabled` decides whether the token is +/// *checked*, and a document served by a gateway with it off would otherwise +/// read as if it were. +nlohmann::json bearer_scheme() { + return nlohmann::json{{"type", "http"}, + {"scheme", "bearer"}, + {"bearerFormat", "JWT"}, + {"description", + "JWT bearer token. Where an operation carries a `security` requirement, the scope on that " + "requirement is the role the gateway's permission table grants for its path, and an empty " + "requirement (`security: []`) marks an operation reachable with no token at all. Whether " + "any of it is enforced is a deployment setting: with `auth.enabled` off the gateway serves " + "every operation unauthenticated, which is why this document carries no top-level " + "`security` requirement in that configuration. With it on, `auth.require_auth_for` decides " + "how much is checked - under `write` a GET is served without a token even though its " + "operation names the role the table would grant."}}; +} + +/// Remove every per-operation `security` requirement from an assembled +/// document, leaving the scheme definitions and the document-level +/// requirement alone. Walks whatever `paths` contains, so it does not care +/// which producer wrote an operation. +void strip_per_operation_security(nlohmann::json & document) { + auto paths = document.find("paths"); + if (paths == document.end() || !paths->is_object()) { + return; + } + for (auto & path_item : *paths) { + if (!path_item.is_object()) { + continue; + } + for (auto & operation : path_item) { + if (operation.is_object()) { + operation.erase("security"); + } + } + } +} + +} // namespace + CapabilityGenerator::CapabilityGenerator(handlers::HandlerContext & ctx, GatewayNode & node, PluginManager * plugin_mgr, - const RouteRegistry * route_registry) - : ctx_(ctx), node_(node), plugin_mgr_(plugin_mgr), route_registry_(route_registry), schema_builder_() { + const RouteRegistry * route_registry, DocsCacheBounds bounds) + : ctx_(ctx) + , node_(node) + , plugin_mgr_(plugin_mgr) + , route_registry_(route_registry) + , schema_builder_() + , bounds_(bounds) { } // TODO(#272): Fix TOCTOU race - use compare-and-swap when storing cached specs // TODO(#273): Use cache.snapshot() for consistent reads across multiple queries -std::optional CapabilityGenerator::generate(const std::string & base_path) const { +std::optional CapabilityGenerator::generate_serialized(const std::string & base_path) const { auto cache_key = get_cache_key(base_path); auto cached = lookup_cache(cache_key); if (cached.has_value()) { @@ -48,13 +100,60 @@ std::optional CapabilityGenerator::generate(const std::string & } auto result = generate_impl(base_path); - if (result.has_value()) { - store_cache(cache_key, *result); + if (!result.has_value()) { + return std::nullopt; + } + + // `dump(2)` is exactly what `http::detail::write_json_body` applies to a + // document, so serializing here rather than at the writer leaves the bytes + // on the wire unchanged - and lets the cache hold them instead of a DOM. + auto document = result->dump(2); + store_cache(cache_key, document); + return document; +} + +std::optional CapabilityGenerator::generate(const std::string & base_path) const { + auto document = generate_serialized(base_path); + if (!document.has_value()) { + return std::nullopt; } - return result; + + // Non-throwing parse: handlers in this gateway report failure by value + // rather than by exception. The input is text this class just produced with + // `dump(2)`, so a parse failure would mean nlohmann cannot read back its own + // output; nullopt is then the only honest answer available here. + auto parsed = nlohmann::json::parse(*document, /*cb=*/nullptr, /*allow_exceptions=*/false); + if (parsed.is_discarded()) { + return std::nullopt; + } + return parsed; } std::optional CapabilityGenerator::generate_impl(const std::string & base_path) const { + auto document = build_document(base_path); + + // Applied once, here, over the finished document rather than inside any one + // producer - and that placement is the point. + // + // The rule is a property of the *gateway*, not of where an operation came + // from: this document is served from `/docs` by a running gateway and + // describes it, and `AuthManager::requires_authentication` returns false + // outright when `!config_.enabled` (`auth_manager.cpp:316-319`), so with + // authentication off every caller is admitted and no operation may publish a + // role. Two producers emit one - `RouteEntry::requires_role` through + // `RouteRegistry::to_openapi_paths()`, and a plugin's + // `OperationDesc::requires_role` through the fold - and both reach every + // `/docs` sub-document as well, because those are a projection + // of the same two. Putting the rule in either producer would have meant a + // second copy in the other. Here it sits after all of them, so a new one + // inherits it. + if (document.has_value() && !ctx_.auth_config().enabled) { + strip_per_operation_security(*document); + } + return document; +} + +std::optional CapabilityGenerator::build_document(const std::string & base_path) const { auto resolved = PathResolver::resolve(base_path); switch (resolved.category) { @@ -100,6 +199,62 @@ std::optional CapabilityGenerator::generate_impl(const std::stri // ----------------------------------------------------------------------------- nlohmann::json CapabilityGenerator::generate_root() const { + std::vector tags{ + {"Server", "Gateway health, metadata, and version info"}, + {"Discovery", "Entity discovery and hierarchy navigation"}, + {"Data", "Read and write ROS 2 topic data"}, + {"Operations", "Execute ROS 2 service and action operations"}, + {"Configuration", "Read and write ROS 2 node parameters"}, + {"Faults", "Fault management and diagnostics"}, + {"Logs", "Application log access and configuration"}, + {"Bulk Data", "Large file downloads (rosbags, snapshots)"}, + {"Subscriptions", "Cyclic data subscriptions and event streaming"}, + {"Triggers", "Event-driven condition monitoring and notifications"}, + {"FaultTriggers", "Threshold rules on discovered data points that raise and auto-clear faults"}, + {"Locking", "Entity lock management for exclusive access"}, + {"Scripts", "Diagnostic script upload, execution, and management"}, + {"Updates", "Software update management"}, + {"Lifecycle", "Entity status and lifecycle control (start, restart, shutdown)"}, + {"Authentication", "JWT-based authentication"}, + }; + + const auto registry_paths = route_registry_ ? route_registry_->to_openapi_paths() : nlohmann::json::object(); + const auto extension_paths = plugin_paths(); + + // A plugin picks its own tag, so the tag list cannot be a literal and stay + // complete - `test_health` and `test_openapi_contract` both fail on a tag an + // operation uses without the document declaring it. Collected from the + // operations themselves so the invariant holds for whatever a plugin + // chooses, rather than for the one tag the in-tree plugin happens to use. + for (const auto & [path, item] : extension_paths.items()) { + for (const auto & [method, operation] : item.items()) { + if (!operation.is_object()) { + continue; + } + // Walked rather than looked up with `find()`: GCC inlines the iterator + // dereference into a -Wnull-dereference false positive, the same one + // `route_registry.cpp` documents around its parameter scan, and the + // build treats it as an error. + for (const auto & [op_key, op_value] : operation.items()) { + if (op_key != "tags" || !op_value.is_array()) { + continue; + } + for (const auto & tag : op_value) { + if (!tag.is_string()) { + continue; + } + const auto & name = tag.get_ref(); + const bool already_declared = std::any_of(tags.begin(), tags.end(), [&name](const TagInfo & declared) { + return declared.name == name; + }); + if (!already_declared) { + tags.push_back({name, "Vendor extension resources served by a gateway plugin"}); + } + } + } + } + } + OpenApiSpecBuilder builder; builder.info("ROS 2 Medkit Gateway", kGatewayVersion) .description( @@ -108,35 +263,37 @@ nlohmann::json CapabilityGenerator::generate_root() const { .contact("selfpatch.ai", "https://selfpatch.ai") .sovd_version(kSovdVersion) .server(build_server_url(), "Gateway server") - .tags({ - {"Server", "Gateway health, metadata, and version info"}, - {"Discovery", "Entity discovery and hierarchy navigation"}, - {"Data", "Read and write ROS 2 topic data"}, - {"Operations", "Execute ROS 2 service and action operations"}, - {"Configuration", "Read and write ROS 2 node parameters"}, - {"Faults", "Fault management and diagnostics"}, - {"Logs", "Application log access and configuration"}, - {"Bulk Data", "Large file downloads (rosbags, snapshots)"}, - {"Subscriptions", "Cyclic data subscriptions and event streaming"}, - {"Triggers", "Event-driven condition monitoring and notifications"}, - {"FaultTriggers", "Threshold rules on discovered data points that raise and auto-clear faults"}, - {"Locking", "Entity lock management for exclusive access"}, - {"Scripts", "Diagnostic script upload, execution, and management"}, - {"Updates", "Software update management"}, - {"Lifecycle", "Entity status and lifecycle control (start, restart, shutdown)"}, - {"Authentication", "JWT-based authentication"}, - }); - - // Use route registry as single source of truth for paths when available - if (route_registry_) { - builder.add_paths(route_registry_->to_openapi_paths()); - } - - const auto & auth_config = ctx_.auth_config(); - if (auth_config.enabled) { - builder.security_scheme("bearerAuth", {{"type", "http"}, {"scheme", "bearer"}, {"bearerFormat", "JWT"}}); + .tags(tags) + .add_paths(registry_paths); + + // Plugin-served routes are mounted on the same HTTP server as the + // registry's, so a client that cannot see them here has no other way to + // learn they exist. Merged after the registry's paths and never over them: + // a plugin pattern that shadows a gateway route is a routing defect, and + // silently replacing the gateway's description of that path would hide it. + for (const auto & [path, item] : extension_paths.items()) { + if (registry_paths.contains(path)) { + RCLCPP_WARN(handlers::HandlerContext::logger(), + "Plugin describes path '%s', which the gateway already documents; keeping the gateway's " + "description. The plugin's route is still mounted - check it is not shadowing a gateway route.", + path.c_str()); + continue; + } + builder.add_paths(nlohmann::json{{path, item}}); } + // Registered whether or not authentication is on. With it on, this is the + // scheme the per-operation requirements name - an operation cannot reference + // a scheme the document does not define. With it off there are no such + // requirements at all (`generate_impl` strips them from the assembled + // document), and the definition is kept anyway because a definition nothing + // references asserts nothing about this gateway - it only tells a reader + // what `bearerAuth` would mean. + // + // The *document-level* requirement - "every request needs a token" - is the + // part that would be untrue with `auth.enabled` off, so that stays gated. + builder.security_scheme("bearerAuth", bearer_scheme(), ctx_.auth_config().enabled); + // Register named schemas in components/schemas for $ref usage. // Generated clients use these as named types (e.g., FaultDetail, Lock, Trigger). nlohmann::json named_schemas; @@ -173,153 +330,186 @@ nlohmann::json CapabilityGenerator::generate_root() const { } // ----------------------------------------------------------------------------- -// Entity collection spec (e.g., /areas, /components) +// Sub-documents - a projection of the document the gateway serves +// +// Every `/docs` document below is a slice of `served_paths()`, +// with the ids the caller named substituted into the path templates. Nothing +// here describes a route a second time: what a sub-document says about an +// operation is what the root document says about it, minus the templating the +// caller has already resolved. +// +// The exception is a data item and an operation item, whose payload schema +// comes from the ROS type in the entity cache - a fact no registration holds. +// `add_cache_derived_items` is the whole of that exception. // ----------------------------------------------------------------------------- -nlohmann::json CapabilityGenerator::generate_entity_collection(const ResolvedPath & resolved) const { - PathBuilder path_builder(schema_builder_, ctx_.auth_config().enabled); - nlohmann::json paths; - - // Build parent path prefix from parent chain using concrete entity IDs - // (this spec is scoped to a specific entity, not a generic template) - std::string prefix; - for (const auto & parent : resolved.parent_chain) { - prefix += "/" + parent.entity_type + "/" + parent.entity_id; +nlohmann::json CapabilityGenerator::served_paths() const { + // Bound to named locals. Both calls return by value, and iterating `.items()` + // on the temporary walks a destroyed object. + const nlohmann::json registry_paths = + route_registry_ ? route_registry_->to_openapi_paths() : nlohmann::json::object(); + const nlohmann::json extension_paths = plugin_paths(); + + nlohmann::json served = registry_paths; + for (const auto & [path, item] : extension_paths.items()) { + if (served.contains(path)) { + continue; // `generate_root` warns about the shadowing; once is enough. + } + served[path] = item; } + return served; +} - // Collection listing path - std::string collection_path = prefix + "/" + resolved.entity_type; - paths[collection_path] = path_builder.build_entity_collection(resolved.entity_type); - - // Detail path for individual entity - // Derive singular for path parameter - std::string singular = resolved.entity_type; - if (!singular.empty() && singular.back() == 's') { - singular.pop_back(); +nlohmann::json CapabilityGenerator::project(const nlohmann::json & served, const std::string & template_prefix, + const std::vector & bindings) { + const nlohmann::json subtree = paths_under(served, template_prefix); + + nlohmann::json bound = nlohmann::json::object(); + for (const auto & [key, item] : subtree.items()) { + std::string path = key; + nlohmann::json path_item = item; + for (const auto & [parameter, value] : bindings) { + const std::string placeholder = "{" + parameter + "}"; + const auto pos = path.find(placeholder); + if (pos == std::string::npos) { + continue; + } + path.replace(pos, placeholder.size(), value); + strip_entity_path_parameter(path_item, parameter); + } + bound[path] = std::move(path_item); } - std::string detail_path = collection_path + "/{" + singular + "_id}"; - paths[detail_path] = path_builder.build_entity_detail(resolved.entity_type); + return bound; +} +nlohmann::json CapabilityGenerator::build_subtree_document(const std::string & title, + const nlohmann::json & paths) const { OpenApiSpecBuilder builder; - builder.info("ROS 2 Medkit Gateway - " + resolved.entity_type, kGatewayVersion) + builder.info("ROS 2 Medkit Gateway - " + title, kGatewayVersion) .sovd_version(kSovdVersion) .server(build_server_url(), "Gateway server") + // The projected operations carry whatever `security` their registration + // declared, so the scheme they name has to be defined here as well - an + // operation cannot reference a scheme its document does not have. With + // `auth.enabled` off `generate_impl` removes those requirements from the + // finished document, and the definition stays for the same reason it does + // in the root document: it asserts nothing about this gateway. + .security_scheme("bearerAuth", bearer_scheme(), ctx_.auth_config().enabled) .add_paths(paths); + // Only the schemas this slice reaches. The root document carries all of + // `dto::AllDtos`; an entity page that did the same would ship every DTO the + // gateway knows on every request, and one that carried none - which is what + // these documents used to do - publishes `$ref`s no client can resolve. + nlohmann::json named_schemas = nlohmann::json::object(); + for (const auto & [name, schema] : referenced_schemas(paths, SchemaBuilder::component_schemas())) { + named_schemas[name] = schema; + } + builder.add_schemas(named_schemas); + return builder.build(); } -// ----------------------------------------------------------------------------- -// Specific entity spec (e.g., /apps/my_app) -// ----------------------------------------------------------------------------- - -nlohmann::json CapabilityGenerator::generate_specific_entity(const ResolvedPath & resolved) const { - PathBuilder path_builder(schema_builder_, ctx_.auth_config().enabled); - nlohmann::json paths; +std::pair> +CapabilityGenerator::entity_template(const ResolvedPath & resolved, bool include_self) { + std::string prefix; + std::vector bindings; + + auto append = [&prefix, &bindings](const std::string & entity_type, const std::string & entity_id) { + // "areas" -> "area_id", "subcomponents" -> "subcomponent_id": the same + // singular-plus-`_id` shape every entity route in `rest_server.cpp` is + // registered under. + std::string parameter = entity_type; + if (!parameter.empty() && parameter.back() == 's') { + parameter.pop_back(); + } + parameter += "_id"; + prefix += "/" + entity_type + "/{" + parameter + "}"; + bindings.push_back({parameter, entity_id}); + }; - // Build entity path prefix - std::string entity_path; for (const auto & parent : resolved.parent_chain) { - entity_path += "/" + parent.entity_type + "/" + parent.entity_id; + append(parent.entity_type, parent.entity_id); + } + if (include_self) { + append(resolved.entity_type, resolved.entity_id); + } else { + prefix += "/" + resolved.entity_type; } - entity_path += "/" + resolved.entity_type + "/" + resolved.entity_id; + return {prefix, bindings}; +} - // Entity detail endpoint - paths[entity_path] = path_builder.build_entity_detail(resolved.entity_type, false); +std::string CapabilityGenerator::concrete_path(const std::string & template_prefix, + const std::vector & bindings) { + std::string path = template_prefix; + for (const auto & [parameter, value] : bindings) { + const std::string placeholder = "{" + parameter + "}"; + const auto pos = path.find(placeholder); + if (pos != std::string::npos) { + path.replace(pos, placeholder.size(), value); + } + } + return path; +} - // Add resource collection paths based on entity capabilities - auto sovd_type = entity_type_from_keyword(resolved.entity_type); - add_resource_collection_paths(paths, entity_path, resolved.entity_id, sovd_type); +std::optional CapabilityGenerator::single_parameter_segment_under(const nlohmann::json & served, + const std::string & prefix) { + if (!served.is_object()) { + return std::nullopt; + } + std::optional found; + for (auto entry = served.begin(); entry != served.end(); ++entry) { + const std::string & key = entry.key(); + if (key.size() <= prefix.size() + 1 || key.compare(0, prefix.size(), prefix) != 0 || key[prefix.size()] != '/') { + continue; + } + const std::string segment = key.substr(prefix.size() + 1); + if (segment.find('/') != std::string::npos || segment.front() != '{' || segment.back() != '}') { + continue; + } + const std::string parameter = segment.substr(1, segment.size() - 2); + if (found.has_value() && *found != parameter) { + return std::nullopt; // ambiguous - see the header comment + } + found = parameter; + } + return found; +} - OpenApiSpecBuilder builder; - builder.info("ROS 2 Medkit Gateway - " + resolved.entity_id, kGatewayVersion) - .sovd_version(kSovdVersion) - .server(build_server_url(), "Gateway server") - .add_paths(paths); +// ----------------------------------------------------------------------------- +// Entity collection spec (e.g., /areas, /components) +// ----------------------------------------------------------------------------- - return builder.build(); +nlohmann::json CapabilityGenerator::generate_entity_collection(const ResolvedPath & resolved) const { + const auto [prefix, bindings] = entity_template(resolved, false); + const nlohmann::json served = served_paths(); + return build_subtree_document(resolved.entity_type, project(served, prefix, bindings)); } // ----------------------------------------------------------------------------- -// Resource collection spec (e.g., /apps/my_app/data) +// Specific entity spec (e.g., /apps/my_app) // ----------------------------------------------------------------------------- -nlohmann::json CapabilityGenerator::generate_resource_collection(const ResolvedPath & resolved) const { - auto sovd_type_check = entity_type_from_keyword(resolved.entity_type); - if (sovd_type_check == SovdEntityType::UNKNOWN) { - return build_base_spec(); - } - - PathBuilder path_builder(schema_builder_, ctx_.auth_config().enabled); - nlohmann::json paths; +nlohmann::json CapabilityGenerator::generate_specific_entity(const ResolvedPath & resolved) const { + const auto [prefix, bindings] = entity_template(resolved, true); + const nlohmann::json served = served_paths(); + return build_subtree_document(resolved.entity_id, project(served, prefix, bindings)); +} - // Build entity path - std::string entity_path; - for (const auto & parent : resolved.parent_chain) { - entity_path += "/" + parent.entity_type + "/" + parent.entity_id; - } - entity_path += "/" + resolved.entity_type + "/" + resolved.entity_id; +// ----------------------------------------------------------------------------- +// Resource collection spec (e.g., /apps/my_app/data) +// ----------------------------------------------------------------------------- - std::string collection_path = entity_path + "/" + resolved.resource_collection; - const auto & cache = node_.get_thread_safe_cache(); +nlohmann::json CapabilityGenerator::generate_resource_collection(const ResolvedPath & resolved) const { + auto [prefix, bindings] = entity_template(resolved, true); + const std::string entity_path = concrete_path(prefix, bindings); + prefix += "/" + resolved.resource_collection; - if (resolved.resource_collection == "data") { - auto data = cache.get_entity_data(resolved.entity_id); - paths[collection_path] = path_builder.build_data_collection(entity_path, data.topics); - // Add individual data item paths - for (const auto & topic : data.topics) { - std::string item_path = collection_path + "/" + topic.name; - paths[item_path] = path_builder.build_data_item(entity_path, topic); - } - } else if (resolved.resource_collection == "operations") { - auto ops = cache.get_app_operations(resolved.entity_id); - // Try component/area/function-level aggregation if app-level is empty - auto sovd_type = entity_type_from_keyword(resolved.entity_type); - if (ops.empty() && sovd_type == SovdEntityType::COMPONENT) { - ops = cache.get_component_operations(resolved.entity_id); - } else if (ops.empty() && sovd_type == SovdEntityType::AREA) { - ops = cache.get_area_operations(resolved.entity_id); - } else if (ops.empty() && sovd_type == SovdEntityType::FUNCTION) { - ops = cache.get_function_operations(resolved.entity_id); - } - paths[collection_path] = path_builder.build_operations_collection(entity_path, ops); - for (const auto & svc : ops.services) { - std::string item_path = collection_path + "/" + svc.name; - paths[item_path] = path_builder.build_operation_item(entity_path, svc); - } - for (const auto & action : ops.actions) { - std::string item_path = collection_path + "/" + action.name; - paths[item_path] = path_builder.build_operation_item(entity_path, action); - } - } else if (resolved.resource_collection == "configurations") { - paths[collection_path] = path_builder.build_configurations_collection(entity_path); - } else if (resolved.resource_collection == "faults") { - paths[collection_path] = path_builder.build_faults_collection(entity_path); - } else if (resolved.resource_collection == "logs") { - paths[collection_path] = path_builder.build_logs_collection(entity_path); - // Also add log configuration sub-endpoint - add_log_configuration_path(paths, collection_path, entity_path); - } else if (resolved.resource_collection == "bulk-data") { - paths[collection_path] = path_builder.build_bulk_data_collection(entity_path); - } else if (resolved.resource_collection == "cyclic-subscriptions") { - paths[collection_path] = path_builder.build_cyclic_subscriptions_collection(entity_path); - } else { - // Unsupported resource collection - just note it exists with a generic path - nlohmann::json generic_path; - nlohmann::json get_op; - get_op["summary"] = "List " + resolved.resource_collection + " for " + resolved.entity_id; - get_op["responses"]["200"]["description"] = "Successful response"; - generic_path["get"] = std::move(get_op); - paths[collection_path] = std::move(generic_path); - } + const nlohmann::json served = served_paths(); + auto paths = project(served, prefix, bindings); + add_cache_derived_items(paths, resolved, entity_path); - OpenApiSpecBuilder builder; - builder.info("ROS 2 Medkit Gateway - " + resolved.entity_id + "/" + resolved.resource_collection, kGatewayVersion) - .sovd_version(kSovdVersion) - .server(build_server_url(), "Gateway server") - .add_paths(paths); - - return builder.build(); + return build_subtree_document(resolved.entity_id + "/" + resolved.resource_collection, paths); } // ----------------------------------------------------------------------------- @@ -327,118 +517,146 @@ nlohmann::json CapabilityGenerator::generate_resource_collection(const ResolvedP // ----------------------------------------------------------------------------- nlohmann::json CapabilityGenerator::generate_specific_resource(const ResolvedPath & resolved) const { - auto sovd_type_check = entity_type_from_keyword(resolved.entity_type); - if (sovd_type_check == SovdEntityType::UNKNOWN) { - return build_base_spec(); + auto [prefix, bindings] = entity_template(resolved, true); + const std::string entity_path = concrete_path(prefix, bindings); + const std::string collection_prefix = prefix + "/" + resolved.resource_collection; + + const nlohmann::json served = served_paths(); + + // Which template the item sits under is read from the registry, not + // tabulated: the item route is the one key that extends the collection by + // exactly one segment and whose segment is a whole `{param}`. A collection + // whose item segment is a literal - `/logs/configuration` - has none, and + // the literal is the prefix. + const auto item_parameter = single_parameter_segment_under(served, collection_prefix); + if (item_parameter.has_value()) { + prefix = collection_prefix + "/{" + *item_parameter + "}"; + bindings.push_back({*item_parameter, resolved.resource_id}); + } else { + prefix = collection_prefix + "/" + resolved.resource_id; } - PathBuilder path_builder(schema_builder_, ctx_.auth_config().enabled); - nlohmann::json paths; + auto paths = project(served, prefix, bindings); + add_cache_derived_items(paths, resolved, entity_path); - // Build full path - std::string entity_path; - for (const auto & parent : resolved.parent_chain) { - entity_path += "/" + parent.entity_type + "/" + parent.entity_id; - } - entity_path += "/" + resolved.entity_type + "/" + resolved.entity_id; + return build_subtree_document(resolved.resource_id, paths); +} - std::string resource_path = entity_path + "/" + resolved.resource_collection + "/" + resolved.resource_id; +void CapabilityGenerator::add_cache_derived_items(nlohmann::json & paths, const ResolvedPath & resolved, + const std::string & entity_path) const { + const std::string collection_path = entity_path + "/" + resolved.resource_collection; const auto & cache = node_.get_thread_safe_cache(); - + const PathBuilder path_builder(schema_builder_, ctx_.auth_config().enabled); + const bool one_item = !resolved.resource_id.empty(); + + // A data point or operation the cache does not know needs nothing here: the + // projection already published the item route's own description at that key, + // which is a truthful account of what the gateway will do with the request. + // Only a resource the cache *does* know gains anything, and what it gains is + // the ROS type. if (resolved.resource_collection == "data") { - // Look up specific topic data auto data = cache.get_entity_data(resolved.entity_id); - bool found = false; for (const auto & topic : data.topics) { - if (topic.name == resolved.resource_id) { - paths[resource_path] = path_builder.build_data_item(entity_path, topic); - found = true; - break; + if (one_item && topic.name != resolved.resource_id) { + continue; } + paths[collection_path + "/" + topic.name] = path_builder.build_data_item(entity_path, topic); } - if (!found) { - // Topic not found in cache, generate a generic data path - TopicData generic_topic; - generic_topic.name = resolved.resource_id; - generic_topic.type = ""; - generic_topic.direction = "publish"; - paths[resource_path] = path_builder.build_data_item(entity_path, generic_topic); - } - } else if (resolved.resource_collection == "operations") { - // Look up specific operation - auto ops = cache.get_app_operations(resolved.entity_id); - auto sovd_type = entity_type_from_keyword(resolved.entity_type); - if (ops.empty() && sovd_type == SovdEntityType::COMPONENT) { - ops = cache.get_component_operations(resolved.entity_id); - } else if (ops.empty() && sovd_type == SovdEntityType::AREA) { - ops = cache.get_area_operations(resolved.entity_id); - } else if (ops.empty() && sovd_type == SovdEntityType::FUNCTION) { - ops = cache.get_function_operations(resolved.entity_id); - } + return; + } - bool found = false; - for (const auto & svc : ops.services) { - if (svc.name == resolved.resource_id) { - paths[resource_path] = path_builder.build_operation_item(entity_path, svc); - found = true; + if (resolved.resource_collection != "operations") { + return; + } + + // Operations aggregate upward: an app owns its services directly, while a + // component, area or function collects the ones its apps expose. + auto ops = cache.get_app_operations(resolved.entity_id); + if (ops.empty()) { + switch (entity_type_from_keyword(resolved.entity_type)) { + case SovdEntityType::COMPONENT: + ops = cache.get_component_operations(resolved.entity_id); + break; + case SovdEntityType::AREA: + ops = cache.get_area_operations(resolved.entity_id); + break; + case SovdEntityType::FUNCTION: + ops = cache.get_function_operations(resolved.entity_id); + break; + case SovdEntityType::APP: + case SovdEntityType::SERVER: + case SovdEntityType::UNKNOWN: + default: break; - } } - if (!found) { - for (const auto & action : ops.actions) { - if (action.name == resolved.resource_id) { - paths[resource_path] = path_builder.build_operation_item(entity_path, action); - found = true; - break; - } - } + } + + for (const auto & svc : ops.services) { + if (one_item && svc.name != resolved.resource_id) { + continue; } - if (!found) { - // Operation not found - generate generic - ServiceInfo generic_svc; - generic_svc.name = resolved.resource_id; - generic_svc.type = ""; - paths[resource_path] = path_builder.build_operation_item(entity_path, generic_svc); + paths[collection_path + "/" + svc.name] = path_builder.build_operation_item(entity_path, svc); + } + for (const auto & action : ops.actions) { + if (one_item && action.name != resolved.resource_id) { + continue; } - } else if (resolved.resource_collection == "faults") { - paths[resource_path] = path_builder.build_faults_collection(entity_path); - } else { - // Generic resource path - nlohmann::json generic_path; - nlohmann::json get_op; - get_op["summary"] = "Get " + resolved.resource_id; - get_op["responses"]["200"]["description"] = "Successful response"; - generic_path["get"] = std::move(get_op); - paths[resource_path] = std::move(generic_path); + paths[collection_path + "/" + action.name] = path_builder.build_operation_item(entity_path, action); } - - OpenApiSpecBuilder builder; - builder.info("ROS 2 Medkit Gateway - " + resolved.resource_id, kGatewayVersion) - .sovd_version(kSovdVersion) - .server(build_server_url(), "Gateway server") - .add_paths(paths); - - return builder.build(); } // ----------------------------------------------------------------------------- // Plugin route docs // ----------------------------------------------------------------------------- -nlohmann::json CapabilityGenerator::generate_plugin_docs(const std::string & path) const { +nlohmann::json CapabilityGenerator::plugin_paths() const { if (!plugin_mgr_) { - return {}; + return nlohmann::json::object(); } - auto descriptions = plugin_mgr_->collect_route_descriptions(); - nlohmann::json matching_paths = nlohmann::json::object(); - - for (const auto & desc : descriptions) { + // A plugin's declared role is carried through unchanged here. Whether the + // gateway is in a configuration that honours it is not a question about the + // fold, so it is not answered in the fold - `generate_impl` strips every + // per-operation requirement once, over the finished document, for whatever + // producer wrote it. + nlohmann::json paths = nlohmann::json::object(); + for (const auto & desc : plugin_mgr_->collect_route_descriptions()) { auto paths_json = desc.to_json(); // CapabilityGenerator is friend - for (auto & [key, value] : paths_json.items()) { - if (key == path || key.find(path + "/") == 0) { - matching_paths[key] = value; + for (auto & [key, item] : paths_json.items()) { + for (auto & [method, operation] : item.items()) { + if (!operation.is_object()) { + continue; + } + // What separates a plugin operation from a gateway one, and the + // reason it has to be visible in the document rather than inferred + // from the path: a plugin route is mounted straight onto the HTTP + // server by `PluginManager::register_routes`, not through the + // `RouteRegistry`. Everything the registry attaches at mount time - + // most of all the emitted-status recorder that + // `test_openapi_error_coverage` reads - therefore never sees it. + operation["x-medkit-plugin-served"] = true; + + // 416 is answered by cpp-httplib before routing, so it reaches a + // plugin route for exactly the same reason it reaches a registry + // one (see the `add_response_ref("416", ...)` comment in + // `route_registry.cpp`). Stamped here rather than left to the + // plugin: it is a fact about the HTTP server the plugin is mounted + // on, not about the plugin. + operation["responses"]["416"] = nlohmann::json{{"$ref", "#/components/responses/GenericError"}}; } + paths[key] = item; + } + } + return paths; +} + +nlohmann::json CapabilityGenerator::generate_plugin_docs(const std::string & path) const { + nlohmann::json matching_paths = nlohmann::json::object(); + + auto all_paths = plugin_paths(); + for (auto & [key, value] : all_paths.items()) { + if (key == path || key.find(path + "/") == 0) { + matching_paths[key] = value; } } @@ -450,6 +668,9 @@ nlohmann::json CapabilityGenerator::generate_plugin_docs(const std::string & pat builder.info("ROS 2 Medkit Gateway - Plugin", kGatewayVersion) .sovd_version(kSovdVersion) .server(build_server_url(), "Gateway server") + // Without the definition, the per-operation `security` a plugin + // declares would name a scheme this sub-document does not have. + .security_scheme("bearerAuth", bearer_scheme(), ctx_.auth_config().enabled) .add_paths(matching_paths); return builder.build(); } @@ -596,20 +817,6 @@ bool CapabilityGenerator::validate_entity_hierarchy(const ResolvedPath & resolve // Helper methods // ----------------------------------------------------------------------------- -nlohmann::json CapabilityGenerator::build_base_spec() const { - OpenApiSpecBuilder builder; - builder.info("ROS 2 Medkit Gateway", kGatewayVersion) - .sovd_version(kSovdVersion) - .server(build_server_url(), "Gateway server"); - - const auto & auth_config = ctx_.auth_config(); - if (auth_config.enabled) { - builder.security_scheme("bearerAuth", {{"type", "http"}, {"scheme", "bearer"}, {"bearerFormat", "JWT"}}); - } - - return builder.build(); -} - std::string CapabilityGenerator::build_server_url() const { // Read host/port independently so a missing host doesn't clobber a valid port std::string host = "localhost"; @@ -653,139 +860,24 @@ SovdEntityType CapabilityGenerator::entity_type_from_keyword(const std::string & return SovdEntityType::UNKNOWN; } -void CapabilityGenerator::add_log_configuration_path(nlohmann::json & paths, const std::string & logs_path, - const std::string & entity_path) { - nlohmann::json config_path_item; - - nlohmann::json config_get; - config_get["tags"] = nlohmann::json::array({"Logs"}); - config_get["summary"] = "Get log configuration for " + entity_path; - config_get["description"] = "Returns the current log level configuration."; - config_get["responses"]["200"]["description"] = "Current log configuration"; - config_get["responses"]["200"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("LogConfiguration"); - config_path_item["get"] = std::move(config_get); - - nlohmann::json config_put; - config_put["tags"] = nlohmann::json::array({"Logs"}); - config_put["summary"] = "Update log configuration for " + entity_path; - config_put["description"] = "Update the log level configuration."; - config_put["requestBody"]["required"] = true; - config_put["requestBody"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("LogConfiguration"); - config_put["responses"]["204"]["description"] = "Log configuration updated"; - config_path_item["put"] = std::move(config_put); - - paths[logs_path + "/configuration"] = std::move(config_path_item); -} - -void CapabilityGenerator::add_resource_collection_paths(nlohmann::json & paths, const std::string & entity_path, - const std::string & entity_id, - ros2_medkit_gateway::SovdEntityType entity_type) const { - if (entity_type == SovdEntityType::UNKNOWN) { - return; - } - PathBuilder path_builder(schema_builder_, ctx_.auth_config().enabled); - auto caps = EntityCapabilities::for_type(entity_type); - const auto & cache = node_.get_thread_safe_cache(); - - for (const auto & col : caps.collections()) { - std::string col_path = entity_path + "/" + to_path_segment(col); - - switch (col) { - case ResourceCollection::DATA: { - auto data = cache.get_entity_data(entity_id); - paths[col_path] = path_builder.build_data_collection(entity_path, data.topics); - break; - } - case ResourceCollection::OPERATIONS: { - AggregatedOperations ops; - switch (entity_type) { - case SovdEntityType::APP: - ops = cache.get_app_operations(entity_id); - break; - case SovdEntityType::COMPONENT: - ops = cache.get_component_operations(entity_id); - break; - case SovdEntityType::AREA: - ops = cache.get_area_operations(entity_id); - break; - case SovdEntityType::FUNCTION: - ops = cache.get_function_operations(entity_id); - break; - case SovdEntityType::SERVER: - case SovdEntityType::UNKNOWN: - default: - break; - } - paths[col_path] = path_builder.build_operations_collection(entity_path, ops); - break; - } - case ResourceCollection::CONFIGURATIONS: - paths[col_path] = path_builder.build_configurations_collection(entity_path); - break; - case ResourceCollection::FAULTS: - paths[col_path] = path_builder.build_faults_collection(entity_path); - break; - case ResourceCollection::BULK_DATA: - paths[col_path] = path_builder.build_bulk_data_collection(entity_path); - break; - case ResourceCollection::CYCLIC_SUBSCRIPTIONS: - paths[col_path] = path_builder.build_cyclic_subscriptions_collection(entity_path); - break; - case ResourceCollection::LOGS: - paths[col_path] = path_builder.build_logs_collection(entity_path); - add_log_configuration_path(paths, col_path, entity_path); - break; - // Registered for every entity type and unconditionally 501: the routes - // carry `.only_status(501, ...)`, so a 200 here would be a success a - // client can never observe. - case ResourceCollection::DATA_CATEGORIES: - case ResourceCollection::DATA_GROUPS: { - nlohmann::json not_implemented; - nlohmann::json get_op; - get_op["tags"] = nlohmann::json::array({"Data"}); - get_op["summary"] = "List " + to_string(col) + " for " + entity_id; - get_op["description"] = "Not implemented for ROS 2 - this route always answers 501."; - get_op["responses"]["501"] = nlohmann::json{{"$ref", "#/components/responses/GenericError"}}; - not_implemented["get"] = std::move(get_op); - paths[col_path] = std::move(not_implemented); - break; - } +// ----------------------------------------------------------------------------- +// Cache helpers +// ----------------------------------------------------------------------------- - // Served, but with no dedicated builder in this file yet, so the listing - // is generic. `to_openapi_paths()` already holds each of these routes - // with its real statuses and schema; projecting the sub-document out of - // the registry is what removes the last of this hand-written half. - case ResourceCollection::LOCKS: - case ResourceCollection::TRIGGERS: - case ResourceCollection::SCRIPTS: - case ResourceCollection::FAULT_TRIGGERS: { - nlohmann::json generic_path; - nlohmann::json get_op; - get_op["summary"] = "List " + to_string(col) + " for " + entity_id; - get_op["responses"]["200"]["description"] = "Successful response"; - generic_path["get"] = std::move(get_op); - paths[col_path] = std::move(generic_path); - break; - } +void CapabilityGenerator::clear_cache_locked() const { + spec_cache_.clear(); + cache_bytes_ = 0; +} - // No entity-scoped route, so no entity type lists one of these and the - // loop cannot reach these labels. (`UPDATES` is in the SERVER list, and - // SERVER never reaches this function - it is called for the four entity - // types only.) They are spelled out rather than folded into a `default:` - // so that adding an enumerator fails the build here - // (-Werror=switch-enum) instead of silently acquiring a fabricated 200. - case ResourceCollection::DATA_LISTS: - case ResourceCollection::MODES: - case ResourceCollection::COMMUNICATION_LOGS: - case ResourceCollection::UPDATES: - break; - } - } +size_t CapabilityGenerator::cache_entry_count() const { + std::shared_lock lock(cache_mutex_); + return spec_cache_.size(); } -// ----------------------------------------------------------------------------- -// Cache helpers -// ----------------------------------------------------------------------------- +size_t CapabilityGenerator::cache_byte_size() const { + std::shared_lock lock(cache_mutex_); + return cache_bytes_; +} std::string CapabilityGenerator::get_cache_key(const std::string & path) const { auto & cache = node_.get_thread_safe_cache(); @@ -793,14 +885,14 @@ std::string CapabilityGenerator::get_cache_key(const std::string & path) const { { std::unique_lock lock(cache_mutex_); if (generation != cached_generation_) { - spec_cache_.clear(); + clear_cache_locked(); cached_generation_ = generation; } } return std::to_string(generation) + ":" + path; } -std::optional CapabilityGenerator::lookup_cache(const std::string & key) const { +std::optional CapabilityGenerator::lookup_cache(const std::string & key) const { std::shared_lock lock(cache_mutex_); auto it = spec_cache_.find(key); if (it != spec_cache_.end()) { @@ -809,12 +901,33 @@ std::optional CapabilityGenerator::lookup_cache(const std::strin return std::nullopt; } -void CapabilityGenerator::store_cache(const std::string & key, const nlohmann::json & spec) const { +void CapabilityGenerator::store_cache(const std::string & key, const std::string & document) const { + const size_t entry_bytes = key.size() + document.size(); + // A document that cannot fit the budget by itself is served but not cached; + // storing it would put the cache over its bound for as long as it is held. + if (entry_bytes > bounds_.max_bytes) { + return; + } + std::unique_lock lock(cache_mutex_); - if (spec_cache_.size() >= kMaxCacheSize) { - spec_cache_.clear(); // Simple eviction: clear all when full + + // Replacing an existing key: swap the accounting rather than adding to it. + // Two threads can miss on the same key concurrently (TODO(#272)), so this + // path is reachable and double-counting here would leak budget until the + // next generation change. + auto it = spec_cache_.find(key); + if (it != spec_cache_.end()) { + cache_bytes_ -= it->first.size() + it->second.size(); + it->second = document; + cache_bytes_ += entry_bytes; + return; + } + + if (spec_cache_.size() >= bounds_.max_entries || cache_bytes_ + entry_bytes > bounds_.max_bytes) { + clear_cache_locked(); // Simple eviction: clear all when full } - spec_cache_[key] = spec; + spec_cache_.emplace(key, document); + cache_bytes_ += entry_bytes; } } // namespace openapi diff --git a/src/ros2_medkit_gateway/src/openapi/capability_generator.hpp b/src/ros2_medkit_gateway/src/openapi/capability_generator.hpp index 2995c2b1c..347231ab1 100644 --- a/src/ros2_medkit_gateway/src/openapi/capability_generator.hpp +++ b/src/ros2_medkit_gateway/src/openapi/capability_generator.hpp @@ -19,6 +19,8 @@ #include #include #include +#include +#include #include "path_resolver.hpp" #include "route_registry.hpp" @@ -37,22 +39,69 @@ class HandlerContext; namespace openapi { +/// Entry-count bound on `CapabilityGenerator`'s document cache. +/// +/// Kept alongside the byte bound rather than replaced by it: the byte budget +/// accounts for the key and the document text, which is what the cache spends +/// nearly all of its memory on, but not for the per-entry hash node the map +/// allocates. This bounds that. +inline constexpr size_t kDocsCacheMaxEntries = 256; + +/// Byte bound on `CapabilityGenerator`'s document cache, counting each entry's +/// key plus its serialized document. +/// +/// The bound that matters: an entry's size is a function of how large the +/// ROS 2 graph is, so a count alone leaves the cache unbounded in bytes. +inline constexpr size_t kDocsCacheMaxBytes = 16UL * 1024 * 1024; + +/// The two bounds above, overridable at construction. +/// +/// Overridable so that eviction is reachable from a test without generating +/// megabytes of documents to reach the shipped budget. Production +/// construction takes the defaults. +struct DocsCacheBounds { + size_t max_entries{kDocsCacheMaxEntries}; + size_t max_bytes{kDocsCacheMaxBytes}; +}; + /// Main engine that generates context-aware OpenAPI specs for any valid /// gateway path. Uses PathResolver to classify the path, then dispatches /// to the appropriate combination of SchemaBuilder + PathBuilder + /// OpenApiSpecBuilder to produce the full document. class CapabilityGenerator { public: - static constexpr size_t kMaxCacheSize = 256; - CapabilityGenerator(handlers::HandlerContext & ctx, GatewayNode & node, PluginManager * plugin_mgr, - const RouteRegistry * route_registry = nullptr); - - /// Generate OpenAPI spec for the given base path (without /docs suffix). - /// Returns nullopt if the path is not valid or resolvable. + const RouteRegistry * route_registry = nullptr, DocsCacheBounds bounds = DocsCacheBounds{}); + + /// Number of documents currently cached. + size_t cache_entry_count() const; + + /// Bytes the cache currently accounts for: every entry's key plus its + /// serialized document. + size_t cache_byte_size() const; + + /// The OpenAPI document for the given base path (without the /docs suffix), + /// serialized exactly as `http::detail::write_json_body` would serialize it + /// (`dump(2)`). Returns nullopt if the path is not valid or resolvable. + /// + /// This is the form the cache holds and the form the `/docs` routes serve, + /// so a served document is never a copy of a parsed DOM. + std::optional generate_serialized(const std::string & base_path) const; + + /// The same document parsed. + /// + /// A convenience over `generate_serialized` for callers that want to inspect + /// the structure - today the unit tests. Serving code should call + /// `generate_serialized`: this parses on every call, because the parsed form + /// is not what is stored. Returns nullopt if the path is not valid or + /// resolvable. std::optional generate(const std::string & base_path) const; private: + /// One `{param}` a path template carries and the literal id the requested + /// path bound it to. + using PathBinding = std::pair; + nlohmann::json generate_root() const; nlohmann::json generate_entity_collection(const ResolvedPath & resolved) const; nlohmann::json generate_specific_entity(const ResolvedPath & resolved) const; @@ -62,49 +111,125 @@ class CapabilityGenerator { /// Validate entity exists and parent-child relationships hold. bool validate_entity_hierarchy(const ResolvedPath & resolved) const; - /// Build base spec with standard info/server/security blocks. - nlohmann::json build_base_spec() const; - /// Build the server URL from node parameters. std::string build_server_url() const; /// Map path resolver entity type to SovdEntityType for cache lookups. static ros2_medkit_gateway::SovdEntityType entity_type_from_keyword(const std::string & keyword); - /// Build resource collection paths for a specific entity based on its capabilities. - void add_resource_collection_paths(nlohmann::json & paths, const std::string & entity_path, - const std::string & entity_id, - ros2_medkit_gateway::SovdEntityType entity_type) const; - - /// Add logs/configuration GET+PUT sub-paths to the paths object. - static void add_log_configuration_path(nlohmann::json & paths, const std::string & logs_path, - const std::string & entity_path); + /// Every path the running gateway serves, registry routes and plugin-mounted + /// routes alike, in the shape the root document publishes them. + /// + /// Where a plugin describes a path the registry already holds, the + /// registry's description wins - the same precedence `generate_root` applies, + /// which is also where the shadowing is warned about, so this stays quiet. + nlohmann::json served_paths() const; + + /// The slice of `served` at or beneath `template_prefix`, with each binding's + /// `{param}` replaced by the literal id in the path key and the parameter + /// that described it removed from every operation. + static nlohmann::json project(const nlohmann::json & served, const std::string & template_prefix, + const std::vector & bindings); + + /// Wrap a projected `paths` object in the sub-document envelope: info block, + /// server, the bearer scheme its operations may name, and the component + /// schemas its `$ref`s reach. + nlohmann::json build_subtree_document(const std::string & title, const nlohmann::json & paths) const; + + /// The entity path template for a resolved path's parent chain, plus the + /// bindings that turn it back into the concrete path the caller asked about. + /// The resolved path's own entity is included only when `include_self` - + /// an entity *collection* names a type with no id of its own. + static std::pair> entity_template(const ResolvedPath & resolved, + bool include_self); + + /// `template_prefix` with every binding substituted - i.e. the concrete path + /// the caller asked about, rebuilt from the same two pieces the projection + /// uses so the two cannot disagree about where an entity lives. + static std::string concrete_path(const std::string & template_prefix, const std::vector & bindings); + + /// The parameter name of the single-segment `{param}` route directly under + /// `prefix`, if `served` holds one. + /// + /// Read from the served paths rather than tabulated per collection: the item + /// route is the one key that extends the collection by exactly one segment + /// and whose segment is a whole `{param}`. A hand-written map from + /// collection to parameter name is the kind of second source this projection + /// exists to delete. Returns nullopt when the collection has no item route + /// (`/logs` has only the literal `/logs/configuration`) - and also when more + /// than one candidate exists, because that is a routing shape this reading + /// does not model and guessing would bind an id to the wrong parameter. + static std::optional single_parameter_segment_under(const nlohmann::json & served, + const std::string & prefix); + + /// Add the concrete data / operation item paths under `entity_path`. + /// + /// The one part of a sub-document the route registry cannot supply: the + /// registry holds `/apps/{app_id}/data/{data_id}` with a payload schema that + /// has to cover every topic, while the schema of a *particular* topic comes + /// from the ROS type in the entity cache. + /// + /// `resolved.resource_id` empty means "every item in the collection", each + /// at its own concrete key alongside the projected template. Non-empty + /// narrows it to that one item, whose key the projection also produced - + /// there the cache-derived item replaces it, because the ROS schema is the + /// reason a caller asked about a single data point. + /// + /// Adds nothing for a resource the cache does not hold, deliberately: the + /// projected item route already describes what the gateway will do with the + /// request, and a hand-built item with an empty schema would say less. + void add_cache_derived_items(nlohmann::json & paths, const ResolvedPath & resolved, + const std::string & entity_path) const; + + /// Every path item the loaded plugins describe (via the optional + /// `describe_plugin_routes` dlsym export), normalised into the shape the + /// rest of the document uses. Empty when no plugin exports the symbol. + nlohmann::json plugin_paths() const; /// Generate OpenAPI docs for plugin-registered routes (via dlsym). nlohmann::json generate_plugin_docs(const std::string & path) const; - /// Core generation logic (called on cache miss). + /// Core generation logic (called on cache miss). Dispatches to the producer + /// for the resolved path category via `build_document`, then applies the + /// document-wide rules that hold whatever produced an operation. std::optional generate_impl(const std::string & base_path) const; + /// Dispatch to the producer for the resolved path category. Everything it + /// returns still goes through `generate_impl`'s document-wide pass. + std::optional build_document(const std::string & base_path) const; + /// Build a cache key for the given path, invalidating the cache if the /// entity cache generation has changed. std::string get_cache_key(const std::string & path) const; - /// Look up a previously cached spec by key. - std::optional lookup_cache(const std::string & key) const; + /// Look up a previously cached document by key. + std::optional lookup_cache(const std::string & key) const; - /// Store a generated spec in the cache. - void store_cache(const std::string & key, const nlohmann::json & spec) const; + /// Store a serialized document in the cache. A document that would not fit + /// the byte budget on its own is not stored. + void store_cache(const std::string & key, const std::string & document) const; + + /// Drop every entry and reset the byte total. Caller holds `cache_mutex_` + /// exclusively. + void clear_cache_locked() const; handlers::HandlerContext & ctx_; GatewayNode & node_; PluginManager * plugin_mgr_; const RouteRegistry * route_registry_; SchemaBuilder schema_builder_; - - // Generation-based spec cache - invalidated when entity cache changes + DocsCacheBounds bounds_; + + // Generation-based document cache - invalidated when entity cache changes. + // + // Values are serialized documents, not parsed DOMs. A DOM of one of these + // documents costs several times its serialized size in resident memory (a + // node per value, each separately allocated), and every cache hit would + // have to deep-copy it. Text costs its own length and a hit hands the + // handler bytes it can write straight out. mutable std::shared_mutex cache_mutex_; - mutable std::unordered_map spec_cache_; + mutable std::unordered_map spec_cache_; + mutable size_t cache_bytes_{0}; mutable uint64_t cached_generation_{0}; }; diff --git a/src/ros2_medkit_gateway/src/openapi/openapi_spec_builder.cpp b/src/ros2_medkit_gateway/src/openapi/openapi_spec_builder.cpp index 99215694b..f3a1fe820 100644 --- a/src/ros2_medkit_gateway/src/openapi/openapi_spec_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/openapi_spec_builder.cpp @@ -68,8 +68,9 @@ OpenApiSpecBuilder & OpenApiSpecBuilder::add_schemas(const nlohmann::json & sche return *this; } -OpenApiSpecBuilder & OpenApiSpecBuilder::security_scheme(const std::string & name, const nlohmann::json & scheme) { - security_schemes_.push_back({name, scheme}); +OpenApiSpecBuilder & OpenApiSpecBuilder::security_scheme(const std::string & name, const nlohmann::json & scheme, + bool document_level_requirement) { + security_schemes_.push_back({name, scheme, document_level_requirement}); return *this; } @@ -181,13 +182,19 @@ nlohmann::json OpenApiSpecBuilder::build() const { {"X-RateLimit-Reset", {{"description", "Unix timestamp at which the window resets."}, {"schema", {{"type", "string"}}}}}}; - // 7. Security schemes (if any) - if (!security_schemes_.empty()) { - spec["security"] = nlohmann::json::array(); - for (const auto & ss : security_schemes_) { - spec["components"]["securitySchemes"][ss.name] = ss.scheme; - spec["security"].push_back({{ss.name, nlohmann::json::array()}}); + // 7. Security schemes (if any). A scheme registered without a + // document-level requirement still lands in `components/securitySchemes` - + // that is what lets a single operation name it - but adds no `security` + // entry, so the document does not claim every request needs a token. + for (const auto & ss : security_schemes_) { + spec["components"]["securitySchemes"][ss.name] = ss.scheme; + if (!ss.document_level_requirement) { + continue; } + if (!spec.contains("security")) { + spec["security"] = nlohmann::json::array(); + } + spec["security"].push_back({{ss.name, nlohmann::json::array()}}); } return spec; diff --git a/src/ros2_medkit_gateway/src/openapi/openapi_spec_builder.hpp b/src/ros2_medkit_gateway/src/openapi/openapi_spec_builder.hpp index 0a7467fd4..e17976069 100644 --- a/src/ros2_medkit_gateway/src/openapi/openapi_spec_builder.hpp +++ b/src/ros2_medkit_gateway/src/openapi/openapi_spec_builder.hpp @@ -55,8 +55,18 @@ class OpenApiSpecBuilder { /// Merge schemas into components/schemas. OpenApiSpecBuilder & add_schemas(const nlohmann::json & schemas); - /// Add a security scheme and corresponding global security requirement. - OpenApiSpecBuilder & security_scheme(const std::string & name, const nlohmann::json & scheme); + /// Add a security scheme, and by default the matching document-level + /// security requirement. + /// + /// The two are separable because they say different things. The scheme is + /// a definition - "this document refers to a bearer token called `name`" - + /// and an operation cannot name a scheme the document does not define, so + /// any per-operation `security` requires it. The document-level + /// requirement is a claim about enforcement: it says *every* request needs + /// that token. Pass `document_level_requirement = false` to register the + /// definition without making that claim. + OpenApiSpecBuilder & security_scheme(const std::string & name, const nlohmann::json & scheme, + bool document_level_requirement = true); /// Build the complete OpenAPI 3.1.0 document. nlohmann::json build() const; @@ -81,6 +91,7 @@ class OpenApiSpecBuilder { struct SecuritySchemeEntry { std::string name; nlohmann::json scheme; + bool document_level_requirement; }; std::vector security_schemes_; diff --git a/src/ros2_medkit_gateway/src/openapi/path_builder.cpp b/src/ros2_medkit_gateway/src/openapi/path_builder.cpp index ec00e1004..8244be2e2 100644 --- a/src/ros2_medkit_gateway/src/openapi/path_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/path_builder.cpp @@ -19,134 +19,10 @@ namespace ros2_medkit_gateway { namespace openapi { -namespace { -/// Map entity-type keyword (e.g. "areas") to its DTO collection schema name. -std::string entity_type_to_list_name(const std::string & entity_type) { - if (entity_type == "areas") { - return "AreaList"; - } - if (entity_type == "components") { - return "ComponentList"; - } - if (entity_type == "apps") { - return "AppList"; - } - if (entity_type == "functions") { - return "FunctionList"; - } - return "AreaList"; // safe fallback -} - -/// Map entity-type keyword (e.g. "areas") to its DTO detail schema name. -std::string entity_type_to_detail_name(const std::string & entity_type) { - if (entity_type == "areas") { - return "AreaDetail"; - } - if (entity_type == "components") { - return "ComponentDetail"; - } - if (entity_type == "apps") { - return "AppDetail"; - } - if (entity_type == "functions") { - return "FunctionDetail"; - } - return "AreaDetail"; // safe fallback -} -} // namespace - PathBuilder::PathBuilder(const SchemaBuilder & schema_builder, bool auth_enabled) : schema_builder_(schema_builder), auth_enabled_(auth_enabled) { } -// ----------------------------------------------------------------------------- -// Entity collection paths -// ----------------------------------------------------------------------------- - -nlohmann::json PathBuilder::build_entity_collection(const std::string & entity_type) const { - nlohmann::json path_item; - - nlohmann::json get_op; - get_op["tags"] = nlohmann::json::array({"Discovery"}); - get_op["summary"] = "List all " + entity_type; - get_op["description"] = "Returns the collection of " + entity_type + " entities."; - get_op["parameters"] = build_query_params_for_collection(); - get_op["responses"]["200"]["description"] = "Successful response"; - get_op["responses"]["200"]["content"]["application/json"]["schema"] = - SchemaBuilder::ref(entity_type_to_list_name(entity_type)); - - // Merge error responses - auto errors = error_responses(); - for (auto & [code, val] : errors.items()) { - get_op["responses"][code] = val; - } - - path_item["get"] = std::move(get_op); - return path_item; -} - -// ----------------------------------------------------------------------------- -// Entity detail paths -// ----------------------------------------------------------------------------- - -nlohmann::json PathBuilder::build_entity_detail(const std::string & entity_type, bool use_template) const { - nlohmann::json path_item; - - // Derive singular name from entity_type for param description - // "areas" -> "area", "components" -> "component", "apps" -> "app" - std::string singular = entity_type; - if (!singular.empty() && singular.back() == 's') { - singular.pop_back(); - } - - nlohmann::json get_op; - get_op["tags"] = nlohmann::json::array({"Discovery"}); - get_op["summary"] = "Get " + singular + " details"; - get_op["description"] = "Returns detailed information about a specific " + singular + "."; - if (use_template) { - get_op["parameters"] = - nlohmann::json::array({build_path_param(singular + "_id", "The " + singular + " identifier")}); - } - get_op["responses"]["200"]["description"] = "Successful response"; - get_op["responses"]["200"]["content"]["application/json"]["schema"] = - SchemaBuilder::ref(entity_type_to_detail_name(entity_type)); - - auto errors = error_responses(); - for (auto & [code, val] : errors.items()) { - get_op["responses"][code] = val; - } - - path_item["get"] = std::move(get_op); - return path_item; -} - -// ----------------------------------------------------------------------------- -// Data collection -// ----------------------------------------------------------------------------- - -nlohmann::json PathBuilder::build_data_collection(const std::string & entity_path, - const std::vector & /*topics*/) const { - nlohmann::json path_item; - - nlohmann::json get_op; - get_op["tags"] = nlohmann::json::array({"Data"}); - get_op["summary"] = "List data items for " + entity_path; - get_op["description"] = "Returns all available data items (topics) for this entity."; - get_op["parameters"] = build_query_params_for_collection(); - - get_op["responses"]["200"]["description"] = "Successful response"; - get_op["responses"]["200"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("DataList"); - - auto errors = error_responses(); - for (auto & [code, val] : errors.items()) { - get_op["responses"][code] = val; - } - - path_item["get"] = std::move(get_op); - path_item["x-sovd-data-category"] = "currentData"; - return path_item; -} - // ----------------------------------------------------------------------------- // Data item // ----------------------------------------------------------------------------- @@ -196,33 +72,6 @@ nlohmann::json PathBuilder::build_data_item(const std::string & /*entity_path*/, return path_item; } -// ----------------------------------------------------------------------------- -// Operations collection -// ----------------------------------------------------------------------------- - -nlohmann::json PathBuilder::build_operations_collection(const std::string & entity_path, - const AggregatedOperations & /*ops*/) const { - nlohmann::json path_item; - - // GET - list all operations - nlohmann::json get_op; - get_op["tags"] = nlohmann::json::array({"Operations"}); - get_op["summary"] = "List operations for " + entity_path; - get_op["description"] = "Returns all available operations (services and actions) for this entity."; - get_op["parameters"] = build_query_params_for_collection(); - - get_op["responses"]["200"]["description"] = "Successful response"; - get_op["responses"]["200"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("OperationList"); - - auto errors = error_responses(); - for (auto & [code, val] : errors.items()) { - get_op["responses"][code] = val; - } - - path_item["get"] = std::move(get_op); - return path_item; -} - // ----------------------------------------------------------------------------- // Operation item (service) // ----------------------------------------------------------------------------- @@ -316,214 +165,6 @@ nlohmann::json PathBuilder::build_operation_item(const std::string & /*entity_pa return path_item; } -// ----------------------------------------------------------------------------- -// Configurations collection -// ----------------------------------------------------------------------------- - -nlohmann::json PathBuilder::build_configurations_collection(const std::string & entity_path) const { - nlohmann::json path_item; - - // GET - list all configuration parameters - nlohmann::json get_op; - get_op["tags"] = nlohmann::json::array({"Configuration"}); - get_op["summary"] = "List configuration parameters for " + entity_path; - get_op["description"] = "Returns all configuration parameters for this entity."; - get_op["parameters"] = build_query_params_for_collection(); - get_op["responses"]["200"]["description"] = "Successful response"; - get_op["responses"]["200"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("ConfigurationList"); - - auto errors = error_responses(); - for (auto & [code, val] : errors.items()) { - get_op["responses"][code] = val; - } - - path_item["get"] = std::move(get_op); - - // DELETE - delete all configuration parameters - nlohmann::json delete_op; - delete_op["tags"] = nlohmann::json::array({"Configuration"}); - delete_op["summary"] = "Delete all configuration parameters"; - delete_op["description"] = "Delete all configuration parameters for this entity, resetting them to defaults."; - delete_op["responses"]["204"]["description"] = "All parameters deleted"; - delete_op["responses"]["207"]["description"] = "Partial success - some nodes failed"; - delete_op["responses"]["207"]["content"]["application/json"]["schema"] = - SchemaBuilder::ref("ConfigurationDeleteMultiStatus"); - - auto del_errors = error_responses(); - for (auto & [code, val] : del_errors.items()) { - delete_op["responses"][code] = val; - } - - path_item["delete"] = std::move(delete_op); - return path_item; -} - -// ----------------------------------------------------------------------------- -// Faults collection -// ----------------------------------------------------------------------------- - -nlohmann::json PathBuilder::build_faults_collection(const std::string & entity_path) const { - nlohmann::json path_item; - - // GET - list faults - nlohmann::json get_op; - get_op["tags"] = nlohmann::json::array({"Faults"}); - get_op["summary"] = entity_path.empty() ? "List all faults" : "List faults for " + entity_path; - get_op["description"] = - entity_path.empty() ? "Returns all faults." : "Returns all faults associated with this entity."; - get_op["parameters"] = build_query_params_for_collection(); - get_op["responses"]["200"]["description"] = "Successful response"; - get_op["responses"]["200"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("FaultList"); - - auto errors = error_responses(); - for (auto & [code, val] : errors.items()) { - get_op["responses"][code] = val; - } - - path_item["get"] = std::move(get_op); - - // DELETE - clear all faults for this entity - nlohmann::json delete_op; - delete_op["tags"] = nlohmann::json::array({"Faults"}); - delete_op["summary"] = entity_path.empty() ? "Clear all faults" : "Clear faults for " + entity_path; - delete_op["description"] = - entity_path.empty() ? "Clear all faults in the system." : "Clear all faults associated with this entity."; - delete_op["responses"]["204"]["description"] = "Faults cleared successfully"; - - auto del_errors = error_responses(); - for (auto & [code, val] : del_errors.items()) { - delete_op["responses"][code] = val; - } - - path_item["delete"] = std::move(delete_op); - return path_item; -} - -// ----------------------------------------------------------------------------- -// Logs collection -// ----------------------------------------------------------------------------- - -nlohmann::json PathBuilder::build_logs_collection(const std::string & entity_path) const { - nlohmann::json path_item; - - nlohmann::json get_op; - get_op["tags"] = nlohmann::json::array({"Logs"}); - get_op["summary"] = "List log entries for " + entity_path; - get_op["description"] = "Returns log entries associated with this entity."; - - // Log-specific query parameters - nlohmann::json params = build_query_params_for_collection(); - nlohmann::json level_param; - level_param["name"] = "level"; - level_param["in"] = "query"; - level_param["required"] = false; - level_param["description"] = "Filter by log level (e.g., DEBUG, INFO, WARN, ERROR, FATAL)"; - level_param["schema"]["type"] = "string"; - params.push_back(std::move(level_param)); - - get_op["parameters"] = std::move(params); - get_op["responses"]["200"]["description"] = "Successful response"; - get_op["responses"]["200"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("LogEntryList"); - - auto errors = error_responses(); - for (auto & [code, val] : errors.items()) { - get_op["responses"][code] = val; - } - - path_item["get"] = std::move(get_op); - return path_item; -} - -// ----------------------------------------------------------------------------- -// Bulk data collection -// ----------------------------------------------------------------------------- - -nlohmann::json PathBuilder::build_bulk_data_collection(const std::string & entity_path) const { - nlohmann::json path_item; - - nlohmann::json get_op; - get_op["tags"] = nlohmann::json::array({"Bulk Data"}); - get_op["summary"] = "List bulk data categories for " + entity_path; - get_op["description"] = "Returns available bulk data categories (e.g., rosbags) for this entity."; - get_op["parameters"] = build_query_params_for_collection(); - get_op["responses"]["200"]["description"] = "Successful response"; - get_op["responses"]["200"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("BulkDataCategoryList"); - - auto errors = error_responses(); - for (auto & [code, val] : errors.items()) { - get_op["responses"][code] = val; - } - - path_item["get"] = std::move(get_op); - return path_item; -} - -// ----------------------------------------------------------------------------- -// Cyclic subscriptions collection -// ----------------------------------------------------------------------------- - -nlohmann::json PathBuilder::build_cyclic_subscriptions_collection(const std::string & entity_path) const { - nlohmann::json path_item; - - // GET - list active subscriptions - nlohmann::json get_op; - get_op["tags"] = nlohmann::json::array({"Subscriptions"}); - get_op["summary"] = "List cyclic subscriptions for " + entity_path; - get_op["description"] = "Returns all active cyclic subscriptions for this entity."; - get_op["parameters"] = build_query_params_for_collection(); - get_op["responses"]["200"]["description"] = "Successful response"; - get_op["responses"]["200"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("CyclicSubscriptionList"); - - auto errors = error_responses(); - for (auto & [code, val] : errors.items()) { - get_op["responses"][code] = val; - } - - path_item["get"] = std::move(get_op); - - // POST - create a new cyclic subscription - nlohmann::json post_op; - post_op["tags"] = nlohmann::json::array({"Subscriptions"}); - post_op["summary"] = "Create cyclic subscription"; - post_op["description"] = "Create a new cyclic subscription to stream data changes via SSE."; - post_op["requestBody"]["required"] = true; - post_op["requestBody"]["content"]["application/json"]["schema"] = - SchemaBuilder::ref("CyclicSubscriptionCreateRequest"); - post_op["responses"]["201"]["description"] = "Subscription created"; - post_op["responses"]["201"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("CyclicSubscription"); - - auto post_errors = error_responses(); - for (auto & [code, val] : post_errors.items()) { - post_op["responses"][code] = val; - } - - path_item["post"] = std::move(post_op); - return path_item; -} - -// ----------------------------------------------------------------------------- -// SSE endpoint -// ----------------------------------------------------------------------------- - -nlohmann::json PathBuilder::build_sse_endpoint(const std::string & /*path*/, const std::string & description) const { - nlohmann::json path_item; - - nlohmann::json get_op; - get_op["tags"] = nlohmann::json::array({"Events"}); - get_op["summary"] = description; - get_op["description"] = description + " Streams events using Server-Sent Events (SSE)."; - get_op["responses"]["200"]["description"] = "SSE event stream"; - get_op["responses"]["200"]["content"]["text/event-stream"]["schema"] = {{"type", "string"}}; - - auto errors = error_responses(); - for (auto & [code, val] : errors.items()) { - get_op["responses"][code] = val; - } - - path_item["get"] = std::move(get_op); - return path_item; -} - // ----------------------------------------------------------------------------- // Error responses // ----------------------------------------------------------------------------- @@ -551,43 +192,5 @@ nlohmann::json PathBuilder::error_responses() const { return errors; } -// ----------------------------------------------------------------------------- -// Private helpers -// ----------------------------------------------------------------------------- - -nlohmann::json PathBuilder::build_path_param(const std::string & name, const std::string & description) const { - nlohmann::json param; - param["name"] = name; - param["in"] = "path"; - param["required"] = true; - param["description"] = description; - param["schema"]["type"] = "string"; - return param; -} - -nlohmann::json PathBuilder::build_query_params_for_collection() const { - nlohmann::json params = nlohmann::json::array(); - - nlohmann::json limit_param; - limit_param["name"] = "limit"; - limit_param["in"] = "query"; - limit_param["required"] = false; - limit_param["description"] = "Maximum number of items to return"; - limit_param["schema"]["type"] = "integer"; - limit_param["schema"]["minimum"] = 1; - params.push_back(std::move(limit_param)); - - nlohmann::json offset_param; - offset_param["name"] = "offset"; - offset_param["in"] = "query"; - offset_param["required"] = false; - offset_param["description"] = "Number of items to skip"; - offset_param["schema"]["type"] = "integer"; - offset_param["schema"]["minimum"] = 0; - params.push_back(std::move(offset_param)); - - return params; -} - } // namespace openapi } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/openapi/path_builder.hpp b/src/ros2_medkit_gateway/src/openapi/path_builder.hpp index 7d5f827ff..c9fe05c69 100644 --- a/src/ros2_medkit_gateway/src/openapi/path_builder.hpp +++ b/src/ros2_medkit_gateway/src/openapi/path_builder.hpp @@ -16,7 +16,6 @@ #include #include -#include #include "ros2_medkit_gateway/core/discovery/models/common.hpp" #include "ros2_medkit_gateway/core/models/thread_safe_entity_cache.hpp" @@ -26,41 +25,35 @@ namespace openapi { class SchemaBuilder; -/// Builds OpenAPI 3.1.0 PathItem JSON objects for each resource type. -/// Uses SchemaBuilder for response/request schemas and adds SOVD extensions. +/// Builds the OpenAPI PathItem for one *discovered* resource - a single ROS 2 +/// topic, service or action. +/// +/// What is left here after the `/docs` sub-documents became a +/// projection of `RouteRegistry::to_openapi_paths()`: the route registry holds +/// `/apps/{app_id}/data/{data_id}` with a payload schema that has to cover +/// every topic at once, and it has no way to learn that *this* entity's +/// `temperature` carries a `std_msgs/msg/Float32` or that a publish-only topic +/// cannot be written. That comes from the entity cache, so these three +/// builders do, and nothing else in this class does. +/// +/// A path item built here is not a projection of anything and so does not +/// carry what a projected operation carries - the framework-level error set, +/// the declared role. Everything the registry *can* answer is answered by the +/// projection; adding a fourth builder here is how the hand-written half grows +/// back. class PathBuilder { public: explicit PathBuilder(const SchemaBuilder & schema_builder, bool auth_enabled = false); - // Entity collection paths (GET /areas, GET /components, etc.) - nlohmann::json build_entity_collection(const std::string & entity_type) const; - - // Entity detail path (GET /areas/{id}, GET /apps/{id}) - /// @param use_template If true, emit path parameter for {entity_id}. If false, assume concrete path. - nlohmann::json build_entity_detail(const std::string & entity_type, bool use_template = true) const; - - // Resource collection paths - nlohmann::json build_data_collection(const std::string & entity_path, const std::vector & topics) const; nlohmann::json build_data_item(const std::string & entity_path, const TopicData & topic) const; - nlohmann::json build_operations_collection(const std::string & entity_path, const AggregatedOperations & ops) const; nlohmann::json build_operation_item(const std::string & entity_path, const ServiceInfo & service) const; nlohmann::json build_operation_item(const std::string & entity_path, const ActionInfo & action) const; - nlohmann::json build_configurations_collection(const std::string & entity_path) const; - nlohmann::json build_faults_collection(const std::string & entity_path) const; - nlohmann::json build_logs_collection(const std::string & entity_path) const; - nlohmann::json build_bulk_data_collection(const std::string & entity_path) const; - nlohmann::json build_cyclic_subscriptions_collection(const std::string & entity_path) const; - // SSE endpoints - nlohmann::json build_sse_endpoint(const std::string & path, const std::string & description) const; - - // Common helpers + /// The 400/404/500 set every item path carries, plus 401/403 when + /// authentication is on. nlohmann::json error_responses() const; private: - nlohmann::json build_path_param(const std::string & name, const std::string & description) const; - nlohmann::json build_query_params_for_collection() const; - const SchemaBuilder & schema_builder_; bool auth_enabled_; }; diff --git a/src/ros2_medkit_gateway/src/openapi/route_registry.hpp b/src/ros2_medkit_gateway/src/openapi/route_registry.hpp index 14aaea275..703ad0e36 100644 --- a/src/ros2_medkit_gateway/src/openapi/route_registry.hpp +++ b/src/ros2_medkit_gateway/src/openapi/route_registry.hpp @@ -29,6 +29,7 @@ #include #include +#include "ros2_medkit_gateway/core/auth/auth_config.hpp" #include "ros2_medkit_gateway/core/http/error_codes.hpp" #include "ros2_medkit_gateway/dto/contract.hpp" #include "ros2_medkit_gateway/dto/json_reader.hpp" @@ -388,6 +389,35 @@ class RouteEntry { /// closure captures the shared_ptr by value). RouteEntry & error_renderer(ErrorRenderer renderer); + /// Declare the weakest role that may call this route. + /// + /// One declaration, two consumers, and that is the point of putting it on + /// the registration rather than in a table somewhere else: + /// * `RouteRegistry::route_permissions()` turns it into the entries + /// `AuthManager::check_authorization` matches against, expanded to this + /// role *and every stronger one* - `AuthConfig` has no inheritance, so + /// the expansion is what makes "operator or above" true; + /// * `to_openapi_paths()` publishes it as `security: [{bearerAuth: + /// []}]`, the same shape a plugin's `OperationDesc::requires_role` + /// emits. + /// + /// Mandatory. Enforcement fails closed - a path no entry matches is 403 - + /// so a registration that declares neither this nor `public_route()` ships a + /// route nobody below ADMIN can call. `validate_completeness()` reports that + /// as an error, including for `hidden()` routes: hidden means absent from the + /// document, not absent from the router. + RouteEntry & requires_role(UserRole role); + + /// Declare that this route is reachable with no token at all. + /// + /// Only legitimate where the *middleware* exempts the path before it ever + /// reaches the permission table - today that is `/auth/*` alone, which + /// `AllAuthRequirementPolicy` and `WriteOnlyAuthRequirementPolicy` both let + /// through by prefix. Marking any other route public would emit `security: + /// []` while `require_auth_for: all` still demanded a token, and the route + /// would answer 403 for every role the table does not otherwise cover. + RouteEntry & public_route(); + private: friend class RouteRegistry; std::string method_; @@ -418,6 +448,12 @@ class RouteEntry { bool takes_no_request_body_{false}; std::string operation_id_; + /// Set by requires_role() / public_route(). The optional distinguishes + /// "public" (declared, no role) from "not declared yet", which is what lets + /// validate_completeness() tell a deliberate exemption from an omission. + std::optional required_role_; + bool role_declared_{false}; + /// Heap-allocated so the typed wrapper closure can hold a stable handle to /// the renderer choice and observe later `.error_renderer(...)` updates. std::shared_ptr error_renderer_{std::make_shared(ErrorRenderer::kSovdGenericError)}; @@ -690,14 +726,33 @@ class RouteRegistry { /// Register the OpenAPI JSON endpoint at the given path. The spec body is /// supplied by the caller (typically a closure over the gateway's - /// `OpenApiSpecBuilder`). + /// `OpenApiSpecBuilder`) already serialized, and written out as-is. + /// + /// Serialized rather than a `nlohmann::json` the wrapper would dump: the + /// capability generator caches documents as text, and taking a DOM here + /// would mean parsing one back out of the cache only for this wrapper to + /// dump it again. The caller owes the same bytes `write_json_body` would + /// have produced - see `http::detail::write_json_text`. + /// + /// The route is documented like any other. A capability description a + /// client cannot discover from the document it is reading is one it has to + /// be told about out of band, which defeats the point of serving it. RouteEntry & docs_endpoint(const std::string & openapi_path, - std::function(http::TypedRequest)> handler); + std::function(http::TypedRequest)> handler); - /// Register a catch-all docs route via cpp-httplib regex (used for Swagger - /// UI subtree where the path arguments are not fixed). The route is hidden - /// from the OpenAPI spec. - RouteEntry & docs_subtree(const std::string & regex_pattern, HandlerFn handler); + /// Register a route whose URI is a cpp-httplib regex the OpenAPI path + /// grammar cannot express, used for the `/docs` sub-document + /// whose prefix is any entity or resource path. + /// + /// `openapi_path` is what the document publishes and `regex_pattern` is + /// what cpp-httplib matches; they are separate arguments because the regex + /// is not a path template and emitting it as a path key would put a + /// literal `(.+)` in the document. The caller owns both, so they can + /// disagree - keep the template the shape a client would fill in. + /// + /// Declares its own 200 carrying the OpenAPI document, so a call site never + /// hand-attaches a success status. + RouteEntry & docs_subtree(const std::string & openapi_path, const std::string & regex_pattern, HandlerFn handler); // --------------------------------------------------------------------------- // Registry-level operations. @@ -720,6 +775,42 @@ class RouteRegistry { /// warnings indicate missing optional metadata. std::vector validate_completeness() const; + /// Derive the RBAC permission entries for every registered route. + /// + /// Keyed by role, each value a set of `":"` strings in + /// the grammar `AuthManager::matches_path` reads - the same table + /// `check_authorization` has always consulted, now produced from the + /// registrations instead of restated beside them. + /// + /// Two translations happen here and neither is cosmetic: + /// + /// * The pattern comes from each route's **cpp-httplib regex**, not from its + /// OpenAPI path. That regex is what actually decides whether a request + /// reaches the handler, so deriving the permission from it is what keeps + /// the two from disagreeing. `([^/]+)` becomes `*` (one segment) and + /// `(.+)` becomes `**` (any number), which matters for the routes whose + /// last parameter is allowed to contain slashes - a ROS topic name under + /// `/data/{data_id}` or a dotted parameter under + /// `/configurations/{config_id}` - and for the `/docs` + /// catch-all, whose prefix is a whole path. Deriving from `{param}` alone + /// would have made all three single-segment and 403'd the very requests + /// they exist to serve. + /// + /// * Roles are expanded upward. `AuthConfig` has no inheritance + /// (`check_authorization` looks up exactly one role's set), so a route + /// declaring OPERATOR lands in OPERATOR, CONFIGURATOR and ADMIN. + /// + /// `public_route()` routes contribute nothing: the middleware answers them + /// before the table is consulted, so an entry would be dead weight. + /// + /// Routes that declare no role contribute nothing either, which is exactly + /// the fail-closed 403 `validate_completeness()` reports as an error. + /// + /// Covers only what the registry holds. Routes mounted straight onto the + /// HTTP server - plugin routes, Swagger UI, the test-build status recorder - + /// are not here; see `AuthConfig::residual_route_permissions()`. + RoutePermissions route_permissions(const std::string & api_prefix) const; + /// Number of registered routes. size_t size() const { return routes_.size(); @@ -871,6 +962,42 @@ class RouteRegistry { std::shared_ptr renderer, GateHandle gate); }; +// ============================================================================= +// Projections over an emitted `paths` object +// +// These take what `to_openapi_paths()` produced rather than the registry +// itself, so they apply equally to the plugin-fold's paths - and so the +// `/docs` sub-documents are a slice of the document the gateway +// actually serves instead of a second, hand-written description of it. +// ============================================================================= + +/// Every path item in `paths` whose key is `prefix` or lies beneath it. +/// +/// The comparison is by path segment, not by string prefix, and that is the +/// whole point: `/apps/{app_id}/data` *is* a string prefix of +/// `/apps/{app_id}/data-groups`, so a `starts_with` filter would hand the data +/// collection's sub-document three routes belonging to a different collection. +/// A key qualifies when it equals `prefix` exactly, or continues it with `/`. +/// +/// Returns an empty object when `paths` is not an object, so a caller can pass +/// a document that has no paths at all. +nlohmann::json paths_under(const nlohmann::json & paths, const std::string & prefix); + +/// Remove the `in: path` parameter named `param_name` from every operation in +/// `path_item`. +/// +/// The other half of substituting a literal id into a path key, and not +/// optional: OpenAPI ties a path parameter to a `{template}` in the key, so +/// once the key names a concrete entity a parameter declared for it describes +/// a placeholder the path no longer has. Leaving it behind publishes a +/// sub-document a strict validator rejects and a generated client would build +/// a call signature from. +/// +/// Only `in: path` parameters of that name are touched - a header or query +/// parameter that happens to share the name stays, because substituting the +/// path answered neither. +void strip_entity_path_parameter(nlohmann::json & path_item, const std::string & param_name); + // ============================================================================= // Template implementations // ============================================================================= diff --git a/src/ros2_medkit_gateway/test/test_auth_config.cpp b/src/ros2_medkit_gateway/test/test_auth_config.cpp index 41322fc8f..031bea5ca 100644 --- a/src/ros2_medkit_gateway/test/test_auth_config.cpp +++ b/src/ros2_medkit_gateway/test/test_auth_config.cpp @@ -20,151 +20,52 @@ using namespace ros2_medkit_gateway; // ============================================================================= -// AuthConfig role permissions tests +// Residual route permissions // ============================================================================= +// +// What is left in AuthConfig after the per-role table moved to the +// registrations. These tests are about the residual list itself - what it +// covers and, more usefully, what it deliberately does not. The gateway's real +// per-route grants are derived by `RouteRegistry::route_permissions()` and +// checked end to end against enforcement in `test_rbac_contract.test.py`; +// asserting them here would only restate the derivation's input. -TEST(AuthConfigRolePermissionsTest, ViewerPermissionsExist) { - const auto & permissions = AuthConfig::get_role_permissions(); - - auto it = permissions.find(UserRole::VIEWER); - ASSERT_NE(it, permissions.end()); - - const auto & viewer_perms = it->second; - - // Viewer should have read access - EXPECT_TRUE(viewer_perms.count("GET:/api/v1/health") > 0); - EXPECT_TRUE(viewer_perms.count("GET:/api/v1/areas") > 0); - EXPECT_TRUE(viewer_perms.count("GET:/api/v1/components") > 0); - - // Viewer should NOT have write access - EXPECT_TRUE(viewer_perms.count("POST:/api/v1/components/*/operations/*") == 0); - EXPECT_TRUE(viewer_perms.count("PUT:/api/v1/components/*/configurations/*") == 0); - EXPECT_TRUE(viewer_perms.count("DELETE:/api/v1/components/*/faults/*") == 0); -} - -TEST(AuthConfigRolePermissionsTest, OperatorPermissionsIncludeOperations) { - const auto & permissions = AuthConfig::get_role_permissions(); - - auto it = permissions.find(UserRole::OPERATOR); - ASSERT_NE(it, permissions.end()); - - const auto & operator_perms = it->second; - - // Operator should have operation permissions - EXPECT_TRUE(operator_perms.count("POST:/api/v1/components/*/operations/*/executions") > 0); - EXPECT_TRUE(operator_perms.count("PUT:/api/v1/components/*/operations/*/executions/*") > 0); - EXPECT_TRUE(operator_perms.count("DELETE:/api/v1/components/*/operations/*/executions/*") > 0); - EXPECT_TRUE(operator_perms.count("DELETE:/api/v1/components/*/faults/*") > 0); - EXPECT_TRUE(operator_perms.count("PUT:/api/v1/components/*/data/*") > 0); - - // Operator should NOT have config modification - EXPECT_TRUE(operator_perms.count("PUT:/api/v1/components/*/configurations/*") == 0); -} - -TEST(AuthConfigRolePermissionsTest, ConfiguratorPermissionsIncludeConfigurations) { - const auto & permissions = AuthConfig::get_role_permissions(); - - auto it = permissions.find(UserRole::CONFIGURATOR); - ASSERT_NE(it, permissions.end()); - - const auto & config_perms = it->second; - - // Configurator should have config permissions - EXPECT_TRUE(config_perms.count("PUT:/api/v1/components/*/configurations/*") > 0); - EXPECT_TRUE(config_perms.count("DELETE:/api/v1/components/*/configurations/*") > 0); - - // Plus all operator permissions - EXPECT_TRUE(config_perms.count("POST:/api/v1/components/*/operations/*/executions") > 0); - EXPECT_TRUE(config_perms.count("PUT:/api/v1/components/*/operations/*/executions/*") > 0); -} - -TEST(AuthConfigRolePermissionsTest, AdminHasWildcardAccess) { - const auto & permissions = AuthConfig::get_role_permissions(); +TEST(AuthConfigResidualPermissionsTest, AdminWildcardsCoverEveryMethod) { + // @verifies REQ_INTEROP_086 + const auto & permissions = AuthConfig::residual_route_permissions(); auto it = permissions.find(UserRole::ADMIN); ASSERT_NE(it, permissions.end()); const auto & admin_perms = it->second; - - // Admin should have wildcard access EXPECT_TRUE(admin_perms.count("GET:/api/v1/**") > 0); EXPECT_TRUE(admin_perms.count("POST:/api/v1/**") > 0); EXPECT_TRUE(admin_perms.count("PUT:/api/v1/**") > 0); EXPECT_TRUE(admin_perms.count("DELETE:/api/v1/**") > 0); } -// ============================================================================= -// Status and lifecycle RBAC tests -// ============================================================================= - -TEST(AuthConfigRolePermissionsTest, ViewerCanReadStatus) { - const auto & permissions = AuthConfig::get_role_permissions(); - - const auto & viewer_perms = permissions.at(UserRole::VIEWER); - EXPECT_TRUE(viewer_perms.count("GET:/api/v1/apps/*/status") > 0); - EXPECT_TRUE(viewer_perms.count("GET:/api/v1/components/*/status") > 0); -} - -TEST(AuthConfigRolePermissionsTest, OperatorCanReadStatusAndControlNonDestructiveLifecycle) { - const auto & permissions = AuthConfig::get_role_permissions(); - - const auto & operator_perms = permissions.at(UserRole::OPERATOR); - EXPECT_TRUE(operator_perms.count("GET:/api/v1/apps/*/status") > 0); - EXPECT_TRUE(operator_perms.count("GET:/api/v1/components/*/status") > 0); - // Non-destructive transitions are allowed for OPERATOR. - EXPECT_TRUE(operator_perms.count("PUT:/api/v1/apps/*/status/start") > 0); - EXPECT_TRUE(operator_perms.count("PUT:/api/v1/apps/*/status/restart") > 0); - EXPECT_TRUE(operator_perms.count("PUT:/api/v1/apps/*/status/force-restart") > 0); - EXPECT_TRUE(operator_perms.count("PUT:/api/v1/components/*/status/start") > 0); - EXPECT_TRUE(operator_perms.count("PUT:/api/v1/components/*/status/restart") > 0); - EXPECT_TRUE(operator_perms.count("PUT:/api/v1/components/*/status/force-restart") > 0); -} - -TEST(AuthConfigRolePermissionsTest, OperatorCannotPerformDestructiveTransitions) { - const auto & permissions = AuthConfig::get_role_permissions(); - - const auto & operator_perms = permissions.at(UserRole::OPERATOR); - // shutdown / force-shutdown tear an entity down and are gated behind CONFIGURATOR. - EXPECT_TRUE(operator_perms.count("PUT:/api/v1/apps/*/status/shutdown") == 0); - EXPECT_TRUE(operator_perms.count("PUT:/api/v1/apps/*/status/force-shutdown") == 0); - EXPECT_TRUE(operator_perms.count("PUT:/api/v1/components/*/status/shutdown") == 0); - EXPECT_TRUE(operator_perms.count("PUT:/api/v1/components/*/status/force-shutdown") == 0); - // The old broad wildcard must not be present. - EXPECT_TRUE(operator_perms.count("PUT:/api/v1/apps/*/status/*") == 0); -} - -TEST(AuthConfigRolePermissionsTest, ConfiguratorCanControlDestructiveLifecycle) { - const auto & permissions = AuthConfig::get_role_permissions(); - - const auto & config_perms = permissions.at(UserRole::CONFIGURATOR); - EXPECT_TRUE(config_perms.count("GET:/api/v1/apps/*/status") > 0); - EXPECT_TRUE(config_perms.count("GET:/api/v1/components/*/status") > 0); - // CONFIGURATOR has the non-destructive transitions plus the teardown ones. - EXPECT_TRUE(config_perms.count("PUT:/api/v1/apps/*/status/restart") > 0); - EXPECT_TRUE(config_perms.count("PUT:/api/v1/apps/*/status/shutdown") > 0); - EXPECT_TRUE(config_perms.count("PUT:/api/v1/apps/*/status/force-shutdown") > 0); - EXPECT_TRUE(config_perms.count("PUT:/api/v1/components/*/status/shutdown") > 0); - EXPECT_TRUE(config_perms.count("PUT:/api/v1/components/*/status/force-shutdown") > 0); +TEST(AuthConfigResidualPermissionsTest, NoRoleBelowAdminHasResidualEntries) { + // The residual list exists for routes mounted outside the RouteRegistry - + // plugin routes above all. Granting one of those to a weaker role would hand + // that role every route whichever plugins the deployment happens to load, + // sight unseen, so the list stops at ADMIN. This is also why a plugin + // operation's published role reads `admin`. + const auto & permissions = AuthConfig::residual_route_permissions(); + + EXPECT_EQ(permissions.count(UserRole::VIEWER), 0u); + EXPECT_EQ(permissions.count(UserRole::OPERATOR), 0u); + EXPECT_EQ(permissions.count(UserRole::CONFIGURATOR), 0u); } -TEST(AuthConfigRolePermissionsTest, ViewerCannotControlLifecycle) { - const auto & permissions = AuthConfig::get_role_permissions(); +TEST(AuthConfigResidualPermissionsTest, CarriesNoPerRouteEntries) { + // The old literal table lived here and listed every gateway route by hand. + // It is gone, and this pins that it stays gone: a per-route entry added back + // here would be a second source for a grant the registration already + // declares, and the two would drift. + const auto & permissions = AuthConfig::residual_route_permissions(); - const auto & viewer_perms = permissions.at(UserRole::VIEWER); - EXPECT_TRUE(viewer_perms.count("PUT:/api/v1/apps/*/status/start") == 0); - EXPECT_TRUE(viewer_perms.count("PUT:/api/v1/apps/*/status/restart") == 0); - EXPECT_TRUE(viewer_perms.count("PUT:/api/v1/apps/*/status/shutdown") == 0); - EXPECT_TRUE(viewer_perms.count("PUT:/api/v1/components/*/status/restart") == 0); -} - -TEST(AuthConfigRolePermissionsTest, AdminStatusCoveredByWildcard) { - const auto & permissions = AuthConfig::get_role_permissions(); - - const auto & admin_perms = permissions.at(UserRole::ADMIN); - // Admin is covered by PUT:/api/v1/** - no specific status entries needed - EXPECT_TRUE(admin_perms.count("PUT:/api/v1/**") > 0); - EXPECT_TRUE(admin_perms.count("PUT:/api/v1/apps/*/status/shutdown") == 0); - EXPECT_TRUE(admin_perms.count("PUT:/api/v1/components/*/status/force-shutdown") == 0); + ASSERT_EQ(permissions.size(), 1u); + EXPECT_EQ(permissions.at(UserRole::ADMIN).size(), 4u); } // ============================================================================= diff --git a/src/ros2_medkit_gateway/test/test_auth_manager.cpp b/src/ros2_medkit_gateway/test/test_auth_manager.cpp index 8cb883ff5..aad73dd5d 100644 --- a/src/ros2_medkit_gateway/test/test_auth_manager.cpp +++ b/src/ros2_medkit_gateway/test/test_auth_manager.cpp @@ -21,6 +21,49 @@ using namespace ros2_medkit_gateway; +namespace { + +/// A stand-in for the shipped permission table, and honestly a stand-in. +/// +/// The real table is derived from the route registrations and installed by +/// `RESTServer::setup_routes()`; an `AuthManager` built on its own has none and +/// fails closed. What the authorization tests below exercise is the *matcher* - +/// exact hit, single-segment `*`, multi-segment `**`, and the no-match refusal +/// - so they need a table shaped like the derived one rather than the derived +/// one itself: roles expanded upward, because `AuthConfig` has no inheritance. +/// +/// It says nothing about whether the shipped table grants the right thing on +/// the right path. No hand-written table can answer that, since it would be the +/// same author restating the same belief twice. The gateway's real table is +/// checked against the gateway's real enforcement in +/// `test_rbac_contract.test.py`. +RoutePermissions matcher_fixture_permissions() { + const std::unordered_set viewer = { + "GET:/api/v1/components", + "GET:/api/v1/components/*/data", + "GET:/api/v1/areas", + }; + const std::unordered_set operator_only = { + "POST:/api/v1/components/*/operations/*/executions", + "DELETE:/api/v1/components/*/faults/*", + "PUT:/api/v1/components/*/data/*", + }; + const std::unordered_set configurator_only = { + "PUT:/api/v1/components/*/configurations/*", + "DELETE:/api/v1/components/*/configurations/*", + }; + + RoutePermissions permissions; + permissions[UserRole::VIEWER] = viewer; + permissions[UserRole::OPERATOR] = viewer; + permissions[UserRole::OPERATOR].insert(operator_only.begin(), operator_only.end()); + permissions[UserRole::CONFIGURATOR] = permissions[UserRole::OPERATOR]; + permissions[UserRole::CONFIGURATOR].insert(configurator_only.begin(), configurator_only.end()); + return permissions; +} + +} // namespace + // Test fixture for AuthManager tests // @verifies REQ_INTEROP_086, REQ_INTEROP_087 class AuthManagerTest : public ::testing::Test { @@ -42,6 +85,11 @@ class AuthManagerTest : public ::testing::Test { .build(); auth_manager_ = std::make_unique(config_); + // ADMIN's entries come from the residual list, which is where they live on + // a running gateway too - `**` per method, covering the routes the registry + // never sees. + auth_manager_->add_route_permissions(matcher_fixture_permissions()); + auth_manager_->add_route_permissions(AuthConfig::residual_route_permissions()); } AuthConfig config_; @@ -406,6 +454,49 @@ TEST_F(AuthManagerTest, AuthorizeAdminHasFullAccess) { EXPECT_TRUE(result.authorized); } +// @verifies REQ_INTEROP_086 +TEST(AuthManagerPermissionTableTest, AManagerWithNoTableAuthorizesNothing) { + // The fail-closed property the whole derivation rests on. `RESTServer` feeds + // the table before the server listens; a manager that never got one must + // refuse rather than fall back to some built-in default, because a default + // would be a second source for every grant. + AuthConfig config = AuthConfigBuilder() + .with_enabled(true) + .with_jwt_secret("test_secret_key_min_32_chars_empty") + .with_token_expiry(3600) + .with_refresh_token_expiry(86400) + .build(); + AuthManager manager(config); + + for (UserRole role : {UserRole::VIEWER, UserRole::OPERATOR, UserRole::CONFIGURATOR, UserRole::ADMIN}) { + auto result = manager.check_authorization(role, "GET", "/api/v1/health"); + EXPECT_FALSE(result.authorized) << "role " << static_cast(role); + } +} + +// @verifies REQ_INTEROP_086 +TEST(AuthManagerPermissionTableTest, AddedPermissionsMergeRatherThanReplace) { + // `RESTServer` calls this twice - once with the registry's derivation, once + // with the residual list - so the second call must not erase the first. + AuthConfig config = AuthConfigBuilder() + .with_enabled(true) + .with_jwt_secret("test_secret_key_min_32_chars_merge") + .with_token_expiry(3600) + .with_refresh_token_expiry(86400) + .build(); + AuthManager manager(config); + + RoutePermissions first; + first[UserRole::VIEWER] = {"GET:/api/v1/health"}; + RoutePermissions second; + second[UserRole::VIEWER] = {"GET:/api/v1/version-info"}; + manager.add_route_permissions(first); + manager.add_route_permissions(second); + + EXPECT_TRUE(manager.check_authorization(UserRole::VIEWER, "GET", "/api/v1/health").authorized); + EXPECT_TRUE(manager.check_authorization(UserRole::VIEWER, "GET", "/api/v1/version-info").authorized); +} + // Test auth requirement checking TEST_F(AuthManagerTest, RequiresAuthForWriteOnly) { EXPECT_FALSE(auth_manager_->requires_authentication("GET", "/api/v1/components")); @@ -857,6 +948,12 @@ class AuthMiddlewareTest : public ::testing::Test { .build(); auth_manager_ = std::make_unique(config_); + // The middleware delegates the RBAC decision to the manager, and a manager + // with no table refuses everything - see matcher_fixture_permissions(). + // ADMIN's grant comes from the residual list, which is where the token + // these tests use gets its reach on a running gateway too. + auth_manager_->add_route_permissions(matcher_fixture_permissions()); + auth_manager_->add_route_permissions(AuthConfig::residual_route_permissions()); middleware_ = std::make_unique(config_, auth_manager_.get()); } diff --git a/src/ros2_medkit_gateway/test/test_capability_generator.cpp b/src/ros2_medkit_gateway/test/test_capability_generator.cpp index f6baba27b..8c34384bb 100644 --- a/src/ros2_medkit_gateway/test/test_capability_generator.cpp +++ b/src/ros2_medkit_gateway/test/test_capability_generator.cpp @@ -54,8 +54,19 @@ void seed_get_no_summary(RouteRegistry & reg, const std::string & path, const st reg.get(path, std::move(h)).tag(tag); } +/// A route whose only outcome is 501, the shape `/data-categories` and +/// `/data-groups` are registered with in `rest_server.cpp`. +void seed_get_501(RouteRegistry & reg, const std::string & path, const std::string & tag) { + std::function(http::TypedRequest)> h = &noop_cap_handler; + reg.get(path, std::move(h)).tag(tag).only_status(501, "Not implemented for ROS 2"); +} + // Populate a RouteRegistry with representative routes matching what the real -// gateway registers, so that generate_root() produces the paths the tests expect. +// gateway registers. The sub-documents are a projection of this registry, so +// what it holds is what the tests below can observe - a collection missing +// here is missing from every sub-document, which is the property the +// `EntityDocumentOffersNoCollectionWithoutARoute` case turns into an +// assertion. void populate_test_routes(RouteRegistry & reg) { seed_get(reg, "/health", "Server", "Health check"); seed_get(reg, "/", "Server", "API overview"); @@ -74,11 +85,17 @@ void populate_test_routes(RouteRegistry & reg) { seed_get(reg, entity_path, "Discovery", std::string("Get ") + singular); seed_get_no_summary(reg, entity_path + "/data", "Data"); seed_get_no_summary(reg, entity_path + "/data/{data_id}", "Data"); + seed_get_501(reg, entity_path + "/data-categories", "Data"); + seed_get_501(reg, entity_path + "/data-groups", "Data"); seed_get_no_summary(reg, entity_path + "/operations", "Operations"); + seed_get_no_summary(reg, entity_path + "/operations/{operation_id}", "Operations"); seed_get_no_summary(reg, entity_path + "/configurations", "Configuration"); seed_get_no_summary(reg, entity_path + "/faults", "Faults"); + seed_get_no_summary(reg, entity_path + "/faults/{fault_code}", "Faults"); seed_get_no_summary(reg, entity_path + "/logs", "Logs"); + seed_get_no_summary(reg, entity_path + "/logs/configuration", "Logs"); seed_get_no_summary(reg, entity_path + "/bulk-data", "Bulk Data"); + seed_get_no_summary(reg, entity_path + "/triggers", "Triggers"); seed_get_no_summary(reg, entity_path + "/cyclic-subscriptions", "Subscriptions"); } @@ -86,6 +103,13 @@ void populate_test_routes(RouteRegistry & reg) { seed_get(reg, "/faults/stream", "Faults", "Stream fault events (SSE)"); } +/// The id of some component the running node discovered, or an empty string. +std::string first_component_id(const GatewayNode & node) { + const auto & cache = node.get_thread_safe_cache(); + auto components = cache.get_components(); + return components.empty() ? std::string{} : components.front().id; +} + } // namespace // ============================================================================= @@ -330,21 +354,16 @@ TEST_F(CapabilityGeneratorTest, GenerateNonexistentComponentReturnsNullopt) { // The entity sub-document is the second surface that advertises an entity's // collections - the `capabilities` array on the detail response is the first. -// It is driven by `EntityCapabilities::for_type`, and `/data-lists`, `/modes` -// and `/updates` used to reach it and be published with a fabricated 200: no -// entity type registers them. // -// What this pins is the generator's own half of the guarantee. The switch in -// `add_resource_collection_paths` is exhaustive and gives the four unrouted -// collections an empty arm, so even a capability list that named one again -// would produce no path here - `EntityCapabilities.NoEntityAdvertises...` -// covers the list itself, and the two together are what keep both surfaces -// clean. +// Since the sub-document became a projection of the routes the gateway +// registers, the guarantee is structural rather than a matter of keeping two +// lists agreeing: a collection appears here exactly when a route answers it. +// `/data-lists`, `/modes`, `/updates` and `/communication-logs` used to be +// published with a fabricated 200 while no route served any of them. TEST_F(CapabilityGeneratorTest, EntityDocumentOffersNoCollectionWithoutARoute) { - const auto & cache = node_->get_thread_safe_cache(); - auto components = cache.get_components(); - ASSERT_FALSE(components.empty()) << "no component discovered - the assertions below would prove nothing"; - const std::string entity_path = "/components/" + components.front().id; + const std::string component_id = first_component_id(*node_); + ASSERT_FALSE(component_id.empty()) << "no component discovered - the assertions below would prove nothing"; + const std::string entity_path = "/components/" + component_id; auto result = generator_->generate(entity_path); ASSERT_TRUE(result.has_value()); @@ -352,7 +371,7 @@ TEST_F(CapabilityGeneratorTest, EntityDocumentOffersNoCollectionWithoutARoute) { for (const auto * phantom : {"/data-lists", "/modes", "/updates", "/communication-logs"}) { EXPECT_FALSE(result->at("paths").contains(entity_path + phantom)) << phantom; } - // ... while the collections the loop does register are still offered. + // ... while every collection the registry does hold is offered. for (const auto * served : {"/data", "/data-categories", "/data-groups", "/operations", "/configurations", "/faults", "/logs", "/bulk-data", "/triggers", "/cyclic-subscriptions"}) { EXPECT_TRUE(result->at("paths").contains(entity_path + served)) << served; @@ -360,22 +379,131 @@ TEST_F(CapabilityGeneratorTest, EntityDocumentOffersNoCollectionWithoutARoute) { } // data-categories and data-groups are registered with `.only_status(501, ...)`, -// so the sub-document has to say 501 and nothing else. The generic listing it -// used to fall through to declared a 200 no client can ever observe. -TEST_F(CapabilityGeneratorTest, NotImplementedCollectionsDeclareOnly501) { - const auto & cache = node_->get_thread_safe_cache(); - auto components = cache.get_components(); - ASSERT_FALSE(components.empty()) << "no component discovered - the assertions below would prove nothing"; - const std::string entity_path = "/components/" + components.front().id; +// so the sub-document must not offer a success a client can never observe. It +// used to fall through to a generic listing that declared a 200. +// +// The response set is not asserted to be *only* 501: the framework declares +// 416 on every operation, `only_status` or not, because cpp-httplib answers an +// unparseable `Range` before any handler runs. What must be absent is a 2xx. +TEST_F(CapabilityGeneratorTest, NotImplementedCollectionsDeclareNoSuccess) { + const std::string component_id = first_component_id(*node_); + ASSERT_FALSE(component_id.empty()) << "no component discovered - the assertions below would prove nothing"; + const std::string entity_path = "/components/" + component_id; auto result = generator_->generate(entity_path); ASSERT_TRUE(result.has_value()); for (const auto * col : {"/data-categories", "/data-groups"}) { - const auto & op = result->at("paths").at(entity_path + col).at("get"); - EXPECT_EQ(op.at("responses").size(), 1u) << col; - EXPECT_TRUE(op.at("responses").contains("501")) << col; - EXPECT_EQ(op.at("responses").at("501").at("$ref"), "#/components/responses/GenericError") << col; + const auto & responses = result->at("paths").at(entity_path + col).at("get").at("responses"); + EXPECT_TRUE(responses.contains("501")) << col; + for (auto it = responses.begin(); it != responses.end(); ++it) { + EXPECT_FALSE(!it.key().empty() && it.key().front() == '2') << col << " declares success " << it.key(); + } + } +} + +// Substituting the id into the path key without removing the parameter that +// described it publishes a parameter the path no longer has - a document a +// strict validator rejects and a generated client builds a call signature +// from. +TEST_F(CapabilityGeneratorTest, EntityDocumentDropsTheParameterItSubstituted) { + const std::string component_id = first_component_id(*node_); + ASSERT_FALSE(component_id.empty()) << "no component discovered - the assertions below would prove nothing"; + const std::string entity_path = "/components/" + component_id; + + auto result = generator_->generate(entity_path); + ASSERT_TRUE(result.has_value()); + ASSERT_TRUE(result->at("paths").contains(entity_path)); + + for (const auto & [path, item] : result->at("paths").items()) { + EXPECT_EQ(path.find("{component_id}"), std::string::npos) << path; + for (const auto & [method, operation] : item.items()) { + if (!operation.is_object() || !operation.contains("parameters")) { + continue; + } + for (const auto & param : operation.at("parameters")) { + EXPECT_FALSE(param.at("in") == "path" && param.at("name") == "component_id") << path << " " << method; + } + } + } + // The parameters a substitution did not answer stay: the data item route + // still templates `{data_id}` and still declares it. + const auto & item = result->at("paths").at(entity_path + "/data/{data_id}").at("get"); + ASSERT_TRUE(item.contains("parameters")); + bool declares_data_id = false; + for (const auto & param : item.at("parameters")) { + declares_data_id = declares_data_id || (param.at("in") == "path" && param.at("name") == "data_id"); + } + EXPECT_TRUE(declares_data_id); +} + +// A prefix match on the string would let `/data-groups` and `/data-categories` +// answer under `/data`, so the data collection's sub-document would describe +// two collections it is not about. +TEST_F(CapabilityGeneratorTest, ResourceCollectionDocumentStopsAtTheSegmentBoundary) { + const std::string component_id = first_component_id(*node_); + ASSERT_FALSE(component_id.empty()) << "no component discovered - the assertions below would prove nothing"; + const std::string entity_path = "/components/" + component_id; + + auto result = generator_->generate(entity_path + "/data"); + ASSERT_TRUE(result.has_value()); + + const auto & paths = result->at("paths"); + EXPECT_TRUE(paths.contains(entity_path + "/data")); + EXPECT_TRUE(paths.contains(entity_path + "/data/{data_id}")); + EXPECT_FALSE(paths.contains(entity_path + "/data-groups")); + EXPECT_FALSE(paths.contains(entity_path + "/data-categories")); +} + +// A sub-document that names a schema it does not carry publishes a `$ref` no +// client can resolve. Every one of these documents used to do exactly that. +// +// Rooted at the whole document, not at `paths`. `referenced_schemas()` walks +// only `paths`, so a chain that leaves through `components/responses` is +// precisely the case a paths-rooted check could not see - the one this is named +// for. It happens to be safe today (the five response components +// `OpenApiSpecBuilder` always emits name only `GenericError` and `OAuth2Error`, +// which it always backfills), but "happens to be safe" is what the walk is here +// to stop depending on. +TEST_F(CapabilityGeneratorTest, SubDocumentCarriesTheSchemasItReferences) { + const std::string component_id = first_component_id(*node_); + ASSERT_FALSE(component_id.empty()) << "no component discovered - the assertions below would prove nothing"; + const std::string entity_path = "/components/" + component_id; + + for (const auto & sub_path : {std::string("/components"), entity_path, entity_path + "/faults", entity_path + "/logs", + entity_path + "/data"}) { + auto result = generator_->generate(sub_path); + ASSERT_TRUE(result.has_value()) << sub_path; + + size_t refs_seen = 0; + std::function check = [&](const nlohmann::json & node) { + if (node.is_array()) { + for (const auto & element : node) { + check(element); + } + return; + } + if (!node.is_object()) { + return; + } + for (const auto & [key, value] : node.items()) { + static const std::string kPrefix = "#/components/"; + if (key == "$ref" && value.is_string() && value.get_ref().rfind(kPrefix, 0) == 0) { + const std::string & ref = value.get_ref(); + const auto slash = ref.rfind('/'); + const std::string section = ref.substr(kPrefix.size(), slash - kPrefix.size()); + ++refs_seen; + EXPECT_TRUE(result->at("components").contains(section) && + result->at("components").at(section).contains(ref.substr(slash + 1))) + << sub_path << " -> " << ref; + continue; + } + check(value); + } + }; + check(*result); + // A document whose walk found nothing would pass vacuously. + EXPECT_GT(refs_seen, 0u) << sub_path; } } @@ -592,6 +720,92 @@ TEST_F(CapabilityGeneratorTest, RepeatedCacheHitsDoNotGrow) { auto result = generator_->generate(path); ASSERT_TRUE(result.has_value()) << "Failed to generate spec for: " << path; } + // Four distinct paths were requested, so four entries is the whole cache. + EXPECT_EQ(generator_->cache_entry_count(), 4u); +} + +// What the cache holds is the serialized document, and the byte total it +// bounds itself with has to agree with that. Asserting the total equals the +// summed key+text lengths is what makes `cache_byte_size` a measurement +// rather than a counter that happens to go up. +TEST_F(CapabilityGeneratorTest, CacheAccountsForTheBytesItHolds) { + const std::vector paths = {"/", "/areas", "/apps"}; + size_t serialized = 0; + for (const auto & path : paths) { + auto document = generator_->generate_serialized(path); + ASSERT_TRUE(document.has_value()) << "Failed to generate document for: " << path; + serialized += document->size(); + } + ASSERT_EQ(generator_->cache_entry_count(), paths.size()); + + // Each key is ":", so the accounted total is the documents + // plus those keys - never less than the documents alone. + EXPECT_GT(generator_->cache_byte_size(), serialized); + size_t keys = 0; + for (const auto & path : paths) { + keys += std::to_string(node_->get_thread_safe_cache().generation()).size() + 1 + path.size(); + } + EXPECT_EQ(generator_->cache_byte_size(), serialized + keys); +} + +// The bound the entry count could not give: with documents this size, 256 +// entries is tens of megabytes. Driven through the overridable budget rather +// than by generating 16 MiB of documents. +TEST_F(CapabilityGeneratorTest, ByteBudgetEvictsBeforeTheEntryCountWould) { + auto first = generator_->generate_serialized("/areas"); + ASSERT_TRUE(first.has_value()); + + // A budget that fits one document of this size but not two. + openapi::DocsCacheBounds bounds; + bounds.max_bytes = first->size() + 64; + CapabilityGenerator bounded(*ctx_, *node_, node_->get_plugin_manager(), route_registry_.get(), bounds); + + ASSERT_TRUE(bounded.generate_serialized("/areas").has_value()); + ASSERT_EQ(bounded.cache_entry_count(), 1u); + + ASSERT_TRUE(bounded.generate_serialized("/apps").has_value()); + // Well under kDocsCacheMaxEntries, so only the byte budget can have evicted. + EXPECT_EQ(bounded.cache_entry_count(), 1u); + EXPECT_LE(bounded.cache_byte_size(), bounds.max_bytes); +} + +// A single document larger than the whole budget is served but not stored - +// caching it would hold the cache over its bound indefinitely. +TEST_F(CapabilityGeneratorTest, DocumentLargerThanTheBudgetIsServedUncached) { + openapi::DocsCacheBounds bounds; + bounds.max_bytes = 16; + CapabilityGenerator bounded(*ctx_, *node_, node_->get_plugin_manager(), route_registry_.get(), bounds); + + auto document = bounded.generate_serialized("/areas"); + ASSERT_TRUE(document.has_value()); + EXPECT_GT(document->size(), bounds.max_bytes); + EXPECT_EQ(bounded.cache_entry_count(), 0u); + EXPECT_EQ(bounded.cache_byte_size(), 0u); +} + +// The entry count is the other half of the bound, and it still has to bite. +TEST_F(CapabilityGeneratorTest, EntryCountEvictsWhenTheByteBudgetIsSlack) { + openapi::DocsCacheBounds bounds; + bounds.max_entries = 2; + CapabilityGenerator bounded(*ctx_, *node_, node_->get_plugin_manager(), route_registry_.get(), bounds); + + ASSERT_TRUE(bounded.generate_serialized("/areas").has_value()); + ASSERT_TRUE(bounded.generate_serialized("/apps").has_value()); + ASSERT_EQ(bounded.cache_entry_count(), 2u); + + // Third distinct path trips the clear-all, leaving just the new entry. + ASSERT_TRUE(bounded.generate_serialized("/components").has_value()); + EXPECT_EQ(bounded.cache_entry_count(), 1u); + EXPECT_LT(bounded.cache_byte_size(), bounds.max_bytes); +} + +// The parsed accessor must describe the same document the routes serve. +TEST_F(CapabilityGeneratorTest, SerializedAndParsedFormsAgree) { + auto document = generator_->generate_serialized("/apps"); + auto parsed = generator_->generate("/apps"); + ASSERT_TRUE(document.has_value()); + ASSERT_TRUE(parsed.has_value()); + EXPECT_EQ(*document, parsed->dump(2)); } // ============================================================================= diff --git a/src/ros2_medkit_gateway/test/test_discovery_handlers.cpp b/src/ros2_medkit_gateway/test/test_discovery_handlers.cpp index a9ab720b4..7e525fb24 100644 --- a/src/ros2_medkit_gateway/test/test_discovery_handlers.cpp +++ b/src/ros2_medkit_gateway/test/test_discovery_handlers.cpp @@ -1082,14 +1082,24 @@ TEST_F(DiscoveryHandlersFixtureTest, GetFunctionUnknownIdReturns404) { } // @verifies REQ_INTEROP_003 -TEST_F(DiscoveryHandlersFixtureTest, GetFunctionReturnsCapabilitiesAndGraphLink) { +// The `x-medkit-graph` link is emitted off the entity's capability list, and +// nothing in the gateway puts that capability there - only a plugin does. This +// fixture loads none, so the key must be absent: it used to be written +// unconditionally, which published a URI that answered 404 on every gateway +// running without the graph provider. The other half of the pair - the link +// present, and equal to the capability href, with the plugin loaded - is +// `test_graph_provider_plugin.test.py::test_01_function_detail_includes_graph_capability`, +// because registering a plugin capability needs a real loaded plugin. +// +// @verifies REQ_INTEROP_003 +TEST_F(DiscoveryHandlersFixtureTest, GetFunctionOmitsGraphLinkWithoutAPlugin) { httplib::Request req; auto typed_req = make_typed_request(req, "/api/v1/functions/navigation", R"(/api/v1/functions/([^/]+))"); auto result = handlers_->get_function(typed_req); auto body = body_json(result); EXPECT_EQ(body["hosts"], "/api/v1/functions/navigation/hosts"); - EXPECT_EQ(body["x-medkit-graph"], "/api/v1/functions/navigation/x-medkit-graph"); + EXPECT_FALSE(body.contains("x-medkit-graph")); EXPECT_EQ(body["_links"]["self"], "/api/v1/functions/navigation"); EXPECT_EQ(body["x-medkit"]["source"], "manifest"); } diff --git a/src/ros2_medkit_gateway/test/test_docs_handlers.cpp b/src/ros2_medkit_gateway/test/test_docs_handlers.cpp index 571ed0210..80f8ff910 100644 --- a/src/ros2_medkit_gateway/test/test_docs_handlers.cpp +++ b/src/ros2_medkit_gateway/test/test_docs_handlers.cpp @@ -112,14 +112,11 @@ TEST_F(DocsHandlersTest, DocsDisabledReturns501) { handlers::DocsHandlers docs_handlers(*ctx_, *node_, node_->get_plugin_manager(), route_registry_.get()); httplib::Request req; - httplib::Response res; - - docs_handlers.handle_docs_root(req, res); + auto result = docs_handlers.handle_docs_root(http::TypedRequest(req)); - EXPECT_EQ(res.status, 501); - - auto body = nlohmann::json::parse(res.body); - EXPECT_TRUE(body.contains("error_code")); + ASSERT_FALSE(result.has_value()); + EXPECT_EQ(result.error().http_status, 501); + EXPECT_FALSE(result.error().code.empty()); } // ============================================================================= @@ -131,14 +128,10 @@ TEST_F(DocsHandlersTest, DocsRootReturnsValidJson) { handlers::DocsHandlers docs_handlers(*ctx_, *node_, node_->get_plugin_manager(), route_registry_.get()); httplib::Request req; - httplib::Response res; + auto result = docs_handlers.handle_docs_root(http::TypedRequest(req)); - docs_handlers.handle_docs_root(req, res); - - // send_json does not set res.status (httplib server framework does that), - // so verify the response body contains a valid OpenAPI spec - ASSERT_FALSE(res.body.empty()); - auto body = nlohmann::json::parse(res.body); + ASSERT_TRUE(result.has_value()) << result.error().message; + auto body = nlohmann::json::parse(result.value()); EXPECT_EQ(body["openapi"], "3.1.0"); EXPECT_TRUE(body.contains("info")); EXPECT_TRUE(body.contains("paths")); @@ -150,6 +143,43 @@ TEST_F(DocsHandlersTest, DocsRootReturnsValidJson) { EXPECT_TRUE(body["paths"].contains("/apps")); } +// The handler answers with text now, and `docs_endpoint` writes that text +// straight out instead of dumping a DOM. That is only wire-compatible while +// the text is exactly what `write_json_body` would have produced, which is +// `dump(2)`. Re-dumping the parse and comparing is the mechanical check: +// indent width, key order and separators all have to agree. +TEST_F(DocsHandlersTest, DocsRootBodyIsTheSameBytesADomWouldHaveWritten) { + handlers::DocsHandlers docs_handlers(*ctx_, *node_, node_->get_plugin_manager(), route_registry_.get()); + + httplib::Request req; + auto result = docs_handlers.handle_docs_root(http::TypedRequest(req)); + + ASSERT_TRUE(result.has_value()) << result.error().message; + EXPECT_EQ(result.value(), nlohmann::json::parse(result.value()).dump(2)); +} + +// Same contract on the scoped route, which reaches the response through +// `write_json_text` rather than through the typed router. +TEST_F(DocsHandlersTest, DocsAnyPathBodyIsTheSameBytesADomWouldHaveWritten) { + handlers::DocsHandlers docs_handlers(*ctx_, *node_, node_->get_plugin_manager(), route_registry_.get()); + + httplib::Request req; + httplib::Response res; + req.path = "/api/v1/apps/docs"; + std::regex pattern(R"(/api/v1/(.*)/docs)"); + std::smatch match; + std::regex_match(req.path, match, pattern); + req.matches = match; + + docs_handlers.handle_docs_any_path(req, res); + + ASSERT_EQ(res.status, 200); + EXPECT_EQ(res.body, nlohmann::json::parse(res.body).dump(2)); + // The header a JSON client selects on. Serving pre-serialized text must not + // have moved it. + EXPECT_EQ(res.get_header_value("Content-Type"), "application/json"); +} + // ============================================================================= // Entity collection path returns 200 (happy path) // ============================================================================= diff --git a/src/ros2_medkit_gateway/test/test_path_builder.cpp b/src/ros2_medkit_gateway/test/test_path_builder.cpp index 6a3d44d9c..2a776bbd4 100644 --- a/src/ros2_medkit_gateway/test/test_path_builder.cpp +++ b/src/ros2_medkit_gateway/test/test_path_builder.cpp @@ -15,13 +15,11 @@ #include #include -#include #include "../src/openapi/path_builder.hpp" #include "../src/openapi/schema_builder.hpp" using ros2_medkit_gateway::ActionInfo; -using ros2_medkit_gateway::AggregatedOperations; using ros2_medkit_gateway::ServiceInfo; using ros2_medkit_gateway::TopicData; using ros2_medkit_gateway::openapi::PathBuilder; @@ -33,117 +31,6 @@ class PathBuilderTest : public ::testing::Test { PathBuilder path_builder_{schema_builder_}; }; -// ============================================================================= -// Entity collection tests -// ============================================================================= - -TEST_F(PathBuilderTest, EntityCollectionHasGet) { - // @verifies REQ_INTEROP_002 - auto result = path_builder_.build_entity_collection("apps"); - ASSERT_TRUE(result.contains("get")); - EXPECT_TRUE(result["get"].contains("summary")); - EXPECT_TRUE(result["get"].contains("responses")); - EXPECT_TRUE(result["get"]["responses"].contains("200")); -} - -TEST_F(PathBuilderTest, EntityCollectionHasItemsSchema) { - // The response schema is now a $ref to the DTO-generated collection schema. - auto result = path_builder_.build_entity_collection("components"); - auto schema = result["get"]["responses"]["200"]["content"]["application/json"]["schema"]; - ASSERT_TRUE(schema.contains("$ref")) << "Entity collection schema should be a $ref to the DTO collection type"; - EXPECT_EQ(schema["$ref"], "#/components/schemas/ComponentList"); -} - -TEST_F(PathBuilderTest, EntityCollectionHasQueryParams) { - auto result = path_builder_.build_entity_collection("areas"); - ASSERT_TRUE(result["get"].contains("parameters")); - auto & params = result["get"]["parameters"]; - ASSERT_GE(params.size(), 2u); - - // Check limit and offset parameters exist - bool has_limit = false; - bool has_offset = false; - for (const auto & p : params) { - if (p["name"] == "limit") { - has_limit = true; - } - if (p["name"] == "offset") { - has_offset = true; - } - } - EXPECT_TRUE(has_limit); - EXPECT_TRUE(has_offset); -} - -TEST_F(PathBuilderTest, EntityCollectionHasErrorResponses) { - auto result = path_builder_.build_entity_collection("apps"); - EXPECT_TRUE(result["get"]["responses"].contains("400")); - EXPECT_TRUE(result["get"]["responses"].contains("404")); - EXPECT_TRUE(result["get"]["responses"].contains("500")); -} - -// ============================================================================= -// Entity detail tests -// ============================================================================= - -TEST_F(PathBuilderTest, EntityDetailHasGet) { - // @verifies REQ_INTEROP_002 - auto result = path_builder_.build_entity_detail("apps"); - ASSERT_TRUE(result.contains("get")); - EXPECT_TRUE(result["get"].contains("summary")); - EXPECT_TRUE(result["get"]["responses"].contains("200")); -} - -TEST_F(PathBuilderTest, EntityDetailHasPathParam) { - auto result = path_builder_.build_entity_detail("components"); - ASSERT_TRUE(result["get"].contains("parameters")); - auto & params = result["get"]["parameters"]; - ASSERT_GE(params.size(), 1u); - EXPECT_EQ(params[0]["in"], "path"); - EXPECT_TRUE(params[0]["required"].get()); -} - -TEST_F(PathBuilderTest, EntityDetailConcretePathOmitsParameters) { - // @verifies REQ_INTEROP_002 - // When use_template=false (concrete entity path), no path parameters should be declared. - // OpenAPI 3.1.0 requires path params to match {placeholders} in the path key. - auto result = path_builder_.build_entity_detail("apps", false); - ASSERT_TRUE(result.contains("get")); - EXPECT_FALSE(result["get"].contains("parameters")) << "Concrete entity path should not declare path parameters"; - EXPECT_TRUE(result["get"].contains("summary")); - EXPECT_TRUE(result["get"]["responses"].contains("200")); -} - -TEST_F(PathBuilderTest, EntityDetailTemplatePathHasParameters) { - // @verifies REQ_INTEROP_002 - // Default (use_template=true) should still include path parameters - auto result = path_builder_.build_entity_detail("apps", true); - ASSERT_TRUE(result["get"].contains("parameters")); - EXPECT_EQ(result["get"]["parameters"][0]["name"], "app_id"); - EXPECT_EQ(result["get"]["parameters"][0]["in"], "path"); - EXPECT_TRUE(result["get"]["parameters"][0]["required"].get()); -} - -// ============================================================================= -// Data collection tests -// ============================================================================= - -TEST_F(PathBuilderTest, DataCollectionHasGet) { - // @verifies REQ_INTEROP_002 - std::vector topics = {{"temperature", "std_msgs/msg/Float32", "publish"}, - {"command", "std_msgs/msg/String", "subscribe"}}; - auto result = path_builder_.build_data_collection("apps/sensor", topics); - ASSERT_TRUE(result.contains("get")); - EXPECT_TRUE(result["get"]["responses"].contains("200")); -} - -TEST_F(PathBuilderTest, DataCollectionHasSovdExtension) { - std::vector topics; - auto result = path_builder_.build_data_collection("apps/sensor", topics); - EXPECT_TRUE(result.contains("x-sovd-data-category")); - EXPECT_EQ(result["x-sovd-data-category"], "currentData"); -} - // ============================================================================= // Data item tests // ============================================================================= @@ -196,29 +83,6 @@ TEST_F(PathBuilderTest, DataItemSchemaFromRosType) { EXPECT_TRUE(schema.contains("properties")); } -// ============================================================================= -// Operations collection tests -// ============================================================================= - -TEST_F(PathBuilderTest, OperationsCollectionHasGet) { - // @verifies REQ_INTEROP_002 - AggregatedOperations ops; - ops.services.push_back({"calibrate", "/engine/calibrate", "std_srvs/srv/Trigger", std::nullopt}); - auto result = path_builder_.build_operations_collection("apps/engine", ops); - ASSERT_TRUE(result.contains("get")); - EXPECT_TRUE(result["get"]["responses"].contains("200")); -} - -TEST_F(PathBuilderTest, OperationsCollectionResponseRefersToOperationList) { - // build_operations_collection now uses SchemaBuilder::ref("OperationList") - a $ref to - // the DTO-generated Collection schema. - AggregatedOperations ops; - auto result = path_builder_.build_operations_collection("apps/engine", ops); - auto schema = result["get"]["responses"]["200"]["content"]["application/json"]["schema"]; - ASSERT_TRUE(schema.contains("$ref")); - EXPECT_EQ(schema["$ref"], "#/components/schemas/OperationList"); -} - // ============================================================================= // Operation item (service) tests // ============================================================================= @@ -281,153 +145,6 @@ TEST_F(PathBuilderTest, ActionOperationHasSovdName) { EXPECT_EQ(result["x-sovd-name"], "navigate"); } -// ============================================================================= -// Configurations collection tests -// ============================================================================= - -TEST_F(PathBuilderTest, ConfigurationsHasGetAndDelete) { - // @verifies REQ_INTEROP_002 - auto result = path_builder_.build_configurations_collection("apps/sensor"); - ASSERT_TRUE(result.contains("get")); - ASSERT_TRUE(result.contains("delete")); - EXPECT_FALSE(result.contains("put")); -} - -TEST_F(PathBuilderTest, ConfigurationsGetReturnsConfigurationListRef) { - // build_configurations_collection now emits a $ref to ConfigurationList DTO schema. - auto result = path_builder_.build_configurations_collection("apps/sensor"); - auto schema = result["get"]["responses"]["200"]["content"]["application/json"]["schema"]; - ASSERT_TRUE(schema.contains("$ref")); - EXPECT_EQ(schema["$ref"], "#/components/schemas/ConfigurationList"); -} - -TEST_F(PathBuilderTest, ConfigurationsDeleteHasSummary) { - auto result = path_builder_.build_configurations_collection("apps/sensor"); - EXPECT_EQ(result["delete"]["summary"], "Delete all configuration parameters"); -} - -TEST_F(PathBuilderTest, ConfigurationsDeleteReturns204And207) { - auto result = path_builder_.build_configurations_collection("apps/sensor"); - ASSERT_TRUE(result["delete"]["responses"].contains("204")); - ASSERT_TRUE(result["delete"]["responses"].contains("207")); - EXPECT_FALSE(result["delete"]["responses"].contains("200")); -} - -// ============================================================================= -// Faults collection tests -// ============================================================================= - -TEST_F(PathBuilderTest, FaultsHasGetAndDelete) { - // @verifies REQ_INTEROP_002 - auto result = path_builder_.build_faults_collection("apps/engine"); - ASSERT_TRUE(result.contains("get")); - ASSERT_TRUE(result.contains("delete")); - EXPECT_FALSE(result.contains("put")); -} - -TEST_F(PathBuilderTest, FaultsGetReturnsFaultList) { - // build_faults_collection now emits a $ref to the registered FaultList DTO schema. - auto result = path_builder_.build_faults_collection("apps/engine"); - auto schema = result["get"]["responses"]["200"]["content"]["application/json"]["schema"]; - // The schema is a $ref to FaultList, not an inline object. - ASSERT_TRUE(schema.contains("$ref")); - EXPECT_EQ(schema["$ref"], "#/components/schemas/FaultList"); -} - -TEST_F(PathBuilderTest, FaultsDeleteReturns204) { - auto result = path_builder_.build_faults_collection("apps/engine"); - ASSERT_TRUE(result["delete"]["responses"].contains("204")); -} - -// ============================================================================= -// Logs collection tests -// ============================================================================= - -TEST_F(PathBuilderTest, LogsHasGet) { - // @verifies REQ_INTEROP_002 - auto result = path_builder_.build_logs_collection("apps/sensor"); - ASSERT_TRUE(result.contains("get")); - EXPECT_TRUE(result["get"]["responses"].contains("200")); -} - -TEST_F(PathBuilderTest, LogsHasLevelQueryParam) { - auto result = path_builder_.build_logs_collection("apps/sensor"); - auto & params = result["get"]["parameters"]; - bool has_level = false; - for (const auto & p : params) { - if (p["name"] == "level") { - has_level = true; - } - } - EXPECT_TRUE(has_level); -} - -TEST_F(PathBuilderTest, LogsReturnsLogEntryListRef) { - // After DTO migration build_logs_collection emits a $ref to LogEntryList. - auto result = path_builder_.build_logs_collection("apps/sensor"); - auto schema = result["get"]["responses"]["200"]["content"]["application/json"]["schema"]; - ASSERT_TRUE(schema.contains("$ref")); - EXPECT_EQ(schema["$ref"], "#/components/schemas/LogEntryList"); -} - -// ============================================================================= -// Bulk data collection tests -// ============================================================================= - -TEST_F(PathBuilderTest, BulkDataHasGet) { - // @verifies REQ_INTEROP_002 - auto result = path_builder_.build_bulk_data_collection("apps/sensor"); - ASSERT_TRUE(result.contains("get")); - EXPECT_TRUE(result["get"]["responses"].contains("200")); -} - -// ============================================================================= -// Cyclic subscriptions collection tests -// ============================================================================= - -TEST_F(PathBuilderTest, CyclicSubscriptionsHasGetAndPost) { - // @verifies REQ_INTEROP_002 - auto result = path_builder_.build_cyclic_subscriptions_collection("apps/sensor"); - ASSERT_TRUE(result.contains("get")); - ASSERT_TRUE(result.contains("post")); -} - -TEST_F(PathBuilderTest, CyclicSubscriptionsPostHasRequestBody) { - // CyclicSubscriptionCreateRequest is now a DTO - request body schema is a $ref. - auto result = path_builder_.build_cyclic_subscriptions_collection("apps/sensor"); - ASSERT_TRUE(result["post"].contains("requestBody")); - auto req_schema = result["post"]["requestBody"]["content"]["application/json"]["schema"]; - ASSERT_TRUE(req_schema.contains("$ref")); - EXPECT_EQ(req_schema["$ref"], "#/components/schemas/CyclicSubscriptionCreateRequest"); -} - -TEST_F(PathBuilderTest, CyclicSubscriptionsPostReturns201) { - auto result = path_builder_.build_cyclic_subscriptions_collection("apps/sensor"); - EXPECT_TRUE(result["post"]["responses"].contains("201")); -} - -// ============================================================================= -// SSE endpoint tests -// ============================================================================= - -TEST_F(PathBuilderTest, SseEndpointHasGet) { - // @verifies REQ_INTEROP_002 - auto result = path_builder_.build_sse_endpoint("/events/faults", "Fault event stream"); - ASSERT_TRUE(result.contains("get")); -} - -TEST_F(PathBuilderTest, SseEndpointHasEventStreamContentType) { - auto result = path_builder_.build_sse_endpoint("/events/faults", "Fault event stream"); - ASSERT_TRUE(result["get"]["responses"].contains("200")); - auto & content = result["get"]["responses"]["200"]["content"]; - ASSERT_TRUE(content.contains("text/event-stream")); -} - -TEST_F(PathBuilderTest, SseEndpointHasDescription) { - auto result = path_builder_.build_sse_endpoint("/events/faults", "Fault event stream"); - EXPECT_EQ(result["get"]["summary"], "Fault event stream"); -} - // ============================================================================= // Error responses tests // ============================================================================= diff --git a/src/ros2_medkit_gateway/test/test_route_descriptions.cpp b/src/ros2_medkit_gateway/test/test_route_descriptions.cpp index 766ec5ff2..03aa514f8 100644 --- a/src/ros2_medkit_gateway/test/test_route_descriptions.cpp +++ b/src/ros2_medkit_gateway/test/test_route_descriptions.cpp @@ -116,3 +116,71 @@ TEST(RouteDescriptionsTest, SchemaTypes) { EXPECT_TRUE(obj["properties"].contains("name")); EXPECT_EQ(obj["required"][0], "name"); } + +// The identity fields the document contract requires of every operation. +// Without them a folded plugin operation fails +// `test_openapi_contract::test_every_operation_is_identified` and +// `test_every_tag_used_is_declared`. +TEST(RouteDescriptionsTest, OperationCarriesTagOperationIdAndRole) { + RouteDescriptionBuilder b; + b.add("/x-medkit-thing/{entity_id}") + .summary("Get thing") + .get(OperationDesc() + .tag("Thing") + .operation_id("getThing") + .requires_role("admin") + .description("Returns the thing.") + .path_param("entity_id", "Entity identifier") + .response(200, SchemaDesc::object().property("id", SchemaDesc::string()), "The thing") + .error_response(404, "GenericError")); + auto json = RouteDescriptionsTestAccess::to_json(b.build()); + auto & get = json["/x-medkit-thing/{entity_id}"]["get"]; + + EXPECT_EQ(get["tags"], nlohmann::json::array({"Thing"})); + EXPECT_EQ(get["operationId"], "getThing"); + EXPECT_EQ(get["summary"], "Get thing"); + EXPECT_EQ(get["description"], "Returns the thing."); + // Role as the scope of a `bearerAuth` requirement - the shape a generated + // client and `test_openapi_contract` both read. + EXPECT_EQ(get["security"], nlohmann::json::array({{{"bearerAuth", nlohmann::json::array({"admin"})}}})); + // An error status is a $ref to the shared component, never an inline body. + EXPECT_EQ(get["responses"]["404"]["$ref"], "#/components/responses/GenericError"); + EXPECT_EQ(get["responses"]["200"]["description"], "The thing"); +} + +// `security: []` is the OpenAPI spelling of "no token needed", and it is +// distinct from declaring no security at all: the latter falls back to the +// document-level requirement. +TEST(RouteDescriptionsTest, PublicRouteDeclaresAnEmptySecurityRequirement) { + RouteDescriptionBuilder b; + b.add("/x-medkit-open").summary("Open").get(OperationDesc().public_route().response(200, SchemaDesc::string())); + auto json = RouteDescriptionsTestAccess::to_json(b.build()); + auto & get = json["/x-medkit-open"]["get"]; + ASSERT_TRUE(get.contains("security")); + EXPECT_TRUE(get["security"].is_array()); + EXPECT_TRUE(get["security"].empty()); +} + +TEST(RouteDescriptionsTest, OperationWithoutASecurityDeclarationOmitsTheKey) { + RouteDescriptionBuilder b; + b.add("/x-medkit-quiet").summary("Quiet").get(OperationDesc().response(200, SchemaDesc::string())); + auto json = RouteDescriptionsTestAccess::to_json(b.build()); + EXPECT_FALSE(json["/x-medkit-quiet"]["get"].contains("security")); +} + +TEST(RouteDescriptionsTest, SchemaDescriptionEnumAndNullability) { + auto described = SchemaDesc::string().description("What it means").to_json(); + EXPECT_EQ(described["description"], "What it means"); + + auto enumerated = SchemaDesc::string().enum_values({"a", "b"}).to_json(); + EXPECT_EQ(enumerated["enum"], nlohmann::json::array({"a", "b"})); + + // `or_null` widens in place, and a description written after it lands on the + // wrapper rather than on the non-null branch - which is where a client that + // does not walk `anyOf` will look. + auto nullable = SchemaDesc::number().or_null().description("Null until measured").to_json(); + EXPECT_FALSE(nullable.contains("type")); + EXPECT_EQ(nullable["anyOf"][0]["type"], "number"); + EXPECT_EQ(nullable["anyOf"][1]["type"], "null"); + EXPECT_EQ(nullable["description"], "Null until measured"); +} diff --git a/src/ros2_medkit_gateway/test/test_route_registry.cpp b/src/ros2_medkit_gateway/test/test_route_registry.cpp index a829ef39e..9b08119db 100644 --- a/src/ros2_medkit_gateway/test/test_route_registry.cpp +++ b/src/ros2_medkit_gateway/test/test_route_registry.cpp @@ -55,6 +55,8 @@ inline constexpr std::string_view dto_name = "RouteReg using namespace ros2_medkit_gateway::openapi; using ros2_medkit_gateway::ErrorInfo; +using ros2_medkit_gateway::RoutePermissions; +using ros2_medkit_gateway::UserRole; using ros2_medkit_gateway::dto::FaultEntityListQuery; using ros2_medkit_gateway::dto::FaultListQuery; using ros2_medkit_gateway::dto::RouteRegistryTestSeedDto; @@ -699,6 +701,7 @@ TEST_F(RouteRegistryTest, DeprecatedFlagAppearsInOutput) { TEST_F(RouteRegistryTest, ValidateCompletenessPassesForCompleteRoute) { seed_get(registry_, "/health") .tag("Server") + .requires_role(UserRole::VIEWER) .summary("Health check") .response(200, "Healthy", json{{"type", "object"}}); @@ -727,7 +730,7 @@ TEST_F(RouteRegistryTest, ValidateCompletenessErrorOnMissingTag) { } TEST_F(RouteRegistryTest, ValidateCompletenessPassesForDeleteWith204) { - seed_del(registry_, "/items/{id}").tag("Items").summary("Delete item"); + seed_del(registry_, "/items/{id}").tag("Items").requires_role(UserRole::OPERATOR).summary("Delete item"); auto issues = registry_.validate_completeness(); int error_count = 0; @@ -740,7 +743,7 @@ TEST_F(RouteRegistryTest, ValidateCompletenessPassesForDeleteWith204) { } TEST_F(RouteRegistryTest, ValidateCompletenessPassesForSSEEndpoint) { - seed_get(registry_, "/events/stream").tag("Events").summary("SSE events stream"); + seed_get(registry_, "/events/stream").tag("Events").requires_role(UserRole::VIEWER).summary("SSE events stream"); auto issues = registry_.validate_completeness(); int error_count = 0; @@ -766,7 +769,7 @@ TEST_F(RouteRegistryTest, ValidateCompletenessWarnsOnMissingSummary) { } TEST_F(RouteRegistryTest, ValidateCompletenessPassesForCompletePostRoute) { - seed_post(registry_, "/items").tag("Items").summary("Create item"); + seed_post(registry_, "/items").tag("Items").requires_role(UserRole::OPERATOR).summary("Create item"); auto issues = registry_.validate_completeness(); int error_count = 0; @@ -822,12 +825,93 @@ TEST_F(RouteRegistryTest, HiddenRouteStillCountedInSize) { EXPECT_EQ(registry_.size(), 2u); } -TEST_F(RouteRegistryTest, HiddenRouteSkippedByValidateCompleteness) { - // Hidden route without required metadata should NOT trigger validation errors +TEST_F(RouteRegistryTest, HiddenRouteSkipsTheDocumentChecksButNotTheRoleCheck) { + // A hidden route publishes nothing, so the document checks have nothing to + // say about it - no tag, no summary and no success schema are defects only in + // a route somebody can read. The role is the exception, and the reason that + // check runs ahead of the hidden skip: hidden removes a route from the + // document, not from the router, so the request still arrives and still meets + // a permission table that would have no entry for it. + seed_post(registry_, "/hidden").requires_role(UserRole::OPERATOR).hidden(); + + EXPECT_TRUE(registry_.validate_completeness().empty()); +} + +// ============================================================================= +// RBAC derivation - one declaration, two consumers +// ============================================================================= + +TEST_F(RouteRegistryTest, DeclaredRoleIsPublishedAsASecurityRequirement) { + seed_get(registry_, "/health").tag("Server").requires_role(UserRole::VIEWER).summary("Health"); + + auto paths = registry_.to_openapi_paths(); + EXPECT_EQ(paths["/health"]["get"]["security"], json::array({{{"bearerAuth", json::array({"viewer"})}}})); +} + +TEST_F(RouteRegistryTest, PublicRoutePublishesTheEmptyRequirement) { + // Not "no requirement": `security: []` is how an operation overrides the + // document-level one. Omitting the key entirely would leave `/auth/token` + // inheriting a demand for the very token it exists to hand out. + seed_post(registry_, "/auth/token").tag("Authentication").public_route().summary("Token"); + + auto paths = registry_.to_openapi_paths(); + EXPECT_EQ(paths["/auth/token"]["post"]["security"], json::array()); +} + +TEST_F(RouteRegistryTest, PermissionEntryIsGrantedToTheRoleAndEveryStrongerOne) { + seed_post(registry_, "/apps/{app_id}/locks").tag("Locking").requires_role(UserRole::OPERATOR).summary("Lock"); + + auto permissions = registry_.route_permissions("/api/v1"); + const std::string entry = "POST:/api/v1/apps/*/locks"; + EXPECT_EQ(permissions[UserRole::OPERATOR].count(entry), 1u); + EXPECT_EQ(permissions[UserRole::CONFIGURATOR].count(entry), 1u); + EXPECT_EQ(permissions[UserRole::ADMIN].count(entry), 1u); + // AuthConfig has no inheritance, so "operator and above" has to mean the + // weaker role is genuinely absent rather than implied. + EXPECT_EQ(permissions[UserRole::VIEWER].count(entry), 0u); +} + +TEST_F(RouteRegistryTest, ASlashSpanningParameterBecomesTheMultiSegmentWildcard) { + // `{data_id}` is a ROS topic name and `{config_id}` a dotted parameter path; + // both compile to `(.+)` in the router, so a single-segment `*` here would + // 403 exactly the requests these routes exist to serve. + seed_get(registry_, "/apps/{app_id}/data/{data_id}").tag("Data").requires_role(UserRole::VIEWER).summary("Item"); + seed_get(registry_, "/apps/{app_id}/operations/{operation_id}") + .tag("Operations") + .requires_role(UserRole::VIEWER) + .summary("Op"); + + auto permissions = registry_.route_permissions("/api/v1"); + EXPECT_EQ(permissions[UserRole::VIEWER].count("GET:/api/v1/apps/*/data/**"), 1u); + EXPECT_EQ(permissions[UserRole::VIEWER].count("GET:/api/v1/apps/*/operations/*"), 1u); +} + +TEST_F(RouteRegistryTest, APublicRouteContributesNoPermissionEntry) { + // The middleware answers `/auth/*` before the table is consulted, so an + // entry would be dead weight on a set that is scanned per request. + seed_post(registry_, "/auth/token").tag("Authentication").public_route().summary("Token"); + + auto permissions = registry_.route_permissions("/api/v1"); + for (UserRole role : {UserRole::VIEWER, UserRole::OPERATOR, UserRole::CONFIGURATOR, UserRole::ADMIN}) { + EXPECT_EQ(permissions[role].size(), 0u) << "role " << static_cast(role); + } +} + +TEST_F(RouteRegistryTest, TheRootRouteIsGrantedWithAndWithoutItsTrailingSlash) { + seed_get(registry_, "/").tag("Server").requires_role(UserRole::VIEWER).summary("Overview"); + + auto permissions = registry_.route_permissions("/api/v1"); + EXPECT_EQ(permissions[UserRole::VIEWER].count("GET:/api/v1"), 1u); + EXPECT_EQ(permissions[UserRole::VIEWER].count("GET:/api/v1/"), 1u); +} + +TEST_F(RouteRegistryTest, HiddenRouteWithNoRoleIsStillReported) { seed_post(registry_, "/hidden").hidden(); auto issues = registry_.validate_completeness(); - EXPECT_TRUE(issues.empty()); + ASSERT_EQ(issues.size(), 1u); + EXPECT_EQ(issues[0].severity, ValidationIssue::Severity::kError); + EXPECT_NE(issues[0].message.find("requires_role"), std::string::npos); } // ============================================================================= @@ -877,7 +961,11 @@ TEST_F(RouteRegistryTest, OnlyStatusPublishesErrorBodySchema) { TEST_F(RouteRegistryTest, OnlyStatusKeepsAnErrorStubComplete) { // The stub declares no 2xx at all; validate_completeness must not ask it for // a success schema it can never have. - seed_get(registry_, "/stub").tag("Test").summary("Stub").only_status(501, "Not implemented"); + seed_get(registry_, "/stub") + .tag("Test") + .requires_role(UserRole::VIEWER) + .summary("Stub") + .only_status(501, "Not implemented"); for (const auto & issue : registry_.validate_completeness()) { EXPECT_NE(issue.severity, ValidationIssue::Severity::kError) << issue.route << ": " << issue.message; diff --git a/src/ros2_medkit_gateway/test/test_typed_route_registry.cpp b/src/ros2_medkit_gateway/test/test_typed_route_registry.cpp index f4f236feb..c6f79cdd1 100644 --- a/src/ros2_medkit_gateway/test/test_typed_route_registry.cpp +++ b/src/ros2_medkit_gateway/test/test_typed_route_registry.cpp @@ -453,22 +453,54 @@ TEST(TypedRouteRegistry, TypedPutRoundTrip) { } // ============================================================================= -// docs_subtree - catch-all regex +// docs_subtree - catch-all regex, documented under a path template // ============================================================================= TEST(TypedRouteRegistry, DocsSubtreeRegexRoutes) { RouteRegistry reg; - reg.docs_subtree("/docs/(.*)", [](const httplib::Request & req, httplib::Response & res) { - res.status = 200; - res.set_content("docs:" + req.matches[1].str(), "text/plain"); - }); + reg.docs_subtree("/{doc_path}/docs", "/(.*)/docs$", + [](const httplib::Request & req, httplib::Response & res) { + res.status = 200; + res.set_content("docs:" + req.matches[1].str(), "text/plain"); + }) + .tag("Server") + .summary("Scoped docs"); auto s = start_server(reg); httplib::Client cli("127.0.0.1", s.port); - auto r = cli.Get("/api/v1/docs/foo/bar.html"); + auto r = cli.Get("/api/v1/foo/bar/docs"); ASSERT_TRUE(r); EXPECT_EQ(r->status, 200); - EXPECT_EQ(r->body, "docs:foo/bar.html"); + EXPECT_EQ(r->body, "docs:foo/bar"); +} + +// The regex is what cpp-httplib matches; the path template is what the +// document publishes. They are separate arguments precisely so the `(.*)` +// never reaches the document as a path key - a client reading `/(.*)/docs$` +// has no way to fill it in. +TEST(TypedRouteRegistry, DocsSubtreePublishesTheTemplateNotTheRegex) { + RouteRegistry reg; + reg.docs_subtree("/{doc_path}/docs", "/(.*)/docs$", + [](const httplib::Request &, httplib::Response & res) { + res.status = 200; + }) + .tag("Server") + .summary("Scoped docs"); + + const auto paths = reg.to_openapi_paths(); + EXPECT_TRUE(paths.contains("/{doc_path}/docs")); + EXPECT_FALSE(paths.contains("/(.*)/docs$")); + // The helper declares the success status, so the call site above did not - + // and could not without hand-attaching a 2xx. + const auto & ok = paths["/{doc_path}/docs"]["get"]["responses"]["200"]; + EXPECT_FALSE(ok["description"].get().empty()); + EXPECT_EQ(ok["content"]["application/json"]["schema"]["type"], "object"); + // The template's parameter is published, so a generated client knows there + // is something to substitute. + const auto & params = paths["/{doc_path}/docs"]["get"]["parameters"]; + ASSERT_EQ(params.size(), 1U); + EXPECT_EQ(params[0]["name"], "doc_path"); + EXPECT_EQ(params[0]["in"], "path"); } // ============================================================================= diff --git a/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/launch_helpers.py b/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/launch_helpers.py index dbf968892..81f3878d4 100644 --- a/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/launch_helpers.py +++ b/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/launch_helpers.py @@ -388,6 +388,29 @@ def create_demo_nodes(nodes=None, *, lidar_faulty=True, coverage=True, # Gateway parameter presets # --------------------------------------------------------------------------- +def graph_provider_params(): + """Gateway parameters that load the graph provider plugin. + + The only in-tree plugin that exports ``describe_plugin_routes``, so it is + what any test about plugin-served routes appearing in the OpenAPI document + has to load. Its ``.so`` path is resolved here, in one place, rather than + at each fixture. + + Returns + ------- + dict + Parameter overrides for ``create_gateway_node(extra_params=...)``. + + """ + graph_plugin = os.path.join( + get_package_prefix('ros2_medkit_graph_provider'), 'lib', + 'ros2_medkit_graph_provider', 'libros2_medkit_graph_provider_plugin.so') + return { + 'plugins': ['graph_provider'], + 'plugins.graph_provider.path': graph_plugin, + } + + def full_feature_gateway_params(scripts_dir): """Gateway parameters that turn on every optional feature gate. @@ -396,6 +419,12 @@ def full_feature_gateway_params(scripts_dir): be live. Without this the gated routes are absent and assertions about them pass vacuously. + Authentication is deliberately NOT enabled here. The document a gateway + serves describes that gateway, so the per-operation ``security`` a plugin + declares is stripped while ``auth.enabled`` is false - which makes this + fixture the auth-off half of that pair. The auth-on half is + ``test_auth.test.py``. + Parameters ---------- scripts_dir : str @@ -408,16 +437,12 @@ def full_feature_gateway_params(scripts_dir): Parameter overrides for ``create_test_launch(gateway_params=...)``. """ - graph_plugin = os.path.join( - get_package_prefix('ros2_medkit_graph_provider'), 'lib', - 'ros2_medkit_graph_provider', 'libros2_medkit_graph_provider_plugin.so') return { 'updates.enabled': True, 'scripts.scripts_dir': scripts_dir, 'triggers.enabled': True, 'locking.enabled': True, - 'plugins': ['graph_provider'], - 'plugins.graph_provider.path': graph_plugin, + **graph_provider_params(), } diff --git a/src/ros2_medkit_integration_tests/test/features/test_auth.test.py b/src/ros2_medkit_integration_tests/test/features/test_auth.test.py index d16abd93c..5d9849406 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_auth.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_auth.test.py @@ -37,7 +37,10 @@ get_test_port, ) from ros2_medkit_test_utils.gateway_test_case import GatewayTestCase -from ros2_medkit_test_utils.launch_helpers import create_gateway_node +from ros2_medkit_test_utils.launch_helpers import ( + create_gateway_node, + graph_provider_params, +) AUTH_PORT = get_test_port() AUTH_BASE_URL = f'http://127.0.0.1:{AUTH_PORT}{API_BASE_PATH}' @@ -63,6 +66,11 @@ def generate_test_description(): 'viewer:viewer_secret:viewer', 'configurator:configurator_secret:configurator', ], + # The auth-on half of the per-operation `security` pair. This is + # the only fixture in the suite with authentication enabled AND a + # plugin that describes a route, so it is the only place the + # published-requirement direction can be driven. + **graph_provider_params(), }, ) @@ -94,6 +102,32 @@ def test_02_root_endpoint_shows_auth_enabled(self): self.assertTrue(data['auth']['enabled']) self.assertEqual(data['auth']['algorithm'], 'HS256') + def test_02b_plugin_operation_publishes_its_role_when_auth_is_on(self): + """With auth enabled the document publishes the role an operation needs. + + The other half of this pair is + ``test_openapi_contract::test_no_operation_publishes_a_role_when_auth_is_off``: + the same gateway code, the same plugin, the opposite ``auth.enabled``. + Splitting it across two fixtures is not a convenience - a single + gateway has one value of ``auth.enabled``, and the whole point of the + rule is that the document follows it. + + The role is ``admin`` because ``AuthConfig``'s ``*`` matches a single + path segment, so no ``viewer`` entry under ``/functions/*`` reaches + this collection and only ADMIN's ``GET:/api/v1/**`` does. + """ + spec = requests.get(f'{self.BASE_URL}/docs', timeout=10).json() + op = spec['paths'].get( + '/functions/{function_id}/x-medkit-graph', {}).get('get') + self.assertIsNotNone( + op, 'the graph provider route is not documented; the fixture must ' + 'load the plugin for this test to mean anything') + self.assertEqual(op.get('security'), [{'bearerAuth': ['admin']}]) + # A requirement may only name a scheme the document defines. + self.assertIn( + 'bearerAuth', + spec.get('components', {}).get('securitySchemes', {})) + def test_03_authenticate_valid_credentials(self): """@verifies REQ_INTEROP_086 - Authentication with valid credentials.""" response = requests.post( diff --git a/src/ros2_medkit_integration_tests/test/features/test_docs_endpoint.test.py b/src/ros2_medkit_integration_tests/test/features/test_docs_endpoint.test.py index 9db995d0c..edc00bdb0 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_docs_endpoint.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_docs_endpoint.test.py @@ -260,6 +260,50 @@ def test_logs_configuration_schema_field_names(self): self.assertIn('severity_filter', put_props) self.assertNotIn('level', put_props) + def test_nested_entity_docs_describes_nothing_rather_than_fabricating(self): + """A nested entity path the gateway does not route publishes no paths. + + `/components/{id}/apps/{id}` resolves - the component really does host + the app - but no route is registered under it, and the nested entity + itself answers 404. Since the sub-document became a projection of the + route registry, its spec is a valid OpenAPI document with an empty + `paths` object. The hand-written producer described 15 paths there that + no route answers: the detail endpoint, one per entry of + `EntityCapabilities::for_type(APP).collections()` - 13 of them, none + skipped - and `/logs/configuration`. + + Pinned so that emptiness is the intended answer rather than an + accident of the filter. + + @verifies REQ_INTEROP_002 + """ + comp_id = self.poll_endpoint_until( + '/components', + lambda d: d if d.get('items') else None, + )['items'][0]['id'] + app_id = self.poll_endpoint_until( + '/apps', + lambda d: d if d.get('items') else None, + )['items'][0]['id'] + nested = f'/components/{comp_id}/apps/{app_id}' + + # The nested entity is not itself a route the gateway serves ... + self.assertEqual( + requests.get(f'{self.BASE_URL}{nested}', timeout=10).status_code, 404, + f'{nested} is routed after all - this case no longer proves anything' + ) + + # ... so its spec describes nothing, and invents nothing. + data = self.poll_endpoint_until( + f'{nested}/docs', + lambda d: d if 'paths' in d else None, + ) + self._assert_valid_openapi_spec(data) + self.assertEqual( + data['paths'], {}, + f'expected no paths, got {list(data["paths"])}' + ) + def test_nonexistent_entity_docs_returns_404(self): """GET /apps/nonexistent_entity_xyz/docs returns 404. diff --git a/src/ros2_medkit_integration_tests/test/features/test_graph_provider_plugin.test.py b/src/ros2_medkit_integration_tests/test/features/test_graph_provider_plugin.test.py index a77a5d287..e03e19aee 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_graph_provider_plugin.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_graph_provider_plugin.test.py @@ -110,6 +110,17 @@ def test_01_function_detail_includes_graph_capability(self): graph_cap['href'], ) + # The top-level `x-medkit-graph` link is a second, independent + # surface: it is emitted off the capability list above, and only + # while a plugin serves the collection. Its absence without the + # plugin is pinned by + # `test_discovery_handlers.cpp::GetFunctionOmitsGraphLinkWithoutAPlugin`; + # this is the other half - with the plugin loaded, it is there and it + # matches the capability href. + self.assertEqual( + data.get('x-medkit-graph'), graph_cap['href'], + 'the detail link and the capability href must name the same URI') + def test_02_graph_endpoint_returns_valid_response(self): """GET /functions/{id}/x-medkit-graph returns a valid graph document. 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 53344efc9..7372de512 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 @@ -21,6 +21,7 @@ """ +import re import unittest import launch_testing @@ -128,6 +129,32 @@ def test_root_includes_apps_endpoints(self): self.assertIn('GET /api/v1/apps/{app_id}/operations', endpoints) self.assertIn('GET /api/v1/apps/{app_id}/configurations', endpoints) + def test_endpoint_list_names_each_route_once(self): + """The endpoints list names each mounted route exactly once. + + It is the route registry's list plus a short hand-written tail for the + routes mounted outside the registry. Moving a route into the registry + without deleting its hand-written entry lists it twice. + + Compared with parameter *names* erased, not as literal strings. The + entry this was written for was spelled ``{entity-path}`` by hand and + ``{entity_path}`` by the registry, so a literal comparison would have + read the two copies as two different endpoints and passed. Erasing the + names is safe here because no two routes in this API differ only by a + parameter name - every pair differs in a literal segment - so a + collision after erasure is a duplicate and not a false positive. + + @verifies REQ_INTEROP_010 + """ + endpoints = self.get_json('/')['endpoints'] + erased = [re.sub(r'\{[^}]*\}', '{}', e) for e in endpoints] + duplicates = sorted({e for e in erased if erased.count(e) > 1}) + self.assertEqual( + duplicates, [], + f'endpoints listed more than once (parameter names erased): {duplicates}') + self.assertIn('GET /api/v1/docs', endpoints) + self.assertIn('GET /api/v1/{entity_path}/docs', endpoints) + def test_docs_endpoint(self): """GET /docs returns OpenAPI 3.1.0 spec. diff --git a/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py b/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py index b3472c2b2..d350040cc 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py @@ -254,12 +254,175 @@ def test_every_tag_used_is_declared(self): self.assertIn( tag, declared, f'{method.upper()} {path}: tag "{tag}" not declared') + def test_capability_description_endpoints_are_documented(self): + """The document describes the endpoints that serve it. + + Both `/docs` routes used to be mounted straight onto the HTTP server, + so the one document every client fetches was the one that never + mentioned how it got there. The scoped `/docs` + sub-documents were worse off: nothing in any response links to one, so + outside the hand-written prose in `docs/api/rest.rst` there was no + machine-readable statement that they exist at all. + """ + paths = self.spec()['paths'] + self.assertIn('/docs', paths) + self.assertIn('/{entity_path}/docs', paths) + # The regex the route is actually mounted on must not leak into a + # path key: `(.+)/docs$` is not something a client can fill in. + self.assertEqual( + [p for p in paths if '(' in p], [], + 'a cpp-httplib regex reached the document as a path key') + + def test_docs_routes_keep_their_json_content_type(self): + """Both `/docs` routes answer `application/json`, indented, and repeat byte-for-byte. + + The generator caches these documents serialized rather than as parsed + DOMs, and both routes write that text straight to the response. Three + things a client depends on have to survive that: the header it selects + on, the 2-space indentation the gateway's JSON convention promises, and + a repeat request answering the same bytes - the last being what says a + cache hit and a cache miss are indistinguishable on the wire. + + The two routes reach the response by different writers (the typed + router for `/docs`, the raw handler for the scoped one), so both are + checked. Exact `dump(2)` canonicality is asserted in + ``test_docs_handlers.cpp``, where nlohmann itself is available to + define it; Python's ``json.dumps`` is not byte-equivalent. + """ + for endpoint in ('/docs', '/apps/docs'): + with self.subTest(endpoint=endpoint): + response = self.get_raw(endpoint) + self.assertEqual( + response.headers.get('Content-Type'), 'application/json', + f'{endpoint}: Content-Type moved') + body = response.content.decode('utf-8') + json.loads(body) # must still parse + second_line = body.split('\n')[1] + self.assertTrue( + second_line.startswith(' "') and not second_line.startswith(' '), + f'{endpoint}: body is not indented with 2 spaces') + repeat = self.get_raw(endpoint).content.decode('utf-8') + self.assertEqual( + body, repeat, + f'{endpoint}: a repeat request answered different bytes') + + def test_plugin_routes_are_documented(self): + """A route a plugin serves and an entity links to is in the document. + + This fixture loads the graph provider, which exports + `describe_plugin_routes`. Plugin routes are mounted outside the + `RouteRegistry`, so nothing else in the document would mention them. + """ + op = self.spec()['paths'].get('/functions/{function_id}/x-medkit-graph', {}).get('get') + self.assertIsNotNone(op, 'the graph provider route is not documented') + self.assertTrue( + op.get('x-medkit-plugin-served'), + 'a folded plugin operation must say it is plugin-served - ' + 'test_openapi_error_coverage reads that marker') + # The gateway declares whatever tag a plugin picks, so the document + # cannot acquire an undeclared one from a plugin. + declared = {t['name'] for t in self.spec().get('tags', [])} + for tag in op.get('tags', []): + self.assertIn(tag, declared) + + def test_no_operation_publishes_a_role_when_auth_is_off(self): + """A document this gateway serves does not claim a role it never checks. + + Scoped to every operation, not to the plugin-served ones: the rule is a + property of the gateway, not of where an operation came from, and the + gateway applies it once over the finished document. Today only the + plugin-folded operation declares a role - `RouteEntry` has no role API + yet - so this reads as a plugin assertion, but it is the same assertion + that must hold for the gateway's own routes once they declare one, and + it will start covering them without an edit. + + This fixture runs with `auth.enabled` false, and + `AuthManager::requires_authentication` returns false outright in that + configuration - every caller is admitted. The graph provider's + `describe_plugin_routes` declares `requires_role("admin")`, and the + gateway strips the requirement rather than publish one it does not + honour. The scheme *definition* stays: a definition nothing requires + claims nothing, and it is what lets the auth-on document name it. + + The opposite direction - auth on, requirement present - is + `test_auth::test_02b_plugin_operation_publishes_its_role_when_auth_is_on`. + """ + spec = self.spec() + self.assertFalse( + spec.get('security'), + 'no document-level requirement with authentication disabled') + offenders = sorted( + op.get('operationId', f'{m.upper()} {p}') + for p, m, op in self.operations() if 'security' in op) + self.assertEqual( + offenders, [], + f'operations publishing a role a disabled gateway ignores: {offenders}') + # Guard against a vacuous pass: the plugin operation whose description + # declares a role has to be in this document for the rule above to have + # had anything to strip. + graph = spec['paths'].get( + '/functions/{function_id}/x-medkit-graph', {}).get('get') + self.assertIsNotNone(graph, 'nothing here declared a role to strip') + self.assertIn( + 'bearerAuth', spec.get('components', {}).get('securitySchemes', {}), + 'the scheme definition is registered whatever auth.enabled says') + def test_no_malformed_path_keys(self): """Path keys have a leading slash and no empty segments.""" for path in self.spec()['paths']: self.assertTrue(path.startswith('/'), f'{path}: missing leading slash') self.assertNotIn('//', path, f'{path}: empty path segment') + def test_entity_id_parameters_follow_the_projection_naming(self): + """Every entity-id path parameter is `_id`. + + ``CapabilityGenerator::entity_template`` derives the parameter name of + an entity segment by dropping a trailing ``s`` and appending ``_id``, + and the ``/docs`` projection substitutes the caller's id on + that name. A route registered as ``/components/{comp_id}/...`` would + therefore never be substituted, and would disappear from the + **entity-scoped** spec while still being served - silently, because the + projection reports the routes it recognises rather than failing on one + it does not. + + Scoped deliberately: it survives in the *collection*-level spec. + ``generate_entity_collection`` passes no bindings, so it substitutes + nothing and filters on the collection prefix alone - + ``GET /components/docs`` still lists ``/components/{comp_id}/hosts``. + Only a document whose prefix carries the id loses the route. + + The convention is otherwise only stated in a comment. A total drift + would show up in ``test_docs_endpoint``; a single renamed route would + not, which is what this closes. + + Two limits, since this checks less than its name suggests. The keyword + tuple below is hardcoded while the route side reads the explicit + singular table in ``rest_server.cpp``, so a **new** entity type is + unchecked here and this still passes. And the non-vacuity floor is + loose: 141 pairs exist today, so losing every ``functions``, ``areas``, + ``subareas`` and ``subcomponents`` pair would leave 83 and still clear + it. Both bound how much drift this notices, not whether what it + notices is real. + + @verifies REQ_INTEROP_002 + """ + keywords = ('areas', 'subareas', 'components', 'subcomponents', + 'apps', 'functions') + offenders = [] + checked = 0 + for path in self.spec()['paths']: + segments = path.strip('/').split('/') + for parent, child in zip(segments, segments[1:]): + if parent not in keywords or not child.startswith('{'): + continue + checked += 1 + expected = '{' + parent[:-1] + '_id}' + if child != expected: + offenders.append(f'{path}: {child} should be {expected}') + self.assertEqual(offenders, [], f'entity id parameter naming: {offenders}') + # A document with no entity-scoped templates would pass vacuously. + self.assertGreater(checked, 50, f'only {checked} entity segments examined') + def declared_success_status(self, path, method): """Return the single 2xx status the document declares for an operation.""" op = self.spec()['paths'][path][method] @@ -1039,10 +1202,15 @@ def test_every_advertised_collection_is_served(self): Two surfaces advertise an entity's resource collections and they are built from two different lists: the ``capabilities`` array on ``GET /{type}/{id}`` comes from a per-handler ``CapabilityBuilder`` - call, the entity's ``/docs`` sub-document from - ``EntityCapabilities::for_type``. Both are followed here, for all four - entity types, because a collection can be right in one list and wrong - in the other. + call, the entity's ``/docs`` sub-document is projected out of the route + registry. Both are followed here, for all four entity types, because a + collection can be right in one list and wrong in the other. + + Only the sub-document paths that declare a ``get`` are followed. Since + the sub-document became a projection it holds every method the gateway + serves under the entity, and ``PUT /{type}/{id}/status/restart`` has no + GET to answer - a 404 there would say nothing about whether the route + exists. A 501 is a served answer - the route exists and reports that the backend does not. A 404 is what this pins: an href the gateway @@ -1064,7 +1232,9 @@ def test_every_advertised_collection_is_served(self): detail = self.get_json(f'/{entity_type}/{entity_id}') subtree = self.get_json(f'/{entity_type}/{entity_id}/docs') advertised = {c['href'] for c in detail.get('capabilities', [])} - advertised |= {f'/api/v1{p}' for p in subtree['paths']} + advertised |= {f'/api/v1{p}' + for p, item in subtree['paths'].items() + if 'get' in item} followed = 0 for href in sorted(advertised): if '{' in href: diff --git a/src/ros2_medkit_integration_tests/test/features/test_openapi_error_coverage.test.py b/src/ros2_medkit_integration_tests/test/features/test_openapi_error_coverage.test.py index 28a476a37..6c2842afe 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_openapi_error_coverage.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_openapi_error_coverage.test.py @@ -34,8 +34,11 @@ ``include/ros2_medkit_gateway/http/detail/status_recorder.hpp``. The short version: it observes the wire status of everything the route registry mounts, and is blind to what answers ahead of routing (the rate limiter's 429, the -auth middleware's 401/403, the CORS reject) and to anything cpp-httplib -answers by itself. Those are declared by hand. +auth middleware's 401/403, the CORS reject), to anything cpp-httplib answers +by itself, and to routes a plugin mounts outside the registry. Those are +declared by hand; the plugin ones are marked ``x-medkit-plugin-served`` in the +document so the reachability assertion below can tell them apart from a +registry route the sweep genuinely failed to reach. """ import json @@ -344,14 +347,40 @@ def test_the_sweep_reached_the_documented_surface(self): going unreached means the substitution, the spec fetch or the recorder itself has quietly stopped working, and the superset rule above would then pass with an empty left-hand side. + + Operations marked ``x-medkit-plugin-served`` are excluded, and that + exclusion is derived rather than listed: a plugin route is mounted by + ``PluginManager::register_routes`` instead of by the ``RouteRegistry``, + and the recorder attaches at the registry's mounting point, so no run + can ever observe one. The sweep still *calls* them - what it cannot do + is see the answer. The marker is stamped by the gateway when it folds + the plugin's description into the document, so this cannot drift into + excusing a registry route. """ - documented = {(method, path) for path, method, _ in self.operations()} + plugin_served = {(method, path) for path, method, op in self.operations() + if op.get('x-medkit-plugin-served')} + documented = {(method, path) for path, method, _ in self.operations()} - plugin_served observed = {(e['method'].lower(), e['path']) for e in self.coverage()['emitted']} reached = observed & documented print(f'coverage: {len(reached)}/{len(documented)} documented operations ' f'reached, {self._swept} requests issued; ' + f'plugin-served (recorder-invisible): {sorted(plugin_served)}; ' f'unreached: {sorted(documented - observed)}') + # This fixture loads the graph provider, which is the only in-tree + # plugin that describes a route. An empty exclusion set would mean the + # fold stopped happening, and this test would then be passing on a + # document that lost an operation rather than on one that never had it. + self.assertTrue( + plugin_served, + 'no plugin-served operation in the document; the fixture loads the ' + 'graph provider, so the plugin route fold has broken') + # The recorder's blind spot is structural, not per-route: nothing a + # plugin serves may appear in `observed` at all. If one ever did, the + # exclusion above would be silently hiding a real gap. + self.assertEqual( + sorted(plugin_served & observed), [], + 'the recorder observed a plugin-served route, so excluding them is wrong') # Cross-check first: every pinned entry must be one the sweep genuinely # cannot call. Without this the literal could be padded with a route # that is reachable, turning the pin into an exemption list. diff --git a/src/ros2_medkit_integration_tests/test/features/test_rbac_contract.test.py b/src/ros2_medkit_integration_tests/test/features/test_rbac_contract.test.py new file mode 100644 index 000000000..aae229080 --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/features/test_rbac_contract.test.py @@ -0,0 +1,494 @@ +#!/usr/bin/env python3 +# Copyright 2026 bburda +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""The role an operation publishes is the role the gateway enforces. + +The document and the permission table come from one declaration on each route +registration, so the claim under test is that the derivation reaches both ends +intact: what ``GET /docs`` says a caller needs is what the middleware actually +demands. + +Its own launch file, and that is the point of it existing separately from +``test_auth.test.py``. Launch configuration is per file, and that fixture runs +``require_auth_for: write`` - under which a GET is served with no token at all, +so a cross-check over the document's GETs would pass without checking anything. +Here it is ``all``: every path except ``/auth/*`` reaches +``AuthManager::check_authorization``, which is the code this file is about. + +@verifies REQ_INTEROP_086 +""" + +import re +import tempfile +import time +import unittest + +import launch +import launch_testing +import launch_testing.actions +import pytest +import requests + +from ros2_medkit_test_utils.constants import ( + ALLOWED_EXIT_CODES, + API_BASE_PATH, + get_test_port, +) +from ros2_medkit_test_utils.launch_helpers import ( + create_gateway_node, + full_feature_gateway_params, +) + +RBAC_PORT = get_test_port() +RBAC_BASE_URL = f'http://127.0.0.1:{RBAC_PORT}{API_BASE_PATH}' + +HTTP_METHODS = {'get', 'post', 'put', 'delete', 'patch'} + +# Weakest first. The gateway expands a declared role upward (a route declaring +# OPERATOR is granted to OPERATOR, CONFIGURATOR and ADMIN), so "one step down" +# is the cheapest token that must be refused. +ROLE_LADDER = ['viewer', 'operator', 'configurator', 'admin'] + +CLIENTS = { + 'viewer': 'viewer_secret', + 'operator': 'operator_secret', + 'configurator': 'configurator_secret', + 'admin': 'admin_secret', +} + +# Substituted for every ``{param}`` when a documented path template is turned +# into a request. Nothing by this name exists, so a request that clears the +# middleware lands on a 404/501/503 from the handler - which is all this file +# needs, since it only ever asks whether the *middleware* refused. +PROBE_ID = 'rbacprobe' + +# Path parameters the router lets span segments, probed with a value that +# actually contains a slash. +# +# HAND-MAINTAINED, and it has to be: the document publishes a path template, +# not the regex behind it, so nothing in the served spec says which parameter +# may carry a `/`. The three below are what `RouteRegistry::to_regex_path` +# compiles to `(.+)` - a ROS topic name under `/data/{data_id}`, a dotted +# parameter path under `/configurations/{config_id}`, and the whole entity +# path prefixing `/docs`. A single-segment probe passes on those +# routes whether or not the permission derivation widened the wildcard, so +# without this list the part of the derivation most likely to be wrong is the +# part nothing here would notice. A fourth such parameter added to the gateway +# and not added here is simply not covered. +SLASH_SPANNING_PARAMS = {'data_id', 'config_id', 'entity_path'} +NESTED_PROBE_ID = f'{PROBE_ID}/nested' + +# The lifecycle policy, written out rather than inferred. +# +# Every other test in this file checks that the published role and the enforced +# role agree. That is a *consistency* property, and consistency cannot notice a +# policy change: both sides come from one declaration, so flipping +# `destructive_transition` in `rest_server.cpp` to always-OPERATOR would move +# the document and the permission table together and leave the whole suite +# green. This table is the second opinion - it says what the roles should be, +# not merely that the two halves match - and it exists for the one call here +# with a real blast radius: `shutdown` and `force-shutdown` tear an entity down, +# `start` / `restart` / `force-restart` bring it back. +EXPECTED_LIFECYCLE_ROLES = { + 'start': 'operator', + 'restart': 'operator', + 'force-restart': 'operator', + 'shutdown': 'configurator', + 'force-shutdown': 'configurator', +} + +# Entity types SOVD gives lifecycle transitions to. +LIFECYCLE_ENTITY_TYPES = {'apps', 'components'} + +# `//{_id}/status/` - the shape a lifecycle transition path +# takes. The test reads the served transitions through this rather than +# assuming the two tables above are exhaustive, so a sixth action or a third +# entity type has to be added here deliberately instead of passing in silence. +LIFECYCLE_PATH = re.compile(r'/(\w+)/\{\w+\}/status/([\w-]+)') + +# Scripts need a writable directory before the routes are mounted at all. +_SCRIPTS_DIR = tempfile.mkdtemp(prefix='medkit-rbac-scripts-') + + +@pytest.mark.launch_test +def generate_test_description(): + """Launch one gateway with authentication on and enforced everywhere.""" + gateway_node = create_gateway_node( + port=RBAC_PORT, + extra_params={ + 'server.host': '127.0.0.1', + 'auth.enabled': True, + 'auth.jwt_secret': 'test_secret_key_for_rbac_contract_integration_1234', + 'auth.jwt_algorithm': 'HS256', + 'auth.token_expiry_seconds': 3600, + 'auth.refresh_token_expiry_seconds': 86400, + # `write` would leave every GET unauthenticated and make the + # cross-check below vacuous - see this module's docstring. + 'auth.require_auth_for': 'all', + 'auth.issuer': 'test_gateway', + 'auth.clients': [ + f'{role}:{secret}:{role}' for role, secret in CLIENTS.items() + ], + # Every optional gate on, so the routes behind them are in the + # document and in the table rather than absent from both. + **full_feature_gateway_params(_SCRIPTS_DIR), + }, + ) + + return launch.LaunchDescription([ + gateway_node, + launch_testing.actions.ReadyToTest(), + ]), {'gateway_node': gateway_node} + + +def _declared_role(operation): + """Return the role an operation publishes. + + ``''`` for an operation that publishes the empty requirement (``security: + []``, reachable with no token), ``None`` when it publishes no requirement + at all - which under ``auth.enabled`` is the defect this file reports. + """ + requirement = operation.get('security') + if requirement is None: + return None + if requirement == []: + return '' + return requirement[0]['bearerAuth'][0] + + +def _weaker_role(role): + """Return the next role down the ladder, or ``None`` for the weakest.""" + index = ROLE_LADDER.index(role) + return ROLE_LADDER[index - 1] if index > 0 else None + + +def _is_sse(operation): + """Report whether this operation is a stream. + + Excluded from every request this file makes: an SSE handler holds the + connection open for the stream's whole lifetime, so a probe would block + until the client timeout rather than answer, and the file would spend its + launch budget waiting on eight of them. + """ + content = operation.get('responses', {}).get('200', {}).get('content', {}) + return 'text/event-stream' in content + + +def _auth_refusal(response): + """Report whether the *middleware* refused, as opposed to a handler. + + Both write 4xx, and the distinction is the whole measurement here. The auth + middleware serialises ``AuthErrorResponse`` - RFC 6749's ``{"error", + "error_description"}`` - while a handler's own 401/403 (a lock owned by + another client, a read-only parameter, a provider refusing a transition) is + the SOVD ``GenericError`` shape with ``error_code``. + """ + if response.status_code not in (401, 403): + return False + try: + body = response.json() + except ValueError: + return True + return 'error' in body and 'error_code' not in body + + +class TestRbacContract(unittest.TestCase): + """The published role and the enforced role are the same role.""" + + _spec = None + + @classmethod + def setUpClass(cls): + """Wait for the gateway, then collect one token per role. + + `GatewayTestCase` is deliberately not the base class: its readiness + wait polls `/health` unauthenticated and expects 200, which under + `require_auth_for: all` is a 401 forever. + """ + cls.session = requests.Session() + cls.tokens = {} + for role, secret in CLIENTS.items(): + cls.tokens[role] = cls._acquire_token(role, secret) + + cls._spec = cls.session.get( + f'{RBAC_BASE_URL}/docs', + headers={'Authorization': f'Bearer {cls.tokens["admin"]}'}, + timeout=15, + ).json() + + @classmethod + def tearDownClass(cls): + cls.session.close() + + @classmethod + def _acquire_token(cls, role, secret): + """Poll `/auth/authorize` until the gateway hands out a token.""" + deadline = time.time() + 30.0 + last = None + while time.time() < deadline: + try: + response = cls.session.post( + f'{RBAC_BASE_URL}/auth/authorize', + json={ + 'grant_type': 'client_credentials', + 'client_id': role, + 'client_secret': secret, + }, + timeout=5, + ) + if response.status_code == 200: + return response.json()['access_token'] + last = f'{response.status_code}: {response.text[:200]}' + except requests.RequestException as exc: + last = str(exc) + time.sleep(0.5) + raise AssertionError(f'no token for {role} within 30s (last: {last})') + + def _auth_header(self, role): + """Build the Authorization header carrying `role`'s token.""" + return {'Authorization': f'Bearer {self.tokens[role]}'} + + def operations(self): + """Yield (path, method, operation) for every documented operation.""" + for path, item in self._spec['paths'].items(): + for method, operation in item.items(): + if method in HTTP_METHODS: + yield path, method, operation + + def _request(self, method, path, headers): + """Send one probe request at a documented path template.""" + url = RBAC_BASE_URL + path + while '{' in url: + start = url.index('{') + end = url.index('}', start) + name = url[start + 1:end] + probe = (NESTED_PROBE_ID if name in SLASH_SPANNING_PARAMS + else PROBE_ID) + url = url[:start] + probe + url[end + 1:] + kwargs = {'timeout': 10, 'headers': headers} + if method in ('post', 'put', 'patch'): + kwargs['json'] = {} + return self.session.request(method.upper(), url, **kwargs) + + def test_every_operation_publishes_a_requirement(self): + """With auth on, no operation leaves its access rule unstated. + + The gateway fails closed: a path the permission table does not match is + 403 for every role the residual list does not cover. An operation that + publishes nothing is therefore not "unrestricted" - it is a route whose + rule the caller has no way to learn and, on a route whose registration + forgot to declare one, a 403 with no explanation anywhere. + """ + silent = sorted( + op.get('operationId', f'{m.upper()} {p}') + for p, m, op in self.operations() + if _declared_role(op) is None + ) + self.assertEqual( + silent, [], + f'operations publishing no security requirement: {silent}') + + def test_published_roles_are_roles_the_gateway_knows(self): + """Every published scope names one of the four configured roles.""" + total = 0 + for path, method, op in self.operations(): + role = _declared_role(op) + total += 1 + if role == '': + continue + self.assertIn( + role, ROLE_LADDER, + f'{method.upper()} {path} publishes unknown role {role!r}') + # Anti-vacuous: the fixture turns every optional gate on, so the + # document is the maximal route surface rather than the handful of + # always-on endpoints. Without this the loop above would pass on a + # document that had collapsed to almost nothing. + self.assertGreater( + total, 200, + f'only {total} operations documented; the fixture lost its gates') + + def test_only_the_auth_endpoints_are_published_as_public(self): + """`security: []` is reserved for the paths the middleware exempts. + + `AllAuthRequirementPolicy` lets `/api/v1/auth/` through by prefix and + nothing else. Publishing the empty requirement anywhere else would + promise a token-free call the middleware would then answer 401. + """ + public = sorted( + path for path, _, op in self.operations() + if _declared_role(op) == '' + ) + self.assertEqual( + public, ['/auth/authorize', '/auth/revoke', '/auth/token']) + + def test_the_declared_role_is_admitted(self): + """A token of the published role clears the middleware, everywhere. + + Sent at the documented path template with a probe id substituted, so + the handler behind it answers 404/501/503 - a status this test does not + care about. What it reads is only whether the middleware refused, which + it can tell from a handler's own 401/403 by the body shape. + """ + checked = 0 + for path, method, op in self.operations(): + role = _declared_role(op) + if role is None or _is_sse(op): + continue + headers = {} if role == '' else self._auth_header(role) + response = self._request(method, path, headers) + self.assertFalse( + _auth_refusal(response), + f'{method.upper()} {path} publishes {role or "public"!r} but ' + f'the middleware answered {response.status_code}: ' + f'{response.text[:200]}') + checked += 1 + self.assertGreater( + checked, 200, f'only {checked} operations probed') + + def test_a_weaker_role_is_refused(self): + """One step down the ladder is refused wherever there is a step. + + Only the direction the derivation could get wrong by being too + generous. A route declaring OPERATOR is expanded to OPERATOR and above, + so VIEWER must not reach it; a route declaring VIEWER has nothing below + it and is skipped, which is why the count is asserted - a derivation + that granted everything to VIEWER would leave nothing to check here. + """ + checked = 0 + for path, method, op in self.operations(): + role = _declared_role(op) + if not role or _is_sse(op): + continue + weaker = _weaker_role(role) + if weaker is None: + continue + response = self._request(method, path, self._auth_header(weaker)) + # `_auth_refusal`, not `status_code == 403`. Several handlers answer + # their own 403 (a lock owned by another client, a read-only + # parameter, a provider refusing a transition), and those carry the + # SOVD `error_code` shape. Accepting a bare 403 would let a genuine + # widening - the middleware waving the weaker token through to a + # handler that then refused for its own reasons - pass as if the + # permission had held. + self.assertTrue( + _auth_refusal(response), + f'{method.upper()} {path} publishes {role!r} but a {weaker!r} ' + f'token was not refused by the middleware; got ' + f'{response.status_code}: {response.text[:200]}') + checked += 1 + self.assertGreater( + checked, 40, + f'only {checked} operations sit above viewer; the derivation may ' + f'have granted everything to the weakest role') + + def test_lifecycle_transitions_publish_the_roles_the_policy_fixes(self): + """Tearing an entity down needs configurator; restarting it needs operator. + + The one assertion in this file that is not a consistency check. Every + other test compares the document with enforcement, and both come from a + single declaration on the registration - so a change to that + declaration moves them together and goes unnoticed. This states the + policy independently, which is what makes an accidental loosening of + the destructive transitions turn the suite red. + + Which transitions exist is read out of the document, not assumed, so a + sixth action or a third entity type gaining transitions fails here + rather than slipping past an expected set that never mentions it. Only + the roles are a literal, and deliberately so: a value derived from + nothing is what makes this a statement about policy instead of a second + look at the same declaration. + """ + served = {} + for path, item in self._spec['paths'].items(): + found = LIFECYCLE_PATH.fullmatch(path) + if found and 'put' in item: + served[found.group(1), found.group(2)] = _declared_role(item['put']) + + self.assertEqual( + {action for _, action in served}, set(EXPECTED_LIFECYCLE_ROLES), + 'the document serves a different set of lifecycle transitions than ' + 'this test states a policy for') + self.assertEqual( + {entity for entity, _ in served}, LIFECYCLE_ENTITY_TYPES, + 'lifecycle transitions are served for a different set of entity ' + 'types than this test states a policy for') + + for (entity_type, action), role in sorted(served.items()): + self.assertEqual( + role, EXPECTED_LIFECYCLE_ROLES[action], + f'PUT /{entity_type}/.../status/{action} publishes {role!r}, ' + f'policy says {EXPECTED_LIFECYCLE_ROLES[action]!r}') + + def test_a_read_route_needs_a_token_and_accepts_the_viewer_one(self): + """The derived table reached the enforcer, not just the document. + + Everything above compares one side of the gateway with the other. This + compares the running gateway with the plain claim: no token is 401, + and the weakest role the table grants gets through. + """ + anonymous = self.session.get(f'{RBAC_BASE_URL}/health', timeout=10) + self.assertEqual(anonymous.status_code, 401) + + authorized = self.session.get( + f'{RBAC_BASE_URL}/health', + headers=self._auth_header('viewer'), + timeout=10, + ) + self.assertEqual(authorized.status_code, 200) + + def test_a_plugin_route_stays_admin_only(self): + """Routes the registry never sees are covered by the residual list. + + A plugin mounts its routes straight onto the HTTP server, so no + `requires_role` on any registration describes them and only ADMIN's + `**` entries reach them. The document says `admin`; this is the + enforcement half of that, and it is the reason the residual list stops + at ADMIN rather than granting plugin surface to a weaker role. + """ + path = '/functions/{function_id}/x-medkit-graph' + operation = self._spec['paths'].get(path, {}).get('get') + self.assertIsNotNone( + operation, + 'the graph provider route is not documented; the fixture must ' + 'load the plugin for this test to mean anything') + self.assertEqual(_declared_role(operation), 'admin') + + for role in ('viewer', 'operator', 'configurator'): + response = self._request('get', path, self._auth_header(role)) + # `_auth_refusal` rather than a bare 403, and this is the route + # where it matters most: it is the only one whose handler is + # third-party, so a plugin answering its own 403 is the likeliest + # way a widening could disguise itself as a refusal. + self.assertTrue( + _auth_refusal(response), + f'a {role} token was not refused by the middleware on a ' + f'plugin route; got {response.status_code}: ' + f'{response.text[:200]}') + + admitted = self._request('get', path, self._auth_header('admin')) + self.assertFalse(_auth_refusal(admitted)) + + +@launch_testing.post_shutdown_test() +class TestShutdown(unittest.TestCase): + """Post-shutdown tests.""" + + def test_exit_codes(self, proc_info): + """Check all processes exited cleanly (SIGTERM allowed).""" + for info in proc_info: + self.assertIn( + info.returncode, ALLOWED_EXIT_CODES, + f'{info.process_name} exited with code {info.returncode}' + ) diff --git a/src/ros2_medkit_plugins/ros2_medkit_graph_provider/README.md b/src/ros2_medkit_plugins/ros2_medkit_graph_provider/README.md index 56a20286c..f45cf071d 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_graph_provider/README.md +++ b/src/ros2_medkit_plugins/ros2_medkit_graph_provider/README.md @@ -9,7 +9,11 @@ into a standalone plugin package in v0.4.0. - Serves `GET /api/v1/functions/{function_id}/x-medkit-graph`, plus a cyclic-subscription sampler under the same resource name and a capability href on every Function's - detail response. + detail response. That href, and the Function detail's top-level `x-medkit-graph` + link, exist only while this plugin is loaded - a gateway without it omits both + rather than publishing a URI nothing answers. +- Exports `describe_plugin_routes`, so the endpoint and the shape of the graph document + are part of `GET /api/v1/docs` instead of being reachable but undocumented. - Subscribes to `/diagnostics` and resolves the publishing node for each metrics sample (never a fabricated or hardcoded name). - Builds a per-Function graph of Apps (nodes) and topic connections (edges) with diff --git a/src/ros2_medkit_plugins/ros2_medkit_graph_provider/src/graph_provider_plugin_exports.cpp b/src/ros2_medkit_plugins/ros2_medkit_graph_provider/src/graph_provider_plugin_exports.cpp index a78410f52..eabc806a4 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_graph_provider/src/graph_provider_plugin_exports.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_graph_provider/src/graph_provider_plugin_exports.cpp @@ -12,6 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. +#include +#include +#include + +#include "ros2_medkit_gateway/core/openapi/route_descriptions.hpp" #include "ros2_medkit_gateway/core/plugins/plugin_types.hpp" #include "ros2_medkit_graph_provider/graph_provider_plugin.hpp" @@ -28,3 +33,173 @@ extern "C" GATEWAY_PLUGIN_EXPORT GatewayPlugin * create_plugin() { extern "C" GATEWAY_PLUGIN_EXPORT IntrospectionProvider * get_introspection_provider(GatewayPlugin * plugin) { return static_cast(plugin); } + +namespace { + +using openapi::OperationDesc; +using openapi::SchemaDesc; + +// Every property below was read out of `build_graph_document_for_apps` and +// `build_edge_json` in graph_provider_plugin.cpp, not out of the tutorial: +// what is in `required` is what those two functions write unconditionally, +// and what is left out is what they write only on some branch. A key that is +// always present but sometimes JSON `null` is `or_null()`, not optional - +// the two say different things to a generated client. + +SchemaDesc edge_metrics_schema() { + return SchemaDesc::object() + .property("frequency_hz", SchemaDesc::number().or_null().description( + "Publish rate observed on the topic, in hertz. `null` while no rate has been " + "observed, and also when several publishers make the measured rate ambiguous " + "and the `multi_publisher_rate` policy is `suppress` - in which case " + "`rate_ambiguous` is set.")) + .property("latency_ms", SchemaDesc::number().or_null().description( + "End-to-end latency reported for the topic, in milliseconds. `null` until a " + "producer reports one.")) + .property("drop_rate_percent", + SchemaDesc::number().description( + "Fraction of messages reported dropped, in percent. 0 when nothing has been reported.")) + .property("metrics_status", + SchemaDesc::string() + .enum_values({"pending", "active", "error"}) + .description("`pending` before any metrics arrive for the topic, `active` while they are fresh, " + "`error` once they have been stale for longer than the freshness window plus its " + "grace period. `error` is what makes the edge count towards a `broken` pipeline.")) + .property("error_reason", SchemaDesc::string().description( + "Why `metrics_status` is `error`. Present only on an edge in that state.")) + .property("publisher_count", + SchemaDesc::integer().description( + "Live publishers on the data topic, from the ROS 2 graph. Omitted when the query did not " + "resolve - never reported as 0 to stand in for 'unknown'.")) + .property("rate_ambiguous", + SchemaDesc::boolean().description( + "Present and true when more than one publisher makes `frequency_hz` untrustworthy. Not by " + "itself a failure: it does not change the edge's verdict.")) + .property("source", SchemaDesc::string().description( + "ROS 2 node that published the `/diagnostics` metrics for this topic. Omitted when " + "the publisher could not be resolved.")) + .required({"frequency_hz", "latency_ms", "drop_rate_percent", "metrics_status"}); +} + +SchemaDesc edge_schema() { + return SchemaDesc::object() + .property("edge_id", SchemaDesc::string().description( + "Identifier of this edge within this document. Stable only for the lifetime of one " + "response - `bottleneck_edge` refers to it.")) + .property("source", SchemaDesc::string().description("Entity id of the publishing App.")) + .property("target", SchemaDesc::string().description("Entity id of the subscribing App.")) + .property("topic_id", SchemaDesc::string().description("Identifier of the topic in this document's `topics`.")) + .property("transport_type", + SchemaDesc::string().description( + "Always `unknown`: the plugin does not resolve the DDS transport behind an edge.")) + .property("metrics", edge_metrics_schema()) + .required({"edge_id", "source", "target", "topic_id", "transport_type", "metrics"}); +} + +SchemaDesc node_schema() { + return SchemaDesc::object() + .property("entity_id", SchemaDesc::string().description("Entity id of the App this node stands for.")) + .property("node_status", + SchemaDesc::string() + .enum_values({"reachable", "unreachable"}) + .description("Whether the App is currently in the ROS 2 graph. An unreachable node publishes and " + "subscribes to nothing, so it contributes no edge - which is why it drags the " + "pipeline to `degraded` on its own.")) + .property("last_seen", SchemaDesc::string().description( + "When the App was last seen online, as `YYYY-MM-DDTHH:MM:SS.mmmZ`. Present only on " + "an unreachable node the plugin has seen before.")) + .required({"entity_id", "node_status"}); +} + +SchemaDesc topic_schema() { + return SchemaDesc::object() + .property("topic_id", SchemaDesc::string().description("Identifier used by this document's edges.")) + .property("name", SchemaDesc::string().description("ROS 2 topic name.")) + .required({"topic_id", "name"}); +} + +SchemaDesc graph_schema() { + auto scope = SchemaDesc::object() + .property("type", SchemaDesc::string().description( + "Always `function`: a graph document is scoped to one SOVD Function.")) + .property("entity_id", SchemaDesc::string().description("Id of that Function.")) + .required({"type", "entity_id"}); + + return SchemaDesc::object() + .property("schema_version", + SchemaDesc::string().description("Version of this document's shape, independent of the gateway's.")) + .property("graph_id", SchemaDesc::string().description("`-graph`.")) + .property("timestamp", + SchemaDesc::string().description("When the graph was built, as `YYYY-MM-DDTHH:MM:SS.mmmZ` in UTC.")) + .property("scope", scope) + .property("pipeline_status", + SchemaDesc::string() + .enum_values({"healthy", "degraded", "broken"}) + .description("`broken` if any edge's metrics are stale, `degraded` if an edge is below its " + "expected rate or over its drop-rate threshold or a scoped node is unreachable, " + "`healthy` otherwise.")) + .property("bottleneck_edge", SchemaDesc::string().or_null().description( + "`edge_id` of the edge furthest below its expected rate. Non-null only while " + "`pipeline_status` is `degraded` and a rate ratio was computed.")) + .property("topics", SchemaDesc::array(topic_schema()).description("Topics connecting the Apps in scope.")) + .property("nodes", SchemaDesc::array(node_schema()).description("Apps in scope, one node each.")) + .property( + "edges", + SchemaDesc::array(edge_schema()).description("One edge per publisher/subscriber pair on a shared topic.")) + .required({"schema_version", "graph_id", "timestamp", "scope", "pipeline_status", "bottleneck_edge", "topics", + "nodes", "edges"}); +} + +} // namespace + +// The gateway resolves this symbol with dlsym and folds what it returns into +// the OpenAPI document it serves. Without it a route this plugin mounts is +// reachable but undiscoverable: the gateway's `RouteRegistry` knows nothing +// about plugin routes, so nothing else in the document mentions them. +// +// The `admin` role is not a preference. A plugin route is mounted straight onto +// the HTTP server, so the gateway's `RouteRegistry` never sees it and no +// `requires_role(...)` on a registration derives a permission entry for it. What +// covers it is `AuthConfig::residual_route_permissions()`, and that list is +// ADMIN's four `**` wildcards alone - a weaker role's entry would have to match +// `/api/v1/functions/{id}/x-medkit-graph` segment by segment, and none does. +// Declaring `viewer` here would publish a role the gateway answers 403 to. +extern "C" GATEWAY_PLUGIN_EXPORT openapi::RouteDescriptions describe_plugin_routes() { + openapi::RouteDescriptionBuilder builder; + + OperationDesc get_graph; + get_graph.tag("Graph") + .operation_id("getFunctionGraph") + .requires_role("admin") + .description( + "Returns the dataflow graph of one SOVD Function: its Apps as nodes, the topics between them as edges, " + "and per-edge throughput, latency and drop-rate metrics sourced from `/diagnostics`. The document is " + "built on demand from the live ROS 2 graph, so two calls a second apart can differ. Supports cyclic " + "subscriptions: point a subscription at this resource to receive the same payload periodically.") + .path_param("function_id", "The function identifier") + .response(200, + SchemaDesc::object() + .property("x-medkit-graph", graph_schema()) + .required({"x-medkit-graph"}) + .description("Vendor extension envelope, so the payload can be told apart from a SOVD " + "standard resource by key."), + "Dataflow graph for the function") + // 400 malformed function id, or an id naming an entity that is not a + // Function; 404 no such Function - both from + // `PluginContext::validate_entity_for_route`. 409 when another client + // holds a lock covering this collection. 503 when the plugin has no + // context yet, or when no graph snapshot could be built for the + // function. 500 when the handler throws - `PluginManager` catches it + // and answers. + .error_response(400, "GenericError") + .error_response(404, "GenericError") + .error_response(409, "GenericError") + .error_response(500, "GenericError") + .error_response(503, "GenericError"); + + builder.add("/functions/{function_id}/x-medkit-graph") + .summary("Get function dataflow graph") + .get(std::move(get_graph)); + + return builder.build(); +} From f4b2d940775e2d094a10304ed8b62c7a8d2c2a74 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Wed, 5 Aug 2026 08:43:56 +0200 Subject: [PATCH 13/17] fix(lint): declare the special members three RAII types were leaving implicit Pre-existing clang-tidy findings in packages this work does not otherwise touch. The gate is mandatory, and "pre-existing" is not a reason to leave it red. --- .../src/fault_audit_log.cpp | 8 ++++++++ .../src/sqlite_fault_storage.cpp | 6 ++++++ .../ros2_medkit_log_bridge/log_bridge_node.hpp | 2 +- .../src/log_bridge_node.cpp | 2 +- .../ros2_medkit_serialization/type_cache.hpp | 18 ++++++++++++++++++ 5 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/ros2_medkit_fault_manager/src/fault_audit_log.cpp b/src/ros2_medkit_fault_manager/src/fault_audit_log.cpp index 65d855bd2..fa7d45e27 100644 --- a/src/ros2_medkit_fault_manager/src/fault_audit_log.cpp +++ b/src/ros2_medkit_fault_manager/src/fault_audit_log.cpp @@ -59,8 +59,16 @@ class Stmt { } } + // All five special members are declared because the destructor is: a class + // that finalizes a raw sqlite3_stmt must not be copied or moved. The moves + // were already suppressed by the user-declared destructor and copies, so + // spelling them out changes nothing at any call site - every use in this + // file is a direct-initialized local - and states the intent the compiler + // was inferring. Stmt(const Stmt &) = delete; Stmt & operator=(const Stmt &) = delete; + Stmt(Stmt &&) = delete; + Stmt & operator=(Stmt &&) = delete; void bind_text(int index, const std::string & value) { if (value.size() > static_cast(std::numeric_limits::max())) { diff --git a/src/ros2_medkit_fault_manager/src/sqlite_fault_storage.cpp b/src/ros2_medkit_fault_manager/src/sqlite_fault_storage.cpp index f2eb1764d..56e232f58 100644 --- a/src/ros2_medkit_fault_manager/src/sqlite_fault_storage.cpp +++ b/src/ros2_medkit_fault_manager/src/sqlite_fault_storage.cpp @@ -42,8 +42,14 @@ class SqliteStatement { } } + // See the identical note on `Stmt` in fault_audit_log.cpp: the moves were + // already suppressed by the user-declared destructor and copies, and every + // use in this file is a direct-initialized local, so declaring them deleted + // is a statement of intent rather than a change. SqliteStatement(const SqliteStatement &) = delete; SqliteStatement & operator=(const SqliteStatement &) = delete; + SqliteStatement(SqliteStatement &&) = delete; + SqliteStatement & operator=(SqliteStatement &&) = delete; sqlite3_stmt * get() const { return stmt_; diff --git a/src/ros2_medkit_log_bridge/include/ros2_medkit_log_bridge/log_bridge_node.hpp b/src/ros2_medkit_log_bridge/include/ros2_medkit_log_bridge/log_bridge_node.hpp index 39ef92379..5499f250b 100644 --- a/src/ros2_medkit_log_bridge/include/ros2_medkit_log_bridge/log_bridge_node.hpp +++ b/src/ros2_medkit_log_bridge/include/ros2_medkit_log_bridge/log_bridge_node.hpp @@ -89,7 +89,7 @@ class LogBridgeNode : public rclcpp::Node { /// (first occurrence passes; same code+severity within report_cooldown_sec is /// suppressed; 0.0 disables). Keyed by severity so a WARN never suppresses a /// same-message ERROR escalation. Exposed for unit testing. - bool cooldown_allows(const std::string & fault_code, uint8_t severity, rclcpp::Time now); + bool cooldown_allows(const std::string & fault_code, uint8_t severity, const rclcpp::Time & now); /// Fetch (or lazily create) the per-source FaultReporter for an originating /// node, so the fault's source_id is the node that logged, not the bridge. diff --git a/src/ros2_medkit_log_bridge/src/log_bridge_node.cpp b/src/ros2_medkit_log_bridge/src/log_bridge_node.cpp index 6b5678fc3..10c980d74 100644 --- a/src/ros2_medkit_log_bridge/src/log_bridge_node.cpp +++ b/src/ros2_medkit_log_bridge/src/log_bridge_node.cpp @@ -177,7 +177,7 @@ bool LogBridgeNode::node_is_eligible(const std::string & source_id) const { return true; } -bool LogBridgeNode::cooldown_allows(const std::string & fault_code, uint8_t severity, rclcpp::Time now) { +bool LogBridgeNode::cooldown_allows(const std::string & fault_code, uint8_t severity, const rclcpp::Time & now) { if (report_cooldown_sec_ <= 0.0) { return true; } diff --git a/src/ros2_medkit_serialization/include/ros2_medkit_serialization/type_cache.hpp b/src/ros2_medkit_serialization/include/ros2_medkit_serialization/type_cache.hpp index b42552796..45097b8a1 100644 --- a/src/ros2_medkit_serialization/include/ros2_medkit_serialization/type_cache.hpp +++ b/src/ros2_medkit_serialization/include/ros2_medkit_serialization/type_cache.hpp @@ -46,6 +46,24 @@ class TypeCache { /// Delete copy assignment TypeCache & operator=(const TypeCache &) = delete; + /// Delete move constructor + /// + /// Already suppressed by the copy declarations above; spelled out so the + /// class declares the whole set rather than leaving two members to be + /// inferred. + TypeCache(TypeCache &&) = delete; + + /// Delete move assignment + TypeCache & operator=(TypeCache &&) = delete; + + /// Defaulted destructor + /// + /// The only instance is the function-local static in `instance()`, so this + /// runs at static destruction exactly as the implicit destructor did, over + /// the same members in the same order. `cache_` holds non-owning pointers + /// into dynmsg's type supports, so there is nothing here to release. + ~TypeCache() = default; + /// Get type info for a message type (C++ introspection) /// /// @param package_name Package name (e.g., "std_msgs") From e89ae7c4120e3137c33484c9ec2d917909f3d27f Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Wed, 5 Aug 2026 08:44:13 +0200 Subject: [PATCH 14/17] fix(gateway): close the seams the per-slice work could not see A whole-branch pass found what reviewing one slice at a time cannot: three surfaces sit outside RouteRegistry::to_openapi_paths() and each published something the gateway does not honour. Per-operation security and the 401/403 declarations were keyed on auth.enabled while enforcement asks the policy, so under require_auth_for: write the document told a client to hold a token for 136 GETs that admit everyone, and under none it asserted an RBAC posture the gateway does not have. Both now key on the policy. Plugin-served operations omitted the 401/403 the middleware does emit on them - the same pre-routing argument the code already made for 416. With locking off, 44 operations still advertised locks while the same gateway's root said locking: false. GET /api/v1 and /docs disagreed in both directions, and nothing compared them. A hand-written array's completeness is now a compiler check rather than a claim the design docs made for it. The cache-derived items in the sub-documents published a request body the handler rejects and a 200 body the gateway never returns; they now inherit both from the templated sibling that names the same route, and are discarded rather than published raw when that sibling cannot be found. --- docs/api/locking.rst | 14 + docs/api/rest.rst | 92 ++- .../design/dto_contract.rst | 20 +- .../design/openapi_derivation.rst | 19 +- .../core/auth/auth_manager.hpp | 16 + .../core/configuration/parameter_types.hpp | 14 +- .../core/openapi/route_descriptions.hpp | 34 ++ .../http/parameter_error_classification.cpp | 33 +- .../src/core/openapi/path_resolver.cpp | 11 + .../src/core/openapi/route_registry.cpp | 106 +++- .../src/http/handlers/health_handlers.cpp | 29 + .../src/http/rest_server.cpp | 31 +- .../src/openapi/capability_generator.cpp | 336 +++++++++-- .../src/openapi/capability_generator.hpp | 18 + .../src/openapi/openapi_spec_builder.cpp | 13 + .../src/openapi/path_builder.cpp | 115 ++-- .../src/openapi/path_builder.hpp | 37 +- .../src/openapi/route_registry.hpp | 83 ++- .../test/test_path_builder.cpp | 84 ++- .../CMakeLists.txt | 9 + .../test/features/test_auth.test.py | 43 +- .../test_auth_policy_contract.test.py | 528 ++++++++++++++++++ .../test/features/test_health.test.py | 10 +- .../test_locking_disabled_contract.test.py | 246 ++++++++ .../features/test_openapi_contract.test.py | 247 ++++++++ 25 files changed, 1991 insertions(+), 197 deletions(-) create mode 100644 src/ros2_medkit_integration_tests/test/features/test_auth_policy_contract.test.py create mode 100644 src/ros2_medkit_integration_tests/test/features/test_locking_disabled_contract.test.py diff --git a/docs/api/locking.rst b/docs/api/locking.rst index 332fad41a..50c382633 100644 --- a/docs/api/locking.rst +++ b/docs/api/locking.rst @@ -212,6 +212,15 @@ generated OpenAPI document, alongside the ``X-Client-Id`` parameter and the ``409`` response, so a generated client can select the lock-participating surface without pattern-matching on paths. +All three appear only on a gateway that has a lock manager. With +``locking.enabled`` set to ``false`` no ``LockManager`` is built, +``validate_lock_access`` returns success without reading the header, and no +write can be refused for a lock - so the marker, the parameter and the ``409`` +are all absent from that gateway's document, which then matches the +``capabilities.locking: false`` its own root reports. The ``/locks`` endpoints +stay in the document either way and answer ``501``. Pinned by +``test_locking_disabled_contract.test.py``. + The marker is applied per route at registration time, not inferred from the handler. It is pinned by ``test_openapi_contract.test.py::test_lock_guarded_set_matches_the_handlers`` @@ -233,6 +242,11 @@ see what survived. The operation declares ``X-Client-Id`` but carries no ``x-medkit-lock-guarded`` marker, because it cannot return the ``409`` the marker implies. +Its ``X-Client-Id`` follows ``locking.enabled`` like the marker does, through +``RouteEntry::lock_client_header()`` rather than a plain ``header_param``: with +locking off there is no lock manager to consult, nothing is ever skipped, and +the header is not declared. + Error Responses --------------- diff --git a/docs/api/rest.rst b/docs/api/rest.rst index a4bf4fb38..7397fbecb 100644 --- a/docs/api/rest.rst +++ b/docs/api/rest.rst @@ -38,6 +38,10 @@ operations that read them. ``X-Medkit-Local-Only`` is about aggregated peers, not locks - so re-read the entity's faults to see what survived. + Everything in this entry describes a gateway with ``locking.enabled`` on. + With it off the header is declared on the ``/locks`` endpoints only, since + those are the only routes that still read it; see :doc:`locking`. + ``X-Medkit-No-Fan-Out`` Answer from this gateway alone: do not query aggregated peers and do not merge their items. Read by the **per-entity** resource-collection list @@ -3373,21 +3377,79 @@ Every scoped spec is a **projection of the root document**: the paths at or below the requested path, with the ids the request named substituted into the templates and the ``in: path`` parameters those substitutions answered removed. For a projected path, what a scoped spec says about an operation is what the -root spec says about it - status codes, schemas, roles and all - and a -collection appears in it exactly when a route answers that collection. - -The exception is the concrete data and operation item paths described below, -which are built from the entity cache rather than projected. They carry the ROS -2 payload schema, which is why they exist; because they are built rather than -projected, nothing reaches them *from* the registration, so what they say about -an operation is narrower than what the root spec says about the templated route -they sit beside. Measured on a component's ``/data`` spec: the projected -``GET /data/{data_id}`` declares ``200, 400, 404, 416, 500, 503`` and the -``PUT`` declares ``200, 400, 404, 409, 416, 500``, while a concrete -``/data/`` declares ``200, 400, 404, 500`` on both - no 416, no 409 on -the lock-guarded write, and with ``auth.enabled`` on no ``security`` -requirement either. Read the templated sibling beside them for the full outcome -set. +root spec says about it - status codes, schemas, roles and all. + +Which prefixes resolve at all is narrower than which paths the gateway serves. +``PathResolver`` recognises a fixed set of resource-collection keywords - +``data``, ``data-categories``, ``data-groups``, ``operations``, ``faults``, +``configurations``, ``logs``, ``bulk-data``, ``cyclic-subscriptions``, +``triggers``, ``updates``, ``hosts`` - and nothing else. ``locks``, ``status``, +``scripts`` and ``fault-triggers`` answer ``200`` on the collection and are +advertised as URI fields on the entity detail response, but +``/docs`` answers ``404`` for them. Read the root document for +those. + +The concrete data and operation item paths described below are the one thing in +a scoped spec that is *built* from the entity cache rather than projected. What +they add is the path itself - one key per discovered topic, service and action, +which SOVD asks for and one ``/data/{data_id}`` registration cannot give - and +the ``x-sovd-*`` extensions that go with it. + +.. warning:: + + **They add no payload schema today, on any gateway.** All four sites that + build a ``TopicData`` push an empty type + (``thread_safe_entity_cache.cpp``), so a topic's ROS 2 type never reaches + this builder in any discovery mode. That is structural, not a property of + one fixture. The ``/data`` listing does resolve the type - it is under + ``x-medkit.ros2.type`` and ``x-medkit.type_info`` on each item - by a path + this projection does not use. Until that type is wired through, read the + listing for a topic's shape and treat these paths as addresses rather than + schemas. + +Everything else a built item says is copied from the projected route it sits +beside, because the two are the *same route*: ``/apps/x/data/temperature`` is +served by the handler registered at ``/apps/{app_id}/data/{data_id}``. Four +things are copied - the declared ``security``, the responses, the +``x-medkit-lock-guarded`` marker, and the non-path parameters (in practice +``X-Client-Id``; the fan-out header is declared on collection *listing* routes, +which are never an item's sibling). Path parameters are not, because a concrete +path has no placeholder for them, and ``operationId`` is not, because it must +stay unique across the document. + +So a built item declares the same ``401``/``403`` components the middleware +actually answers with, the same ``416``, and the whole lock contract together - +the ``409``, the marker and ``X-Client-Id``, which +:ref:`locking ` treats as one declaration. It +follows ``auth.require_auth_for`` and ``locking.enabled`` for the same reason: +the projection does, and this is a copy of it. + +**Responses are copied too, including the 2xx.** The gateway envelopes every +read - ``GET .../data/{data_id}`` answers ``DataValue``, ``GET +.../operations/{operation_id}`` answers ``OperationDetail`` - so a body built +from the ROS 2 message or service-response type would be a second, +contradictory answer for one route rather than a more specific one. The request +body is copied on the same terms: a built ``PUT`` publishes its own envelope +only where the topic's type is known, and inherits ``$ref: DataWriteRequest`` +otherwise, which today is always. + +A built operation item carries a ``GET`` only. The gateway registers no +``POST`` at ``/{entity}/operations/{operation_id}`` - execution is +``POST /{entity}/operations/{operation_id}/executions``, which the projection +publishes beside it - so a ``POST`` on the concrete path answers ``404``. The +ROS service-response schema belongs to that execution result and is not +published anywhere today. + +Both scopes work this way, and the generator has to know which it is in: a +scoped spec substitutes the ids it was given into the path **keys**, so at +``/data/docs`` the sibling is still ``/data/{data_id}`` while at +``/data//docs`` it has already become ``/data/``. A built +item whose sibling is not found is discarded rather than published +un-inherited. Where a projection sits at that key - specific-resource scope - +the projection survives; where none does, the item is simply absent. That +second case is reachable: a nested path such as +``/areas/robot/components/robot-controller/data/temperature/docs`` projects +nothing, and answers with empty ``paths``. ``GET /api/v1/docs`` Returns the full OpenAPI spec for the gateway root, including all server-level diff --git a/src/ros2_medkit_gateway/design/dto_contract.rst b/src/ros2_medkit_gateway/design/dto_contract.rst index 0994b13ef..bf3651976 100644 --- a/src/ros2_medkit_gateway/design/dto_contract.rst +++ b/src/ros2_medkit_gateway/design/dto_contract.rst @@ -966,10 +966,14 @@ and they split into two classes that get opposite treatment: where ``extend`` and ``release`` can only produce ``{400, 403, 404}``. Neither set is copied by hand. The parameter routes declare ``handlers::parameter_error_statuses()``, which runs the classifier over every - enumerator - so a new enumerator mapping to a new status widens the - declaration with no edit at the registration - and a switch with no - ``default`` next to that array makes ``-Werror=switch-enum`` fail the build if - somebody adds an enumerator without listing it. The lock claim is behavioural + enumerator listed in ``kAllParameterErrorCodes`` - so a new enumerator mapping + to a new status widens the declaration with no edit at the registration, once + it has been added to that hand-written array. ``-Werror=switch-enum`` alone + did not force that: it demands a ``case``, and a build with the cases added + and the array left short compiled clean while the registrations silently + dropped a status. The enum ends in a ``COUNT`` sentinel and the array's size + is ``static_assert``-ed against it, which is what makes the array's + completeness a compiler check rather than a convention. The lock claim is behavioural rather than textual, so it is pinned behaviourally: ``LockManagerTest.extend_and_release_answer_only_400_403_404`` drives every reachable failure path of both verbs and asserts the exact status set, @@ -1466,8 +1470,12 @@ checklist plus the DTO steps above: not optional: ``validate_completeness()`` reports a route without it as an error, because authorization fails closed and the route would answer 403 for every role below ADMIN. -6. Update ``handle_root`` endpoint list in ``health_handlers.cpp`` to mirror - the new route. +6. Nothing to do for the root endpoint list: ``get_root`` derives it from the + registry, so registering the route advertises it. Routes a plugin mounts + itself are derived too, from ``RouteDescriptions::endpoints()``. Swagger UI + is the only hand-written entry left. That the list and the document agree is + checked by + ``test_openapi_contract.test.py::test_the_root_list_and_the_document_agree``. 7. Add URI field to entity detail response if the new route is a resource collection. 8. Write a unit test using ``JsonWriter::write()`` and diff --git a/src/ros2_medkit_gateway/design/openapi_derivation.rst b/src/ros2_medkit_gateway/design/openapi_derivation.rst index 1a99d7eb4..fb12b2cf3 100644 --- a/src/ros2_medkit_gateway/design/openapi_derivation.rst +++ b/src/ros2_medkit_gateway/design/openapi_derivation.rst @@ -502,10 +502,21 @@ to re-derive which half is which. Their counterpart *is* declared, which is what makes the boundary a decision rather than an omission: the statuses with a finite first-party range are derived. ``handlers::parameter_error_statuses()`` runs the classifier over every -``ParameterErrorCode``, so a new enumerator widens the declaration with no edit -at any registration, and a switch with no ``default`` beside it makes -``-Werror=switch-enum`` fail the build if an enumerator is added without being -listed. The lock verbs have no enum, so their range is pinned behaviourally by +``ParameterErrorCode`` in ``kAllParameterErrorCodes``, so a new enumerator +widens the declaration with no edit at any registration - **once it is added to +that array**, which is hand-written and is the one step still on the author. + +Two compiler checks sit on that step, and it took both. ``-Werror=switch-enum`` +with a ``default``-less switch beside the array fails the build when an +enumerator is added, but only until a ``case`` is written for it; adding the +cases and leaving the array short compiled cleanly, and the four registrations +then quietly stopped declaring a status the new code produces. The enum +therefore ends in a ``COUNT`` sentinel and the array's length is +``static_assert``-ed against it, so the omission is a build failure rather than +a convention. Adding an enumerator now fails with ``the comparison reduces to +(10 == 11)`` until the array lists it. + +The lock verbs have no enum, so their range is pinned behaviourally by ``LockManagerTest.extend_and_release_answer_only_400_403_404``. **Statuses no handler produces.** The rate limiter's 429 and the auth diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_manager.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_manager.hpp index 648a678ee..578a927cb 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_manager.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/auth/auth_manager.hpp @@ -127,6 +127,22 @@ class AuthManager { */ bool requires_authentication(const std::string & method, const std::string & path) const; + /** + * @brief The policy half of `requires_authentication` + * + * Borrowed, never null, owned by this manager. Exposed so a reader that has + * to answer the same question about a route it is not currently serving - + * `RouteRegistry`, deciding whether an operation may publish a token + * requirement - asks the same object rather than a second copy of the rule. + * Callers must apply `AuthConfig::enabled` themselves; this accessor + * deliberately does not, because it hands out the policy, not the verdict. + * + * @return The configured requirement policy + */ + const IAuthRequirementPolicy * auth_policy() const { + return auth_policy_.get(); + } + /** * @brief Revoke a refresh token * @param refresh_token The refresh token to revoke diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/configuration/parameter_types.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/configuration/parameter_types.hpp index 82c664324..65a3c5137 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/configuration/parameter_types.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/configuration/parameter_types.hpp @@ -32,7 +32,19 @@ enum class ParameterErrorCode { INVALID_VALUE, ///< Invalid value for parameter NO_DEFAULTS_CACHED, ///< No default values cached for reset operation SHUT_DOWN, ///< ConfigurationManager has been shut down - INTERNAL_ERROR ///< Internal/unexpected error + INTERNAL_ERROR, ///< Internal/unexpected error + /// Not an error code - the number of them. Keep last, and add new + /// enumerators above it. + /// + /// `parameter_error_classification.cpp` runs the classifier over every + /// enumerator to derive the statuses four route registrations declare, and + /// it does that from a hand-written array. `-Werror=switch-enum` makes the + /// compiler demand a `case` for a new enumerator, but nothing made it demand + /// an array entry: adding the case alone left the array short and the + /// registrations quietly declaring one status too few. This sentinel is what + /// a `static_assert` on the array's size can compare against, which turns + /// that omission into a build failure. + COUNT }; /// Result of a parameter operation. diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/openapi/route_descriptions.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/openapi/route_descriptions.hpp index e82ec5f70..959dd9801 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/openapi/route_descriptions.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/openapi/route_descriptions.hpp @@ -322,6 +322,18 @@ class PathDescBuilder { return *this; } + /// The method keys this path item carries, lower-case as OpenAPI spells + /// them. Read by `RouteDescriptions::endpoints()`; see the comment there. + std::vector methods() const { + std::vector out; + out.reserve(operations_.size()); + for (const auto & [method, op] : operations_) { + (void)op; + out.push_back(method); + } + return out; + } + // Convert to JSON nlohmann::json to_json() const { nlohmann::json j; @@ -360,6 +372,28 @@ class RouteDescriptions { RouteDescriptions & operator=(const RouteDescriptions &) = default; RouteDescriptions & operator=(RouteDescriptions &&) = default; + /// The (UPPERCASE method, path) pair of every operation described here. + /// + /// Public where `to_json()` is not, and deliberately so: the root endpoint + /// list needs to say that these routes are mounted, and nothing more. A + /// plugin route is mounted straight onto the HTTP server, so without this it + /// was the one served route the root did not advertise while `/docs` + /// documented it. Handing out the whole document instead would give the + /// endpoint list a second, richer view of the same routes that could then + /// drift from the one `CapabilityGenerator` folds. + std::vector> endpoints() const { + std::vector> out; + for (const auto & [path, builder] : paths_) { + for (std::string method : builder.methods()) { + for (char & c : method) { + c = static_cast(std::toupper(static_cast(c))); + } + out.emplace_back(std::move(method), path); + } + } + return out; + } + private: RouteDescriptions() = default; diff --git a/src/ros2_medkit_gateway/src/core/http/parameter_error_classification.cpp b/src/ros2_medkit_gateway/src/core/http/parameter_error_classification.cpp index cf0b4b2f3..7e7eeef2b 100644 --- a/src/ros2_medkit_gateway/src/core/http/parameter_error_classification.cpp +++ b/src/ros2_medkit_gateway/src/core/http/parameter_error_classification.cpp @@ -60,6 +60,12 @@ ParameterErrorClassification classify_error_code(ParameterErrorCode error_code) break; case ParameterErrorCode::SHUT_DOWN: case ParameterErrorCode::INTERNAL_ERROR: + // COUNT is the enumerator count, never a value a transport reports. It is + // named because `-Werror=switch-enum` requires every enumerator to be, and + // it classifies as 500 so that a caller which somehow produced it is not + // told its request was bad. It is deliberately absent from + // `kAllParameterErrorCodes`, so this arm contributes no declared status. + case ParameterErrorCode::COUNT: default: result.status_code = 500; result.error_code = ERR_INTERNAL_ERROR; @@ -79,11 +85,23 @@ constexpr std::array kAllParameterErrorCodes{ ParameterErrorCode::INVALID_VALUE, ParameterErrorCode::NO_DEFAULTS_CACHED, ParameterErrorCode::SHUT_DOWN, ParameterErrorCode::INTERNAL_ERROR}; -/// Compile-time guard on the array above. The switch has a case per enumerator -/// and deliberately no `default`, so `-Werror=switch-enum` turns "somebody -/// added a ParameterErrorCode" into a build failure here - three lines from the -/// array that then has to list it - rather than into a status the routes -/// quietly stop declaring. +/// The array above must list every enumerator, and this is what enforces it. +/// +/// `-Werror=switch-enum` on its own does not: it demands a `case` per +/// enumerator in every switch, and adding those cases leaves a build that +/// compiles cleanly with the array still one entry short - at which point +/// `parameter_error_statuses()` never classifies the new code and the four +/// registrations that read it silently stop declaring a status it can produce. +/// Comparing against `ParameterErrorCode::COUNT` closes that gap: the array's +/// length is now checked, not merely intended. +static_assert(kAllParameterErrorCodes.size() == static_cast(ParameterErrorCode::COUNT), + "kAllParameterErrorCodes must list every ParameterErrorCode; add the new enumerator to the array " + "(and a case to every switch over it)"); + +/// Compile-time guard that every entry of the array is a real enumerator the +/// classifier handles. The switch has a case per enumerator and deliberately no +/// `default`, so `-Werror=switch-enum` turns "somebody added a +/// ParameterErrorCode" into a build failure here too. constexpr bool is_known_parameter_error_code(ParameterErrorCode code) { switch (code) { case ParameterErrorCode::NONE: @@ -97,6 +115,11 @@ constexpr bool is_known_parameter_error_code(ParameterErrorCode code) { case ParameterErrorCode::SHUT_DOWN: case ParameterErrorCode::INTERNAL_ERROR: return true; + // Not an error code, and must never reach the array: the static_assert + // above counts up to it, so listing it there would make the count agree + // while the classifier ran over a non-code. + case ParameterErrorCode::COUNT: + return false; } return false; } diff --git a/src/ros2_medkit_gateway/src/core/openapi/path_resolver.cpp b/src/ros2_medkit_gateway/src/core/openapi/path_resolver.cpp index d454c4b71..d927c38a3 100644 --- a/src/ros2_medkit_gateway/src/core/openapi/path_resolver.cpp +++ b/src/ros2_medkit_gateway/src/core/openapi/path_resolver.cpp @@ -31,6 +31,17 @@ const std::unordered_set & entity_type_keywords() { } /// Resource collection keywords recognized by the SOVD API. +/// +/// Hand-written, and **narrower than the collections the gateway serves** - +/// which is a live gap, not an oversight in this list alone. `locks`, +/// `status`, `scripts` and `fault-triggers` all answer 200 on the collection +/// and are advertised as URI fields on the entity detail response, yet +/// `/docs` answers 404 for them because this set does not name +/// them. Adding a keyword here is not sufficient to close that: the resolved +/// category has to have a producer in `CapabilityGenerator`, so widening this +/// set is a feature rather than a one-line fix. Until then the route's own +/// description says which prefixes resolve, rather than claiming the 404 means +/// the gateway does not serve the path. const std::unordered_set & resource_collection_keywords() { static const std::unordered_set keywords = { "data", "data-categories", "data-groups", "operations", "faults", "configurations", diff --git a/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp b/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp index 64ef822f1..53b301050 100644 --- a/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp +++ b/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp @@ -16,6 +16,7 @@ #include #include +#include #include #include #include @@ -367,17 +368,21 @@ RouteEntry & RouteEntry::errors(const std::vector & codes) { return *this; } -RouteEntry & RouteEntry::lock_guarded() { - lock_guarded_ = true; +RouteEntry & RouteEntry::lock_client_header(const std::string & desc) { + lock_client_header_ = true; // Optional, not required: a caller that sends no `X-Client-Id` is treated as // an anonymous client, which succeeds while nothing is locked and is refused // once something is. Declaring it required would describe a gateway that // rejects the header-less request outright, which is not what happens. - header_param("X-Client-Id", - "Identifies the calling client for lock ownership. While a lock protects this " - "entity's resource collection, only the client holding it may write; every other " - "caller - including one that sends no `X-Client-Id` - is answered 409.", - false, nlohmann::json{{"type", "string"}}); + return header_param("X-Client-Id", desc, false, nlohmann::json{{"type", "string"}}); +} + +RouteEntry & RouteEntry::lock_guarded() { + lock_guarded_ = true; + lock_client_header( + "Identifies the calling client for lock ownership. While a lock protects this " + "entity's resource collection, only the client holding it may write; every other " + "caller - including one that sends no `X-Client-Id` - is answered 409."); // Through errors(), not response(): a 409 here carries the SOVD GenericError // body, and errors() is what publishes it against the shared component // response instead of minting a bespoke bodyless one. @@ -881,6 +886,26 @@ void RouteRegistry::register_all(httplib::Server & server, const std::string & a // to_openapi_paths - generate OpenAPI paths object // ----------------------------------------------------------------------------- +bool RouteRegistry::auth_enforced_on(const RouteEntry & route) const { + // Same two terms, same order, as `AuthManager::requires_authentication`. + if (!auth_enabled_) { + return false; + } + if (auth_policy_ == nullptr) { + return true; + } + // The policy is written against the request line: `WriteOnlyAuth + // RequirementPolicy` compares the method to upper-case literals and + // `AllAuthRequirementPolicy` matches the path prefix the client sends. The + // registry stores neither in that form, so both are converted back here + // rather than the policies being loosened to accept the registry's. + std::string method_upper = route.method_; + for (char & c : method_upper) { + c = static_cast(std::toupper(static_cast(c))); + } + return auth_policy_->requires_authentication(method_upper, auth_api_prefix_ + route.path_); +} + nlohmann::json RouteRegistry::to_openapi_paths() const { nlohmann::json paths = nlohmann::json::object(); @@ -916,7 +941,22 @@ nlohmann::json RouteRegistry::to_openapi_paths() const { // and an unparseable one is rejected with 416 before routing. operation["x-medkit-partial-content"] = true; } - if (route.lock_guarded_) { + // `lock_guarded()` is a registration-time declaration; whether this gateway + // has a LockManager at all is a deployment setting, and the marker, the + // `X-Client-Id` parameter and the 409 are one contract that stands or falls + // together. With `locking.enabled` off `GatewayNode` never builds the + // manager, `HandlerContext::validate_lock_access` returns success without + // reading the header, and no write can be refused - so all three come out, + // and the document stops promising serialised writes on a gateway whose own + // root reports `capabilities.locking: false`. + // + // The header is keyed separately from the marker because the two sets are + // not the same: `DELETE /faults` declares the header through + // `lock_client_header()` and deliberately carries no marker, since it skips + // locked faults rather than refusing. + const bool drop_lock_contract = route.lock_guarded_ && !locking_enabled_; + const bool drop_lock_header = route.lock_client_header_ && !locking_enabled_; + if (route.lock_guarded_ && locking_enabled_) { // Declared by the registration, never inferred from the handler - see // RouteEntry::lock_guarded() for why that derivation is not available. operation["x-medkit-lock-guarded"] = true; @@ -925,6 +965,26 @@ nlohmann::json RouteRegistry::to_openapi_paths() const { // Parameters if (!route.parameters_.empty()) { operation["parameters"] = route.parameters_; + if (drop_lock_header) { + // Erases the first `X-Client-Id` and stops. That is the one + // `lock_client_header()` pushed *provided no route both declares one of + // its own and carries the locking flag* - true of every route today, + // and not enforced anywhere. A route that did both would lose whichever + // declaration came first. The lock CRUD routes are unaffected: they + // declare theirs through plain `header_param` and never set the flag, + // so this branch does not run for them at all. + auto & params = operation["parameters"]; + for (auto it = params.begin(); it != params.end(); ++it) { + const auto name = it->find("name"); + if (name != it->end() && *name == "X-Client-Id") { + params.erase(it); + break; + } + } + if (params.empty()) { + operation.erase("parameters"); + } + } } // Also extract path parameters from the path template and add them @@ -1147,7 +1207,20 @@ nlohmann::json RouteRegistry::to_openapi_paths() const { add_response_ref(code, oauth2 ? "OAuth2Error" : "GenericError"); }; + // Skips exactly one 409 - the one `lock_guarded()` pushed - rather than + // every 409 on the route, so a lock-guarded route that also declared a 409 + // of its own would keep it. `declared_errors_` is a vector and keeps the + // duplicate, which is what makes counting the right instrument here. + // Nothing is removed from `declared_errors_` itself: `validate_completeness` + // reads it to catch a later `only_status()` clearing the status this + // marker promises, and that check is about the registration, not about the + // deployment. + int lock_409_to_drop = drop_lock_contract ? 1 : 0; for (int code : route.declared_errors_) { + if (code == 409 && lock_409_to_drop > 0) { + --lock_409_to_drop; + continue; + } add_error_ref(std::to_string(code)); } @@ -1161,10 +1234,17 @@ nlohmann::json RouteRegistry::to_openapi_paths() const { add_error_ref("500"); } - // The middleware answers these ahead of routing, on every route, so they - // are declared per-route but described once as shared components - that is - // the only place their headers (`WWW-Authenticate`, `Retry-After`, - // `X-RateLimit-*`) can live, since no handler return type produces them. + // The middleware answers these ahead of routing, so they are declared + // per-route but described once as shared components - that is the only + // place their headers (`WWW-Authenticate`, `Retry-After`, `X-RateLimit-*`) + // can live, since no handler return type produces them. + // + // "Ahead of routing" is not "on every route" for the auth pair, and + // reading it that way is what this key corrects. The rate limiter really + // does run on every non-OPTIONS request; the auth middleware runs + // `AuthManager::requires_authentication`, which consults a policy, and + // under `require_auth_for: write` or `none` that policy admits routes this + // document used to promise a 401 on. // // These run AFTER the `errors()` loop above and `add_response_ref` is // first-wins, so a route that declares a status the middleware also owns @@ -1176,7 +1256,7 @@ nlohmann::json RouteRegistry::to_openapi_paths() const { // operations. The body shape is unaffected: Unauthorized, Forbidden and // RateLimited all reference the same GenericError schema. Pinned by // RouteRegistryTest.RouteDeclaredStatusWinsOverTheMiddlewareComponent. - if (auth_enabled_) { + if (auth_enforced_on(route)) { add_response_ref("401", "Unauthorized"); add_response_ref("403", "Forbidden"); } 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 226dc9699..ba8a9a230 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp @@ -164,6 +164,35 @@ http::Result HealthHandlers::get_root(const http::TypedReques } } + // Plugin routes are mounted straight onto the HTTP server by + // `PluginManager::register_routes`, never through the registry, so the loop + // above cannot see them. Without this they were the one thing `/docs` + // documented that the root did not advertise - the opposite direction to + // the `hidden()` routes, which the root advertises and `/docs` omits. + // + // Both directions are correct once the two lists are read for what they + // are: this one says what is *mounted* (see the Swagger UI note below), and + // the document says what is *documented*. A `hidden()` route is mounted and + // answers 405; a plugin route is mounted and is documented. Pinned by + // `test_openapi_contract.test.py::test_the_root_list_and_the_document_agree`. + if (ctx_.node() && ctx_.node()->get_plugin_manager()) { + const std::string api_base_path{API_BASE_PATH}; + for (const auto & desc : ctx_.node()->get_plugin_manager()->collect_route_descriptions()) { + for (const auto & [method, path] : desc.endpoints()) { + // Built in place rather than by `+` chaining, matching + // `RouteRegistry::to_endpoint_list` above: the chain allocates a + // throwaway temporary per operator. + std::string endpoint; + endpoint.reserve(method.size() + api_base_path.size() + path.size() + 1); + endpoint += method; + endpoint += ' '; + endpoint += api_base_path; + endpoint += path; + endpoints.push_back(std::move(endpoint)); + } + } + } + #ifdef ENABLE_SWAGGER_UI // The two `/docs` routes are in the registry, so `to_endpoint_list` above // already lists them; a hand-written entry here would list each twice, and diff --git a/src/ros2_medkit_gateway/src/http/rest_server.cpp b/src/ros2_medkit_gateway/src/http/rest_server.cpp index 4a9160784..8c571e0a7 100644 --- a/src/ros2_medkit_gateway/src/http/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/http/rest_server.cpp @@ -145,9 +145,20 @@ RESTServer::RESTServer(GatewayNode * node, const std::string & host, int port, c // the registry lazily at request time, so the pointer is valid. route_registry_ = std::make_unique(); route_registry_->set_auth_enabled(auth_config_.enabled); + // The second half of the enforcement condition. `auth_manager_` is only + // constructed when auth is on, and it outlives the registry (both are + // members of this server, declared in that order), so the borrowed policy + // stays valid for as long as the registry can be asked. + route_registry_->set_auth_policy(auth_manager_ ? auth_manager_->auth_policy() : nullptr, API_BASE_PATH); // Read the same flag the middleware branch above reads, so the document // declares 429 exactly when the limiter is live. route_registry_->set_rate_limit_enabled(rate_limit_config.enabled); + // Read the manager's existence rather than the parameter: `GatewayNode` + // builds it only when `locking.enabled` is set, and it is the manager's + // absence that `HandlerContext::validate_lock_access` short-circuits on. This + // is the same source `get_root` reports `capabilities.locking` from, so the + // document and the root cannot disagree. + route_registry_->set_locking_enabled(node_->get_lock_manager() != nullptr); health_handlers_ = std::make_unique(*handler_ctx_, route_registry_.get()); discovery_handlers_ = std::make_unique(*handler_ctx_); @@ -429,7 +440,12 @@ void RESTServer::setup_routes() { .summary("Scoped capability description") .description( "Returns the OpenAPI 3.1 document scoped to one entity, resource collection or resource - the SOVD " - "context-specific capability description. 404 when the prefix names nothing this gateway serves.") + "context-specific capability description. 404 when the prefix does not resolve, which is narrower " + "than the paths this gateway serves: `PathResolver` recognises the SOVD resource collections " + "(`data`, `data-categories`, `data-groups`, `operations`, `faults`, `configurations`, `logs`, " + "`bulk-data`, `cyclic-subscriptions`, `triggers`, `updates`, `hosts`) and no others, so `locks`, " + "`status`, `scripts` and `fault-triggers` answer 200 on the collection and 404 here. Read the root " + "document for those.") .operation_id("getScopedCapabilityDescription") .path_param("entity_path", "The entity or resource path the description is scoped to, without a leading slash - for example " @@ -2141,11 +2157,14 @@ void RESTServer::setup_routes() { // `X-Medkit-Local-Only` header declared above is set unconditionally and // is about aggregated peers, not locks - do not read it as the skip // signal. A client that needs to know re-reads the entity's faults. - .header_param("X-Client-Id", - "Identifies the calling client for lock ownership. Faults on entities locked by " - "a different client are silently skipped rather than cleared, and the request " - "still answers 204 - re-read the entity's faults to see what survived.", - false, nlohmann::json{{"type", "string"}}) + // `lock_client_header`, not `header_param`: the description below is a + // claim about locking, so it comes out with `locking.enabled` like the + // rest of them. Declared with a plain header_param it survived on a + // gateway that has no lock manager to skip anything. + .lock_client_header( + "Identifies the calling client for lock ownership. Faults on entities locked by " + "a different client are silently skipped rather than cleared, and the request " + "still answers 204 - re-read the entity's faults to see what survived.") // 503 when the fault store cannot be read - see the list route above. .errors({503}) .operation_id("clearAllFaults") diff --git a/src/ros2_medkit_gateway/src/openapi/capability_generator.cpp b/src/ros2_medkit_gateway/src/openapi/capability_generator.cpp index e5ce199d5..d7f5cf1fa 100644 --- a/src/ros2_medkit_gateway/src/openapi/capability_generator.cpp +++ b/src/ros2_medkit_gateway/src/openapi/capability_generator.cpp @@ -15,6 +15,7 @@ #include "capability_generator.hpp" #include +#include #include #include #include @@ -38,42 +39,65 @@ namespace openapi { namespace { /// The one bearer-token scheme any document that mentions security refers to. -/// The description is part of the definition because the name alone overstates -/// what a given gateway does: `auth.enabled` decides whether the token is -/// *checked*, and a document served by a gateway with it off would otherwise -/// read as if it were. +/// +/// The description is part of the definition because the name alone says +/// nothing about what a *given* gateway does with a token, and the document is +/// served by that gateway. It describes the state this document is already in +/// rather than the settings that produced it: every operation states its own +/// requirement, and the 401/403 an operation declares is one it can answer. nlohmann::json bearer_scheme() { return nlohmann::json{{"type", "http"}, {"scheme", "bearer"}, {"bearerFormat", "JWT"}, {"description", - "JWT bearer token. Where an operation carries a `security` requirement, the scope on that " - "requirement is the role the gateway's permission table grants for its path, and an empty " - "requirement (`security: []`) marks an operation reachable with no token at all. Whether " - "any of it is enforced is a deployment setting: with `auth.enabled` off the gateway serves " - "every operation unauthenticated, which is why this document carries no top-level " - "`security` requirement in that configuration. With it on, `auth.require_auth_for` decides " - "how much is checked - under `write` a GET is served without a token even though its " - "operation names the role the table would grant."}}; -} - -/// Remove every per-operation `security` requirement from an assembled -/// document, leaving the scheme definitions and the document-level -/// requirement alone. Walks whatever `paths` contains, so it does not care -/// which producer wrote an operation. -void strip_per_operation_security(nlohmann::json & document) { + "JWT bearer token. Every operation states its own requirement, and it is the requirement " + "this gateway enforces as configured: a scope names the role the permission table grants " + "for that operation, and the empty requirement (`security: []`) marks an operation " + "reachable with no token at all. The `Unauthorized` and `Forbidden` responses appear on " + "exactly the operations the authentication middleware can refuse. Both follow " + "`auth.enabled` together with `auth.require_auth_for`: under `write` the GETs carry the " + "empty requirement and no refusal statuses, under `none` so does every operation, and with " + "`auth.enabled` off the document drops per-operation requirements and the document-level " + "one alike. Re-read the document after changing either setting - it is generated per " + "gateway, not per release."}}; +} + +/// The five verbs an OpenAPI Path Item Object can carry that this gateway +/// registers. Anything else under a path item (`parameters`, `summary`, +/// vendor extensions) is not an operation and must not be rewritten as one. +bool is_operation_key(const std::string & key) { + return key == "get" || key == "post" || key == "put" || key == "patch" || key == "delete"; +} + +/// An OpenAPI method key as the request line spells it. The auth policies +/// compare against upper-case literals; a document spells its methods in +/// lower case. +std::string to_upper(std::string method) { + for (char & c : method) { + c = static_cast(std::toupper(static_cast(c))); + } + return method; +} + +/// Call `visit(METHOD, path, operation)` for every operation in an assembled +/// document. Walks whatever `paths` contains, so it does not care which +/// producer wrote an operation - registry, plugin fold, or the concrete items +/// a `/docs` sub-document projects. +template +void for_each_operation(nlohmann::json & document, Visitor && visit) { auto paths = document.find("paths"); if (paths == document.end() || !paths->is_object()) { return; } - for (auto & path_item : *paths) { - if (!path_item.is_object()) { + for (auto path_it = paths->begin(); path_it != paths->end(); ++path_it) { + if (!path_it.value().is_object()) { continue; } - for (auto & operation : path_item) { - if (operation.is_object()) { - operation.erase("security"); + for (auto op_it = path_it.value().begin(); op_it != path_it.value().end(); ++op_it) { + if (!op_it.value().is_object() || !is_operation_key(op_it.key())) { + continue; } + visit(to_upper(op_it.key()), path_it.key(), op_it.value()); } } } @@ -137,22 +161,66 @@ std::optional CapabilityGenerator::generate_impl(const std::stri // // The rule is a property of the *gateway*, not of where an operation came // from: this document is served from `/docs` by a running gateway and - // describes it, and `AuthManager::requires_authentication` returns false - // outright when `!config_.enabled` (`auth_manager.cpp:316-319`), so with - // authentication off every caller is admitted and no operation may publish a - // role. Two producers emit one - `RouteEntry::requires_role` through - // `RouteRegistry::to_openapi_paths()`, and a plugin's + // describes it. Two producers emit a requirement - `RouteEntry:: + // requires_role` through `RouteRegistry::to_openapi_paths()`, and a plugin's // `OperationDesc::requires_role` through the fold - and both reach every // `/docs` sub-document as well, because those are a projection // of the same two. Putting the rule in either producer would have meant a // second copy in the other. Here it sits after all of them, so a new one // inherits it. - if (document.has_value() && !ctx_.auth_config().enabled) { - strip_per_operation_security(*document); + if (document.has_value()) { + project_security_onto_enforcement(*document); } return document; } +void CapabilityGenerator::project_security_onto_enforcement(nlohmann::json & document) const { + // Two configurations, two different corrections, because the document-level + // requirement differs between them. + // + // With `auth.enabled` off the middleware admits every caller and the + // document carries no document-level `security` (`openapi_spec_builder` + // only adds one when auth is on), so an operation that publishes nothing + // already reads as "no token needed" - erasing is enough, and is what a + // reader of the scheme description expects. + if (!ctx_.auth_config().enabled) { + for_each_operation(document, [](const std::string &, const std::string &, nlohmann::json & operation) { + operation.erase("security"); + }); + return; + } + + // With it on the document *does* carry `security: [{bearerAuth: []}]`, and + // an operation that publishes nothing inherits it - so erasing here would + // say the opposite of the intent. OpenAPI's override for "reachable with no + // token" is the explicit empty list, which is also exactly what + // `RouteEntry::public_route()` already emits for `/auth/*`; writing it on + // every operation the policy admits puts those routes and the ones a + // permissive `require_auth_for` opens on the same footing. + const auto * auth_manager = ctx_.auth_manager(); + if (auth_manager == nullptr) { + return; + } + for_each_operation(document, + [auth_manager](const std::string & method, const std::string & path, nlohmann::json & operation) { + // `requires_authentication` is the middleware's own predicate, called on + // the same request line a client would send: the path keys here are + // relative to the API base, and the concrete ids a sub-document publishes + // substitute for the templates without changing what the *reachable* + // policies match - all three key on the method and the `/api/v1/auth/` + // prefix, neither of which a substitution touches. + // + // `ConfigurableAuthRequirementPolicy` matches whole path patterns and + // would care, but nothing constructs it: `AuthManager` builds its policy + // from `require_auth_for`, which has exactly three values. That is also + // what makes `test_auth_policy_contract`'s three gateways a *complete* + // sweep of the policy input space rather than a sample of it. + if (!auth_manager->requires_authentication(method, std::string(API_BASE_PATH) + path)) { + operation["security"] = nlohmann::json::array(); + } + }); +} + std::optional CapabilityGenerator::build_document(const std::string & base_path) const { auto resolved = PathResolver::resolve(base_path); @@ -542,13 +610,168 @@ nlohmann::json CapabilityGenerator::generate_specific_resource(const ResolvedPat return build_subtree_document(resolved.resource_id, paths); } +bool CapabilityGenerator::adopt_projected_framework(nlohmann::json & item, const nlohmann::json & paths, + const std::string & sibling_key) { + // A concrete item and the templated route it sits beside are the *same + // route* - `/apps/x/data/temperature` is served by the handler registered at + // `/apps/{app_id}/data/{data_id}`. Only the payload schema differs, because + // only the payload schema depends on which topic was named. + // + // So everything that is not the payload is taken from the projected sibling + // rather than rebuilt here. That is what closes four ways these items used to + // contradict the document they sit in: no `security` at all where the sibling + // published `viewer`/`operator`, no 416 where the sibling declared one, no + // lock 409 on the write, and 401/403 carrying an inline SOVD `GenericError` + // schema where the middleware answers the RFC 6749 shape the `Unauthorized` + // and `Forbidden` components describe. + // + // Inheriting beats restating: the sibling already tracks `auth.require_auth_for`, + // `locking.enabled`, rate limiting and aggregation, so a concrete item cannot + // drift from the gateway's configuration without the projection drifting too. + // Returns false when the sibling is not there, and the caller must then leave + // the projection alone rather than write the un-enriched item over it. The + // first version returned void on a miss and the caller wrote regardless, + // which is how `/operations//docs` came to publish an operation + // with no `security` and no 416 on a gateway that answers 401 for it: at + // specific-resource scope `project()` has already substituted the item id + // into the path *key*, so the templated key this used to look for does not + // exist there. The key is now supplied by the caller, which knows which scope + // it is in, and a miss is a refusal rather than a silent pass-through. + const auto sibling = paths.find(sibling_key); + if (sibling == paths.end() || !sibling->is_object()) { + return false; + } + for (auto method_it = item.begin(); method_it != item.end(); ++method_it) { + if (!method_it.value().is_object()) { + continue; // `x-sovd-*` path-item extensions + } + const auto projected = sibling->find(method_it.key()); + if (projected == sibling->end() || !projected->is_object()) { + // The path item is there but this verb is not, so this method has no + // contract to inherit and would go out un-enriched. Refusing the whole + // item is the same answer as a missing path item, for the same reason. + return false; + } + auto & operation = method_it.value(); + + const auto security = projected->find("security"); + if (security != projected->end()) { + operation["security"] = *security; + } + + // `lock_guarded()` declares the marker, the `X-Client-Id` parameter and the + // 409 as one contract (`route_registry.cpp`). Inheriting the 409 alone + // published a lock conflict with nothing saying who gets refused, which is + // the split that call exists to prevent. + // + // Path parameters are dropped: at collection scope the sibling still + // carries its own unbound id (`{data_id}`, `{operation_id}`), and a + // concrete path has no placeholder for it. Header parameters describe the + // request either way and come across. + const auto lock_marker = projected->find("x-medkit-lock-guarded"); + if (lock_marker != projected->end()) { + operation["x-medkit-lock-guarded"] = *lock_marker; + } + const auto parameters = projected->find("parameters"); + if (parameters != projected->end() && parameters->is_array()) { + nlohmann::json inherited = nlohmann::json::array(); + for (const auto & parameter : *parameters) { + const auto in = parameter.find("in"); + if (in != parameter.end() && *in == "path") { + continue; + } + inherited.push_back(parameter); + } + if (!inherited.empty()) { + operation["parameters"] = std::move(inherited); + } + } + + // A request body the item did not build - which is every one where the ROS + // type is unknown, i.e. all of them today. The sibling's named + // `$ref: DataWriteRequest` says the same thing better than an envelope + // whose `data` member is `x-medkit-schema-unavailable`. + const auto request_body = projected->find("requestBody"); + if (request_body != projected->end() && !operation.contains("requestBody")) { + operation["requestBody"] = *request_body; + } + + // Non-2xx always comes from the sibling, so the inline error bodies give way + // to the shared components. 2xx is filled in only where the item built none + // - and it builds none, because the gateway envelopes every response + // (`DataValue`, `OperationDetail`) rather than returning the bare message. + // A locally built 2xx would be a second, contradictory answer for one route. + const auto responses = projected->find("responses"); + if (responses == projected->end() || !responses->is_object()) { + continue; + } + for (auto resp_it = responses->begin(); resp_it != responses->end(); ++resp_it) { + const bool success = !resp_it.key().empty() && resp_it.key()[0] == '2'; + if (success && operation["responses"].contains(resp_it.key())) { + continue; + } + operation["responses"][resp_it.key()] = resp_it.value(); + } + } + return true; +} + void CapabilityGenerator::add_cache_derived_items(nlohmann::json & paths, const ResolvedPath & resolved, const std::string & entity_path) const { const std::string collection_path = entity_path + "/" + resolved.resource_collection; const auto & cache = node_.get_thread_safe_cache(); - const PathBuilder path_builder(schema_builder_, ctx_.auth_config().enabled); + const PathBuilder path_builder(schema_builder_); const bool one_item = !resolved.resource_id.empty(); + // Where the projected operation for these items lives, which differs by + // scope and is the whole reason this is computed here rather than guessed + // inside the adopter: + // + // * collection scope - `project()` bound the entity id and nothing else, so + // the item route is still templated: `/apps/x/data/{data_id}`. + // * specific-resource scope - it bound the item id too, so the projection + // already sits at the concrete key: `/apps/x/data/parameter_events`. + // + // At specific-resource scope the item is therefore written *at that same + // key*, replacing a projection with the enriched version of itself. + // The item parameter's name is read from the served routes, the same way + // `generate_specific_resource` reads it, rather than spelled out here. Two + // spellings of one fact meant renaming `{data_id}` in the registry would have + // silently discarded every built item - and until this round no test would + // have noticed. + const auto [template_prefix, template_bindings] = entity_template(resolved, true); + (void)template_bindings; + const auto item_parameter = + single_parameter_segment_under(served_paths(), template_prefix + "/" + resolved.resource_collection); + const std::string sibling_key = one_item ? collection_path + "/" + resolved.resource_id + : collection_path + "/{" + item_parameter.value_or("") + "}"; + if (!one_item && !item_parameter.has_value()) { + return; // No templated item route to inherit from; nothing here can be honest. + } + + // A resource id reaches here with its leading `/` already stripped by + // `PathResolver`, while a ROS topic name keeps one - `parameter_events` + // against `/parameter_events`. Comparing them raw matched nothing, so at + // specific-resource scope no data item was built at all and the payload + // schema, the entire reason these items exist, was absent from exactly the + // document a client asks for when it wants one topic. + auto names_match = [](const std::string & lhs, const std::string & rhs) { + const auto strip = [](const std::string & value) { + return !value.empty() && value.front() == '/' ? value.substr(1) : value; + }; + return strip(lhs) == strip(rhs); + }; + + // Enrich first, write only on success. A built item that could not take the + // framework half from its sibling is strictly worse than the projection it + // would replace: the projection is a truthful account of the route minus the + // payload schema, the raw item is neither. + auto emit = [&](const std::string & key, nlohmann::json item) { + if (adopt_projected_framework(item, paths, sibling_key)) { + paths[key] = std::move(item); + } + }; + // A data point or operation the cache does not know needs nothing here: the // projection already published the item route's own description at that key, // which is a truthful account of what the gateway will do with the request. @@ -557,10 +780,11 @@ void CapabilityGenerator::add_cache_derived_items(nlohmann::json & paths, const if (resolved.resource_collection == "data") { auto data = cache.get_entity_data(resolved.entity_id); for (const auto & topic : data.topics) { - if (one_item && topic.name != resolved.resource_id) { + if (one_item && !names_match(topic.name, resolved.resource_id)) { continue; } - paths[collection_path + "/" + topic.name] = path_builder.build_data_item(entity_path, topic); + emit(one_item ? sibling_key : collection_path + "/" + topic.name, + path_builder.build_data_item(entity_path, topic)); } return; } @@ -592,16 +816,18 @@ void CapabilityGenerator::add_cache_derived_items(nlohmann::json & paths, const } for (const auto & svc : ops.services) { - if (one_item && svc.name != resolved.resource_id) { + if (one_item && !names_match(svc.name, resolved.resource_id)) { continue; } - paths[collection_path + "/" + svc.name] = path_builder.build_operation_item(entity_path, svc); + emit(one_item ? sibling_key : collection_path + "/" + svc.name, + path_builder.build_operation_item(entity_path, svc)); } for (const auto & action : ops.actions) { - if (one_item && action.name != resolved.resource_id) { + if (one_item && !names_match(action.name, resolved.resource_id)) { continue; } - paths[collection_path + "/" + action.name] = path_builder.build_operation_item(entity_path, action); + emit(one_item ? sibling_key : collection_path + "/" + action.name, + path_builder.build_operation_item(entity_path, action)); } } @@ -616,9 +842,10 @@ nlohmann::json CapabilityGenerator::plugin_paths() const { // A plugin's declared role is carried through unchanged here. Whether the // gateway is in a configuration that honours it is not a question about the - // fold, so it is not answered in the fold - `generate_impl` strips every + // fold, so it is not answered in the fold - `generate_impl` rewrites every // per-operation requirement once, over the finished document, for whatever // producer wrote it. + const auto * auth_manager = ctx_.auth_manager(); nlohmann::json paths = nlohmann::json::object(); for (const auto & desc : plugin_mgr_->collect_route_descriptions()) { auto paths_json = desc.to_json(); // CapabilityGenerator is friend @@ -627,6 +854,15 @@ nlohmann::json CapabilityGenerator::plugin_paths() const { if (!operation.is_object()) { continue; } + // First-wins, like `RouteRegistry`'s `add_response_ref`: a status the + // plugin described itself keeps the plugin's description. Everything + // stamped below goes through it. + auto add_response_ref = [&operation](const std::string & code, const std::string & component) { + auto & responses = operation["responses"]; + if (!responses.contains(code)) { + responses[code] = nlohmann::json{{"$ref", "#/components/responses/" + component}}; + } + }; // What separates a plugin operation from a gateway one, and the // reason it has to be visible in the document rather than inferred // from the path: a plugin route is mounted straight onto the HTTP @@ -642,7 +878,27 @@ nlohmann::json CapabilityGenerator::plugin_paths() const { // `route_registry.cpp`). Stamped here rather than left to the // plugin: it is a fact about the HTTP server the plugin is mounted // on, not about the plugin. - operation["responses"]["416"] = nlohmann::json{{"$ref", "#/components/responses/GenericError"}}; + add_response_ref("416", "GenericError"); + + // The auth middleware is pre-routing too, and the argument for 416 is + // the argument for these: `PluginManager::register_routes` mounts a + // plugin route on the same `httplib::Server` the middleware's + // pre-routing handler runs on, so an anonymous caller meets the + // middleware before the plugin's handler. Measured on the graph + // provider's `GET /functions/{function_id}/x-medkit-graph` under + // `require_auth_for: all`: no token 401, a viewer token 403, an admin + // token 200, and the document used to declare none of it. + // + // Keyed on the same predicate the registry uses, so a plugin route and + // a registry route on one gateway cannot disagree about whether the + // middleware answers them. Under a policy that admits the operation + // nothing is stamped, and `project_security_onto_enforcement` gives it + // the empty requirement to match. + if (auth_manager != nullptr && + auth_manager->requires_authentication(to_upper(method), std::string(API_BASE_PATH) + key)) { + add_response_ref("401", "Unauthorized"); + add_response_ref("403", "Forbidden"); + } } paths[key] = item; } diff --git a/src/ros2_medkit_gateway/src/openapi/capability_generator.hpp b/src/ros2_medkit_gateway/src/openapi/capability_generator.hpp index 347231ab1..5532a408c 100644 --- a/src/ros2_medkit_gateway/src/openapi/capability_generator.hpp +++ b/src/ros2_medkit_gateway/src/openapi/capability_generator.hpp @@ -198,6 +198,24 @@ class CapabilityGenerator { /// returns still goes through `generate_impl`'s document-wide pass. std::optional build_document(const std::string & base_path) const; + /// Rewrite every per-operation `security` requirement so it states what the + /// running gateway's auth middleware does to that operation, whichever + /// producer wrote it. See the implementation for the two configurations. + void project_security_onto_enforcement(nlohmann::json & document) const; + + /// Give a cache-derived item everything the projected route it sits beside + /// already says - the declared role, every non-2xx status, the lock marker + /// and the non-path parameters - leaving only the ROS payload locally built. + /// + /// `sibling_key` is that route's key in `paths`, which differs by scope: + /// templated at collection scope, already concrete at specific-resource + /// scope, because `project()` substitutes bindings into path keys. + /// + /// @return false when no such operation is there, in which case `item` is + /// unusable and the caller must not write it over the projection. + [[nodiscard]] static bool adopt_projected_framework(nlohmann::json & item, const nlohmann::json & paths, + const std::string & sibling_key); + /// Build a cache key for the given path, invalidating the cache if the /// entity cache generation has changed. std::string get_cache_key(const std::string & path) const; diff --git a/src/ros2_medkit_gateway/src/openapi/openapi_spec_builder.cpp b/src/ros2_medkit_gateway/src/openapi/openapi_spec_builder.cpp index f3a1fe820..30fffb7eb 100644 --- a/src/ros2_medkit_gateway/src/openapi/openapi_spec_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/openapi_spec_builder.cpp @@ -186,6 +186,19 @@ nlohmann::json OpenApiSpecBuilder::build() const { // document-level requirement still lands in `components/securitySchemes` - // that is what lets a single operation name it - but adds no `security` // entry, so the document does not claim every request needs a token. + // + // The callers key `document_level_requirement` on `auth.enabled` alone, and + // that is deliberate rather than the same over-broad key the per-operation + // requirement was corrected away from. This entry is the *default* for an + // operation that states nothing, and with auth on that default is right + // under every `require_auth_for`: a route registered without + // `requires_role()` or `public_route()` is one `AuthManager:: + // check_authorization` fails closed on. It is also never the operative + // statement about a real operation, because + // `CapabilityGenerator::project_security_onto_enforcement` gives every + // operation an explicit requirement - a role or the empty list - before the + // document is served. Dropping it under `none` would gain nothing and would + // make a forgotten registration read as public. for (const auto & ss : security_schemes_) { spec["components"]["securitySchemes"][ss.name] = ss.scheme; if (!ss.document_level_requirement) { diff --git a/src/ros2_medkit_gateway/src/openapi/path_builder.cpp b/src/ros2_medkit_gateway/src/openapi/path_builder.cpp index 8244be2e2..6f6e6e8a1 100644 --- a/src/ros2_medkit_gateway/src/openapi/path_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/path_builder.cpp @@ -19,8 +19,7 @@ namespace ros2_medkit_gateway { namespace openapi { -PathBuilder::PathBuilder(const SchemaBuilder & schema_builder, bool auth_enabled) - : schema_builder_(schema_builder), auth_enabled_(auth_enabled) { +PathBuilder::PathBuilder(const SchemaBuilder & schema_builder) : schema_builder_(schema_builder) { } // ----------------------------------------------------------------------------- @@ -34,9 +33,13 @@ nlohmann::json PathBuilder::build_data_item(const std::string & /*entity_path*/, nlohmann::json get_op; get_op["tags"] = nlohmann::json::array({"Data"}); get_op["summary"] = "Read data: " + topic.name; - get_op["description"] = "Read current value of topic " + topic.name + " (type: " + topic.type + ")."; - get_op["responses"]["200"]["description"] = "Current topic value"; - get_op["responses"]["200"]["content"]["application/json"]["schema"] = schema_builder_.from_ros_msg(topic.type); + get_op["description"] = + "Read current value of topic " + topic.name + (topic.type.empty() ? "." : " (type: " + topic.type + ")."); + // No 200 body. The gateway does not return the bare message: `GET .../data/{data_id}` + // answers the `DataValue` envelope, which is what the templated sibling declares and + // what `adopt_projected_framework` copies in. Building one here published a second, + // contradictory answer for one route - and with `TopicData::type` never populated it + // was an anonymous `x-medkit-schema-unavailable` object replacing a named `$ref`. auto errors = error_responses(); for (auto & [code, val] : errors.items()) { @@ -51,10 +54,24 @@ nlohmann::json PathBuilder::build_data_item(const std::string & /*entity_path*/, put_op["tags"] = nlohmann::json::array({"Data"}); put_op["summary"] = "Write data: " + topic.name; put_op["description"] = "Publish a value to topic " + topic.name + "."; - put_op["requestBody"]["required"] = true; - put_op["requestBody"]["content"]["application/json"]["schema"] = schema_builder_.from_ros_msg(topic.type); - put_op["responses"]["200"]["description"] = "Value written successfully"; - put_op["responses"]["200"]["content"]["application/json"]["schema"] = SchemaBuilder::generic_object_schema(); + // The envelope `DataHandlers::put_data_item` actually reads, and only when + // this builder has something the templated sibling's `$ref: DataWriteRequest` + // does not: the schema of *this* topic's payload under `data`. With the type + // unknown - which is every topic on every gateway today, see the class + // comment - the two say exactly the same thing and the named `$ref` says it + // better, so the sibling's is inherited instead. + if (!topic.type.empty()) { + put_op["requestBody"]["required"] = true; + put_op["requestBody"]["content"]["application/json"]["schema"] = + nlohmann::json{{"type", "object"}, + {"required", nlohmann::json::array({"type", "data"})}, + {"properties", + {{"type", + {{"type", "string"}, + {"const", topic.type}, + {"description", "ROS 2 message type of the topic being written."}}}, + {"data", schema_builder_.from_ros_msg(topic.type)}}}}; + } auto put_errors = error_responses(); for (auto & [code, val] : put_errors.items()) { @@ -85,9 +102,12 @@ nlohmann::json PathBuilder::build_operation_item(const std::string & /*entity_pa get_op["tags"] = nlohmann::json::array({"Operations"}); get_op["summary"] = "Get operation: " + service.name; get_op["description"] = "Get details and last result of service " + service.name + " (type: " + service.type + ")."; - get_op["responses"]["200"]["description"] = "Operation details"; - get_op["responses"]["200"]["content"]["application/json"]["schema"] = - schema_builder_.from_ros_srv_response(service.type); + // No 200 body, for the same reason as the data item: `GET .../operations/{operation_id}` + // answers the `OperationDetail` envelope - measured on the wire as `{"item": {...}}` - + // not the ROS service or action response. Publishing `from_ros_srv_response` here put a + // second, contradictory 200 on one route in one document. The service response schema + // is genuinely useful, but it belongs to the execution result under + // `POST .../{operation_id}/executions`, which no builder writes today. auto errors = error_responses(); for (auto & [code, val] : errors.items()) { @@ -96,23 +116,15 @@ nlohmann::json PathBuilder::build_operation_item(const std::string & /*entity_pa path_item["get"] = std::move(get_op); - // POST - execute operation - nlohmann::json post_op; - post_op["tags"] = nlohmann::json::array({"Operations"}); - post_op["summary"] = "Execute operation: " + service.name; - post_op["description"] = "Execute service " + service.name + " synchronously."; - post_op["requestBody"]["required"] = true; - post_op["requestBody"]["content"]["application/json"]["schema"] = schema_builder_.from_ros_srv_request(service.type); - post_op["responses"]["200"]["description"] = "Operation result"; - post_op["responses"]["200"]["content"]["application/json"]["schema"] = - schema_builder_.from_ros_srv_response(service.type); - - auto post_errors = error_responses(); - for (auto & [code, val] : post_errors.items()) { - post_op["responses"][code] = val; - } - - path_item["post"] = std::move(post_op); + // No POST. The gateway registers `GET /{entity}/operations/{operation_id}` + // and nothing else at that key - execution goes through + // `POST /{entity}/operations/{operation_id}/executions` - so the POST this + // builder used to publish here named an operation the gateway does not serve. + // Measured: `POST /apps/calibration/operations/calibrate` answers 404 while + // `POST /apps/calibration/operations/calibrate/executions` answers 200. Its + // request schema was the bare service-request shape, which compounded the + // problem rather than causing it; correcting the body would have left a 404 + // operation published with a better-looking body. path_item["x-sovd-name"] = service.name; return path_item; } @@ -130,10 +142,12 @@ nlohmann::json PathBuilder::build_operation_item(const std::string & /*entity_pa get_op["summary"] = "Get action status: " + action.name; get_op["description"] = "Get status and result of action " + action.name + " (type: " + action.type + ")."; - // Action goal result type: "pkg/action/Name" -> "pkg/action/Name_GetResult_Response" - get_op["responses"]["200"]["description"] = "Action status"; - get_op["responses"]["200"]["content"]["application/json"]["schema"] = - schema_builder_.from_ros_msg(action.type + "_GetResult_Response"); + // No 200 body, for the same reason as the data item: `GET .../operations/{operation_id}` + // answers the `OperationDetail` envelope - measured on the wire as `{"item": {...}}` - + // not the ROS service or action response. Publishing `from_ros_srv_response` here put a + // second, contradictory 200 on one route in one document. The service response schema + // is genuinely useful, but it belongs to the execution result under + // `POST .../{operation_id}/executions`, which no builder writes today. auto errors = error_responses(); for (auto & [code, val] : errors.items()) { @@ -142,24 +156,15 @@ nlohmann::json PathBuilder::build_operation_item(const std::string & /*entity_pa path_item["get"] = std::move(get_op); - // POST - execute action (asynchronous) - nlohmann::json post_op; - post_op["tags"] = nlohmann::json::array({"Operations"}); - post_op["summary"] = "Execute action: " + action.name; - post_op["description"] = "Start action " + action.name + " asynchronously."; - post_op["requestBody"]["required"] = true; - // Action goal type: "pkg/action/Name" -> "pkg/action/Name_SendGoal_Request" - post_op["requestBody"]["content"]["application/json"]["schema"] = - schema_builder_.from_ros_msg(action.type + "_SendGoal_Request"); - post_op["responses"]["202"]["description"] = "Action accepted"; - post_op["responses"]["202"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("OperationExecution"); - - auto post_errors = error_responses(); - for (auto & [code, val] : post_errors.items()) { - post_op["responses"][code] = val; - } - - path_item["post"] = std::move(post_op); + // No POST. The gateway registers `GET /{entity}/operations/{operation_id}` + // and nothing else at that key - execution goes through + // `POST /{entity}/operations/{operation_id}/executions` - so the POST this + // builder used to publish here named an operation the gateway does not serve. + // Measured: `POST /apps/calibration/operations/calibrate` answers 404 while + // `POST /apps/calibration/operations/calibrate/executions` answers 200. Its + // request schema was the bare service-request shape, which compounded the + // problem rather than causing it; correcting the body would have left a 404 + // operation published with a better-looking body. path_item["x-sovd-name"] = action.name; path_item["x-sovd-asynchronous-execution"] = true; return path_item; @@ -181,14 +186,6 @@ nlohmann::json PathBuilder::error_responses() const { errors["500"]["description"] = "Internal server error"; errors["500"]["content"]["application/json"]["schema"] = SchemaBuilder::generic_error(); - if (auth_enabled_) { - errors["401"]["description"] = "Unauthorized - authentication required"; - errors["401"]["content"]["application/json"]["schema"] = SchemaBuilder::generic_error(); - - errors["403"]["description"] = "Forbidden - insufficient permissions"; - errors["403"]["content"]["application/json"]["schema"] = SchemaBuilder::generic_error(); - } - return errors; } diff --git a/src/ros2_medkit_gateway/src/openapi/path_builder.hpp b/src/ros2_medkit_gateway/src/openapi/path_builder.hpp index c9fe05c69..a424989f4 100644 --- a/src/ros2_medkit_gateway/src/openapi/path_builder.hpp +++ b/src/ros2_medkit_gateway/src/openapi/path_builder.hpp @@ -36,26 +36,45 @@ class SchemaBuilder; /// cannot be written. That comes from the entity cache, so these three /// builders do, and nothing else in this class does. /// -/// A path item built here is not a projection of anything and so does not -/// carry what a projected operation carries - the framework-level error set, -/// the declared role. Everything the registry *can* answer is answered by the -/// projection; adding a fourth builder here is how the hand-written half grows -/// back. +/// A path item built here is not a projection of anything, so it builds only +/// what the entity cache knows and the registration cannot: the concrete path +/// key, the `x-sovd-*` extensions, and a request body where the topic's ROS 2 +/// type is available. Everything else - the declared role, every response +/// including the 2xx, the lock contract, the middleware statuses - is copied +/// in afterwards from the projected sibling by +/// `CapabilityGenerator::adopt_projected_framework`, because the built item +/// and that sibling are the *same route*. +/// +/// **`TopicData::type` is empty on every gateway today.** All four sites that +/// build one push `{topic, "", direction}` +/// (`thread_safe_entity_cache.cpp`), so the `!topic.type.empty()` branch below +/// never runs outside unit tests and a built data item currently contributes +/// its path and nothing else. That is worth knowing before adding to it. +/// +/// Do not restate framework facts here, and do not build a response body: the +/// gateway envelopes every read (`DataValue`, `OperationDetail`), so a body +/// derived from the ROS type is a second, contradictory answer for one route - +/// which is what these items used to publish, alongside an inline +/// `GenericError` for a 401 the middleware answers in the RFC 6749 shape. class PathBuilder { public: - explicit PathBuilder(const SchemaBuilder & schema_builder, bool auth_enabled = false); + explicit PathBuilder(const SchemaBuilder & schema_builder); nlohmann::json build_data_item(const std::string & entity_path, const TopicData & topic) const; nlohmann::json build_operation_item(const std::string & entity_path, const ServiceInfo & service) const; nlohmann::json build_operation_item(const std::string & entity_path, const ActionInfo & action) const; - /// The 400/404/500 set every item path carries, plus 401/403 when - /// authentication is on. + /// The handler-level 400/404/500 every item path carries. + /// + /// Deliberately *not* 401/403: those come from the auth middleware, which + /// decides per gateway configuration whether it answers at all and uses the + /// RFC 6749 body shape rather than the SOVD `GenericError` these carry. + /// `adopt_projected_framework` supplies them, together with 409, 416 and + /// anything else the templated sibling declares. nlohmann::json error_responses() const; private: const SchemaBuilder & schema_builder_; - bool auth_enabled_; }; } // namespace openapi diff --git a/src/ros2_medkit_gateway/src/openapi/route_registry.hpp b/src/ros2_medkit_gateway/src/openapi/route_registry.hpp index 703ad0e36..57ee818b6 100644 --- a/src/ros2_medkit_gateway/src/openapi/route_registry.hpp +++ b/src/ros2_medkit_gateway/src/openapi/route_registry.hpp @@ -30,6 +30,7 @@ #include #include "ros2_medkit_gateway/core/auth/auth_config.hpp" +#include "ros2_medkit_gateway/core/auth/auth_requirement_policy.hpp" #include "ros2_medkit_gateway/core/http/error_codes.hpp" #include "ros2_medkit_gateway/dto/contract.hpp" #include "ros2_medkit_gateway/dto/json_reader.hpp" @@ -347,6 +348,23 @@ class RouteEntry { /// handlers. RouteEntry & lock_guarded(); + /// Declare that this route reads `X-Client-Id` to consult the lock manager, + /// without the rest of the `lock_guarded()` contract. + /// + /// For the one route that reads the header and can still never answer 409: + /// `DELETE /faults` skips faults on entities locked by another client and + /// answers 204 regardless. It must not carry the marker - that would publish + /// a status it cannot return - but the header is a locking declaration all + /// the same, and comes out with `locking.enabled` for the same reason the + /// marker does. Without this, that route is the one place a document on a + /// lock-less gateway still describes lock behaviour. + /// + /// The lock CRUD's own `X-Client-Id` is declared with plain `header_param()` + /// and stays: those routes are served either way and answer 501, and their + /// header is the lock API's own input rather than a claim about how locks + /// affect some other route. + RouteEntry & lock_client_header(const std::string & desc); + /// Declare the `X-Medkit-No-Fan-Out` request header this route reads. /// /// Presence-only: `TypedRequest::fan_out_disabled()` and the @@ -435,6 +453,11 @@ class RouteEntry { bool partial_content_{false}; /// Set by lock_guarded(); emitted as `x-medkit-lock-guarded: true`. bool lock_guarded_{false}; + /// Set by lock_guarded() and by lock_client_header(): this route declares an + /// `X-Client-Id` that describes locking, so it comes out when locking is off. + /// Distinct from lock_guarded_ because one route declares the header without + /// the marker, and the lock CRUD declares the header as neither. + bool lock_client_header_{false}; /// Set by only_status(); suppresses the blanket 400/404/500 injection. bool only_status_{false}; /// Set by the *attachments* body-less typed `put` overload - the @@ -816,11 +839,40 @@ class RouteRegistry { return routes_.size(); } - /// Set whether authentication is enabled (controls 401/403 in OpenAPI output). + /// Set whether authentication is configured at all. + /// + /// Necessary but not sufficient for 401/403: `auth.enabled` off means the + /// middleware admits every caller, but on it means only that a policy is + /// consulted. Pair with `set_auth_policy()` for the rest of the answer. void set_auth_enabled(bool enabled) { auth_enabled_ = enabled; } + /// Set the policy that decides *which* routes the middleware checks + /// (controls 401/403 in OpenAPI output alongside `set_auth_enabled`). + /// + /// `auth.enabled` alone is the wrong key and was the wrong key here. What + /// the middleware runs is `config_.enabled && auth_policy_-> + /// requires_authentication(method, path)` (`AuthManager:: + /// requires_authentication`), and the second half is not a constant: + /// `require_auth_for: write` leaves every GET open, `none` leaves everything + /// open, and both configurations previously published `security` and both + /// refusal statuses on all of them. + /// + /// @param policy The live policy the middleware consults. Not owned; must + /// outlive this registry. Passing nullptr (the default) leaves the + /// registry on `auth_enabled_` alone, which is `require_auth_for: all` - + /// the strictest reading, so a caller that forgets to wire the policy + /// over-declares rather than under-declares. + /// @param api_prefix The prefix the routes are mounted under (`/api/v1`). + /// Registered paths are stored without it, and the policy matches on the + /// path the client sends - `AllAuthRequirementPolicy` exempts + /// `/api/v1/auth/` by prefix - so it has to be put back before asking. + void set_auth_policy(const IAuthRequirementPolicy * policy, std::string api_prefix) { + auth_policy_ = policy; + auth_api_prefix_ = std::move(api_prefix); + } + /// Set whether rate limiting is enabled (controls 429 in OpenAPI output). /// The limiter runs pre-routing on every non-OPTIONS request, so when it is /// on, 429 is reachable on every route and the document has to say so. @@ -839,6 +891,24 @@ class RouteRegistry { aggregation_enabled_ = enabled; } + /// Set whether entity locking is enabled (controls the whole `lock_guarded()` + /// contract in OpenAPI output: the `x-medkit-lock-guarded` marker, the + /// `X-Client-Id` parameter and the 409). + /// + /// `locking.enabled` defaults true and `GatewayNode` only builds the + /// `LockManager` when it is set. With it off `HandlerContext:: + /// validate_lock_access` returns success without reading the header, so no + /// write can be refused for a lock and none of the three declarations + /// describes anything - while the same gateway's root reports + /// `capabilities.locking: false`. + /// + /// Defaults true, matching the ROS parameter: a caller that forgets to wire + /// this over-declares a contract rather than dropping a 409 a client would + /// then meet undocumented. + void set_locking_enabled(bool enabled) { + locking_enabled_ = enabled; + } + /// Escape hatch for JSON routes without typed DTOs (e.g. the fault-trigger /// CRUD): registers a raw cpp-httplib handler under an OpenAPI-style path so /// the route shows up in the generated spec, Swagger UI and the endpoint @@ -861,10 +931,21 @@ class RouteRegistry { RouteEntry & add_raw_route(const std::string & method, const std::string & openapi_path, const std::string & regex_path, HandlerFn handler); + /// Whether the auth middleware answers this route ahead of its handler. + /// + /// The one place the registry decides that, so `to_openapi_paths()` and any + /// later reader ask the same question of the same object the middleware + /// asks. Mirrors `AuthManager::requires_authentication` deliberately - see + /// `set_auth_policy()`. + bool auth_enforced_on(const RouteEntry & route) const; + std::deque routes_; bool auth_enabled_{false}; + const IAuthRequirementPolicy * auth_policy_{nullptr}; + std::string auth_api_prefix_; bool rate_limit_enabled_{false}; bool aggregation_enabled_{false}; + bool locking_enabled_{true}; /// True when `openapi_path` carries one of the four entity-id path /// parameters, which is exactly the condition under which a handler can call diff --git a/src/ros2_medkit_gateway/test/test_path_builder.cpp b/src/ros2_medkit_gateway/test/test_path_builder.cpp index 2a776bbd4..2fda9eb06 100644 --- a/src/ros2_medkit_gateway/test/test_path_builder.cpp +++ b/src/ros2_medkit_gateway/test/test_path_builder.cpp @@ -40,7 +40,10 @@ TEST_F(PathBuilderTest, DataItemGetAlwaysPresent) { TopicData topic{"temperature", "std_msgs/msg/Float32", "publish"}; auto result = path_builder_.build_data_item("apps/sensor", topic); ASSERT_TRUE(result.contains("get")); - EXPECT_TRUE(result["get"]["responses"].contains("200")); + // No 200: the gateway answers the `DataValue` envelope, not the bare + // message, so the read body is the projected sibling's to state. + // `adopt_projected_framework` copies it in. + EXPECT_FALSE(result["get"]["responses"].contains("200")); } TEST_F(PathBuilderTest, DataItemPutForSubscribeTopic) { @@ -74,32 +77,52 @@ TEST_F(PathBuilderTest, DataItemHasSovdExtensions) { EXPECT_EQ(result["x-sovd-name"], "temperature"); } -TEST_F(PathBuilderTest, DataItemSchemaFromRosType) { - TopicData topic{"temperature", "std_msgs/msg/Float32", "publish"}; - auto result = path_builder_.build_data_item("apps/sensor", topic); - auto schema = result["get"]["responses"]["200"]["content"]["application/json"]["schema"]; - // std_msgs/msg/Float32 has a "data" field - EXPECT_EQ(schema["type"], "object"); - EXPECT_TRUE(schema.contains("properties")); +TEST_F(PathBuilderTest, DataItemWriteBodyOmittedWhenTheTypeIsUnknown) { + // `TopicData::type` is empty on every gateway today - every construction site + // in `thread_safe_entity_cache.cpp` pushes `{topic, "", direction}` - so this + // is the branch production actually takes, and it had no coverage at all. + // With nothing to say beyond `DataWriteRequest`, the builder says nothing and + // the projected sibling's named `$ref` is inherited instead. + TopicData topic{"command", "", "subscribe"}; + auto result = path_builder_.build_data_item("apps/actuator", topic); + ASSERT_TRUE(result.contains("put")); + EXPECT_FALSE(result["put"].contains("requestBody")); + // And the description does not render an empty type as "(type: )". + EXPECT_EQ(result["get"]["description"], "Read current value of topic command."); } // ============================================================================= // Operation item (service) tests // ============================================================================= -TEST_F(PathBuilderTest, ServiceOperationHasGetAndPost) { +TEST_F(PathBuilderTest, ServiceOperationHasGetOnly) { // @verifies REQ_INTEROP_002 ServiceInfo service{"calibrate", "/engine/calibrate", "std_srvs/srv/Trigger", std::nullopt}; auto result = path_builder_.build_operation_item("apps/engine", service); ASSERT_TRUE(result.contains("get")); - ASSERT_TRUE(result.contains("post")); -} - -TEST_F(PathBuilderTest, ServiceOperationPostHasRequestBody) { - ServiceInfo service{"calibrate", "/engine/calibrate", "std_srvs/srv/Trigger", std::nullopt}; - auto result = path_builder_.build_operation_item("apps/engine", service); - ASSERT_TRUE(result["post"].contains("requestBody")); - EXPECT_TRUE(result["post"]["requestBody"]["required"].get()); + // The gateway registers no POST at `/{entity}/operations/{operation_id}` - + // execution is `POST .../{operation_id}/executions` - so publishing one here + // named an operation that answers 404. + EXPECT_FALSE(result.contains("post")); + // And no 200: `GET .../operations/{operation_id}` answers `OperationDetail` + // (`{"item": {...}}` on the wire), never the ROS service response. Building + // one from `from_ros_srv_response` put two contradictory bodies on one route. + EXPECT_FALSE(result["get"]["responses"].contains("200")); +} + +TEST_F(PathBuilderTest, DataItemPutBodyIsTheWriteEnvelopeNotTheBareMessage) { + // The handler reads `DataWriteRequest{type, data}`. Publishing the bare + // message schema was answered `400 "type: missing required field"` on the + // wire, so the shape - not merely the status set - has to match. + TopicData topic{"command", "std_msgs/msg/String", "subscribe"}; + auto result = path_builder_.build_data_item("apps/actuator", topic); + auto schema = result["put"]["requestBody"]["content"]["application/json"]["schema"]; + EXPECT_EQ(schema["type"], "object"); + EXPECT_EQ(schema["required"], nlohmann::json::array({"type", "data"})); + EXPECT_EQ(schema["properties"]["type"]["const"], "std_msgs/msg/String"); + // `data` carries what the bare schema used to sit at the top level. + EXPECT_EQ(schema["properties"]["data"]["type"], "object"); + EXPECT_TRUE(schema["properties"]["data"].contains("properties")); } TEST_F(PathBuilderTest, ServiceOperationHasSovdName) { @@ -118,12 +141,12 @@ TEST_F(PathBuilderTest, ServiceOperationNotAsynchronous) { // Operation item (action) tests // ============================================================================= -TEST_F(PathBuilderTest, ActionOperationHasGetAndPost) { +TEST_F(PathBuilderTest, ActionOperationHasGetOnly) { // @verifies REQ_INTEROP_002 ActionInfo action{"navigate", "/nav/navigate", "nav2_msgs/action/NavigateToPose", std::nullopt}; auto result = path_builder_.build_operation_item("apps/navigation", action); ASSERT_TRUE(result.contains("get")); - ASSERT_TRUE(result.contains("post")); + EXPECT_FALSE(result.contains("post")); } TEST_F(PathBuilderTest, ActionOperationIsAsynchronous) { @@ -133,10 +156,11 @@ TEST_F(PathBuilderTest, ActionOperationIsAsynchronous) { EXPECT_TRUE(result["x-sovd-asynchronous-execution"].get()); } -TEST_F(PathBuilderTest, ActionOperationPostReturns202) { +TEST_F(PathBuilderTest, ActionOperationDeclaresNoResponseBodyOfItsOwn) { ActionInfo action{"navigate", "/nav/navigate", "nav2_msgs/action/NavigateToPose", std::nullopt}; auto result = path_builder_.build_operation_item("apps/navigation", action); - EXPECT_TRUE(result["post"]["responses"].contains("202")); + EXPECT_FALSE(result["get"]["responses"].contains("200")); + EXPECT_FALSE(result["get"]["responses"].contains("202")); } TEST_F(PathBuilderTest, ActionOperationHasSovdName) { @@ -159,14 +183,16 @@ TEST_F(PathBuilderTest, ErrorResponsesWithoutAuth) { EXPECT_FALSE(errors.contains("403")); } -TEST_F(PathBuilderTest, ErrorResponsesWithAuth) { - PathBuilder auth_builder(schema_builder_, true); - auto errors = auth_builder.error_responses(); - EXPECT_TRUE(errors.contains("400")); - EXPECT_TRUE(errors.contains("404")); - EXPECT_TRUE(errors.contains("500")); - EXPECT_TRUE(errors.contains("401")); - EXPECT_TRUE(errors.contains("403")); +TEST_F(PathBuilderTest, ErrorResponsesNeverCarryTheMiddlewareStatuses) { + // 401/403 are the auth middleware's, answered in the RFC 6749 shape and only + // where the policy enforces. This builder cannot know either, so it declares + // neither; `CapabilityGenerator::adopt_projected_framework` copies them in + // from the templated sibling, which does know. + auto errors = path_builder_.error_responses(); + EXPECT_FALSE(errors.contains("401")); + EXPECT_FALSE(errors.contains("403")); + EXPECT_FALSE(errors.contains("409")); + EXPECT_FALSE(errors.contains("416")); } TEST_F(PathBuilderTest, ErrorResponsesUseGenericErrorSchema) { diff --git a/src/ros2_medkit_integration_tests/CMakeLists.txt b/src/ros2_medkit_integration_tests/CMakeLists.txt index 2a5f68289..166c31cee 100644 --- a/src/ros2_medkit_integration_tests/CMakeLists.txt +++ b/src/ros2_medkit_integration_tests/CMakeLists.txt @@ -212,6 +212,15 @@ if(BUILD_TESTING) # polling budgets are similarly generous - widen both to match. set_tests_properties(test_graph_provider_stale PROPERTIES TIMEOUT 300) set_tests_properties(test_graph_provider_sse PROPERTIES TIMEOUT 300) + + # test_auth_policy_contract launches three full-feature gateways, one per + # auth.require_auth_for value, and sends a real unauthenticated request at + # every operation each of them documents. That is the measurement - the + # published requirement is compared against what the middleware actually + # does - and it costs three startups plus several hundred requests, two of + # which wait out a fault-service timeout. It does not fit the default 120s + # feature timeout. + set_tests_properties(test_auth_policy_contract PROPERTIES TIMEOUT 300) endif() ament_package() diff --git a/src/ros2_medkit_integration_tests/test/features/test_auth.test.py b/src/ros2_medkit_integration_tests/test/features/test_auth.test.py index 5d9849406..f0e42e5e0 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_auth.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_auth.test.py @@ -102,8 +102,8 @@ def test_02_root_endpoint_shows_auth_enabled(self): self.assertTrue(data['auth']['enabled']) self.assertEqual(data['auth']['algorithm'], 'HS256') - def test_02b_plugin_operation_publishes_its_role_when_auth_is_on(self): - """With auth enabled the document publishes the role an operation needs. + def test_02b_plugin_operation_states_what_this_gateway_enforces(self): + """A plugin's declared role reaches the document, and is then filtered. The other half of this pair is ``test_openapi_contract::test_no_operation_publishes_a_role_when_auth_is_off``: @@ -112,9 +112,19 @@ def test_02b_plugin_operation_publishes_its_role_when_auth_is_on(self): gateway has one value of ``auth.enabled``, and the whole point of the rule is that the document follows it. - The role is ``admin`` because ``AuthConfig``'s ``*`` matches a single - path segment, so no ``viewer`` entry under ``/functions/*`` reaches - this collection and only ADMIN's ``GET:/api/v1/**`` does. + What this fixture pins is the *second* filter. The graph provider + declares ``admin`` on this route, and under ``require_auth_for: all`` + that is what the document publishes and what the middleware demands. + This gateway runs ``write``, under which the middleware admits every + GET - measured, not assumed: the ``/docs`` fetch on the line below + carries no token and succeeds. So the honest published requirement here + is the empty one, and publishing ``admin`` would have promised a check + an anonymous caller does not meet. + + The role-publishing direction has not been dropped; it moved to + ``test_auth_policy_contract.test.py``, which runs one gateway per + ``require_auth_for`` value and can therefore drive both answers for + this same operation. """ spec = requests.get(f'{self.BASE_URL}/docs', timeout=10).json() op = spec['paths'].get( @@ -122,11 +132,30 @@ def test_02b_plugin_operation_publishes_its_role_when_auth_is_on(self): self.assertIsNotNone( op, 'the graph provider route is not documented; the fixture must ' 'load the plugin for this test to mean anything') - self.assertEqual(op.get('security'), [{'bearerAuth': ['admin']}]) - # A requirement may only name a scheme the document defines. + self.assertEqual( + op.get('security'), [], + 'under require_auth_for=write the middleware admits every GET, so ' + 'the plugin route must publish the empty requirement') + # The scheme is defined either way - that is what lets a single + # operation name it - and its presence is the auth-on marker that + # survives a permissive policy. self.assertIn( 'bearerAuth', spec.get('components', {}).get('securitySchemes', {})) + # Anti-vacuous: `security: []` above must mean "this policy admits + # GETs", not "this gateway publishes no roles at all". The writes are + # checked under `write`, and they say so. + roles = { + tuple(req['bearerAuth']) + for item in spec['paths'].values() + for method, operation in item.items() + if method in ('post', 'put', 'patch', 'delete') + for req in operation.get('security', []) + if 'bearerAuth' in req + } + self.assertTrue( + roles, 'no write operation publishes a role; the document has ' + 'stopped naming roles altogether') def test_03_authenticate_valid_credentials(self): """@verifies REQ_INTEROP_086 - Authentication with valid credentials.""" diff --git a/src/ros2_medkit_integration_tests/test/features/test_auth_policy_contract.test.py b/src/ros2_medkit_integration_tests/test/features/test_auth_policy_contract.test.py new file mode 100644 index 000000000..5eff8d5b5 --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/features/test_auth_policy_contract.test.py @@ -0,0 +1,528 @@ +#!/usr/bin/env python3 +# Copyright 2026 bburda +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""What the document says about authentication is what the middleware does. + +``test_rbac_contract.test.py`` asks whether the *role* an operation publishes is +the role the gateway demands, and it asks it under ``require_auth_for: all``. +That leaves the other half unasked: whether an operation should be publishing a +token requirement at all. ``auth.enabled`` is not that answer - enforcement is +``config_.enabled && auth_policy_->requires_authentication(method, path)`` +(``auth_manager.cpp``), so under ``require_auth_for: write`` a GET is served to +an anonymous caller, and under ``none`` so is everything. + +So this file launches three gateways, one per policy, and asks each the same +question about every operation it serves: does the published requirement, and +do the two middleware refusal statuses, match what the middleware does on the +wire? + +Both halves are measured. The published half is read from that gateway's own +``GET /docs``; the enforced half is a real unauthenticated request at the +documented path template. Neither side is assumed from the policy name, so a +policy whose behaviour is not what its name suggests fails here rather than +passing by agreeing with a hard-coded table. + +@verifies REQ_INTEROP_086 +""" + +import tempfile +import time +import unittest + +import launch +import launch_testing +import launch_testing.actions +import pytest +import requests + +from ros2_medkit_test_utils.constants import ( + ALLOWED_EXIT_CODES, + API_BASE_PATH, + get_test_port, +) +from ros2_medkit_test_utils.launch_helpers import ( + create_gateway_node, + full_feature_gateway_params, +) + +HTTP_METHODS = {'get', 'post', 'put', 'delete', 'patch'} + +# One gateway per `require_auth_for` value, each on its own port within this +# test's stride-of-10 allocation. +POLICIES = ('all', 'write', 'none') +PORTS = {policy: get_test_port(offset) for offset, policy in enumerate(POLICIES)} + +JWT_SECRET = 'test_secret_key_for_auth_policy_contract_integration' +CLIENTS = {'admin': 'admin_secret'} + +# Substituted for every `{param}`. Nothing by this name exists, so a probe that +# clears the middleware lands on a handler's 404/501/503 - which is all this +# file needs, since it only ever asks whether the *middleware* refused. +PROBE_ID = 'authpolicyprobe' + +# The two response components the auth middleware owns. A route may declare its +# own 401 - `/auth/authorize` answers one for bad credentials whatever the +# policy - so the presence of the status is not the measurement; the presence of +# *these* components is. +MIDDLEWARE_401 = '#/components/responses/Unauthorized' +MIDDLEWARE_403 = '#/components/responses/Forbidden' + +# `/auth/*` is exempted by every policy the gateway can be configured with, and +# its handlers answer their own RFC 6749 401 for bad credentials - the same body +# shape the middleware uses, which is exactly what `_middleware_refused` reads. +# Probing these paths would therefore measure the handler and call it the +# middleware, so they are checked structurally instead (see +# `test_auth_endpoints_are_published_as_reachable_without_a_token`). +AUTH_PATH_PREFIX = '/auth/' + +_SCRIPTS_DIRS = { + policy: tempfile.mkdtemp(prefix=f'medkit-authpolicy-{policy}-') + for policy in POLICIES +} + + +@pytest.mark.launch_test +def generate_test_description(): + """Launch one authenticated gateway per `require_auth_for` value.""" + nodes = [ + create_gateway_node( + port=PORTS[policy], + name=f'gateway_auth_{policy}', + extra_params={ + 'server.host': '127.0.0.1', + 'auth.enabled': True, + 'auth.jwt_secret': JWT_SECRET, + 'auth.jwt_algorithm': 'HS256', + 'auth.token_expiry_seconds': 3600, + 'auth.require_auth_for': policy, + 'auth.issuer': 'test_gateway', + 'auth.clients': [ + f'{role}:{secret}:admin' for role, secret in CLIENTS.items() + ], + # Every optional gate on, so the routes behind them are in the + # document and on the wire rather than absent from both. + **full_feature_gateway_params(_SCRIPTS_DIRS[policy]), + }, + ) + for policy in POLICIES + ] + + return launch.LaunchDescription([ + *nodes, + launch_testing.actions.ReadyToTest(), + ]), {f'gateway_{policy}': node for policy, node in zip(POLICIES, nodes)} + + +def _base_url(policy): + """Return the API base URL of the gateway running `policy`.""" + return f'http://127.0.0.1:{PORTS[policy]}{API_BASE_PATH}' + + +def _is_sse(operation): + """Report whether this operation is a stream. + + Excluded from every request this file makes: an SSE handler holds the + connection open for the stream's whole lifetime, so a probe would block + until the client timeout rather than answer. + """ + content = operation.get('responses', {}).get('200', {}).get('content', {}) + return 'text/event-stream' in content + + +def _middleware_refused(response): + """Report whether the *middleware* refused, as opposed to a handler. + + Both write 4xx, and the distinction is the whole measurement here. The auth + middleware serialises RFC 6749's ``{"error", "error_description"}`` while a + handler's own 401/403 is the SOVD ``GenericError`` shape with + ``error_code``. + """ + if response.status_code not in (401, 403): + return False + try: + body = response.json() + except ValueError: + return True + return 'error' in body and 'error_code' not in body + + +def _response_ref(operation, status): + """Return the ``$ref`` target this operation gives `status`, or ``''``.""" + entry = operation.get('responses', {}).get(status) + if not isinstance(entry, dict): + return '' + return entry.get('$ref', '') + + +class AuthPolicyContractBase: + """Shared body; one concrete subclass per policy, so failures name it. + + Not a `TestCase` itself - unittest would collect and run it with + `POLICY = None`. The subclasses below mix it with `TestCase`. + """ + + POLICY = None + + @classmethod + def setUpClass(cls): + """Take a token, read the document, and confirm the policy served.""" + cls.session = requests.Session() + cls.base_url = _base_url(cls.POLICY) + cls.token = cls._acquire_token() + cls._spec = cls.session.get( + f'{cls.base_url}/docs', + headers={'Authorization': f'Bearer {cls.token}'}, + timeout=15, + ).json() + + # The gateway states its own policy at the root. Read it rather than + # trusting the launch parameter: without this, a gateway that ignored + # `require_auth_for` would make every assertion below agree with the + # wrong policy and pass. + root = cls.session.get( + cls.base_url, + headers={'Authorization': f'Bearer {cls.token}'}, + timeout=15, + ).json() + served = root.get('auth', {}).get('require_auth_for') + assert served == cls.POLICY, ( + f'asked for require_auth_for={cls.POLICY!r}, gateway on port ' + f'{PORTS[cls.POLICY]} reports {served!r}') + + # Probed once for the whole class. unittest builds a fresh instance per + # test method, so an instance-level cache would re-send several hundred + # requests for each of the four methods below and spend the launch + # budget on it. + cls.enforcement = { + (path, method): _middleware_refused(cls._probe(method, path)) + for path, method, _ in cls._probeable() + } + + @classmethod + def tearDownClass(cls): + cls.session.close() + + @classmethod + def _acquire_token(cls): + """Poll `/auth/authorize` until the gateway hands out an admin token.""" + deadline = time.time() + 60.0 + last = None + while time.time() < deadline: + try: + response = cls.session.post( + f'{cls.base_url}/auth/authorize', + json={ + 'grant_type': 'client_credentials', + 'client_id': 'admin', + 'client_secret': CLIENTS['admin'], + }, + timeout=5, + ) + if response.status_code == 200: + return response.json()['access_token'] + last = f'{response.status_code}: {response.text[:200]}' + except requests.RequestException as exc: + last = str(exc) + time.sleep(0.5) + raise AssertionError( + f'no token from the {cls.POLICY!r} gateway within 60s ' + f'(last: {last})') + + @classmethod + def _operations(cls): + """Yield (path, method, operation) for every documented operation.""" + for path, item in cls._spec['paths'].items(): + for method, operation in item.items(): + if method in HTTP_METHODS: + yield path, method, operation + + @classmethod + def _probeable(cls): + """Yield the operations this file sends a real request to.""" + for path, method, operation in cls._operations(): + if _is_sse(operation) or path.startswith(AUTH_PATH_PREFIX): + continue + yield path, method, operation + + def _published_requires_token(self, operation): + """Read what this operation publishes about needing a token. + + Read the way a client reads it. An absent ``security`` inherits the + document-level requirement, which this gateway emits whenever + ``auth.enabled`` is on, so absent means *required*; the empty list is + OpenAPI's explicit override for "reachable with no token". + """ + requirement = operation.get('security') + if requirement is None: + return bool(self._spec.get('security')) + return requirement != [] + + @classmethod + def _probe(cls, method, path): + """Send one unauthenticated request at a documented path template.""" + url = cls.base_url + path + while '{' in url: + start = url.index('{') + end = url.index('}', start) + url = url[:start] + PROBE_ID + url[end + 1:] + kwargs = {'timeout': 15} + if method in ('post', 'put', 'patch'): + kwargs['json'] = {} + return cls.session.request(method.upper(), url, **kwargs) + + # -- the contract ------------------------------------------------------ + + def test_a_published_token_requirement_is_one_the_gateway_enforces(self): + """Publish a token requirement exactly where the middleware demands one. + + Both directions are defects and both are reported. Publishing where + nothing is enforced sends a caller looking for credentials it does not + need; not publishing where the middleware refuses leaves a caller with + no way to learn why. + """ + mismatched = sorted( + f'{method.upper()} {path}: published=' + f'{self._published_requires_token(op)} enforced={enforced}' + for (path, method), enforced in self.enforcement.items() + for op in [self._spec['paths'][path][method]] + if self._published_requires_token(op) != enforced + ) + self.assertEqual( + mismatched, [], + f'under require_auth_for={self.POLICY!r}, ' + f'{len(mismatched)} operations publish a token requirement the ' + f'middleware does not match: {mismatched[:20]}') + + def test_the_middleware_never_claims_a_refusal_it_will_not_make(self): + """The `Unauthorized`/`Forbidden` components appear only where enforced. + + Keyed on the component rather than on the status, and that is the + point. A route may answer its own 401 or its own 403 for reasons that + have nothing to do with authentication - a refresh token the handler + rejects, a provider refusing a transition, a lock held by another + client - and those shadow the middleware component by design + (`route_registry.cpp`, first-wins). Reading the status alone would call + every one of them a middleware claim. + """ + overclaimed = sorted( + f'{method.upper()} {path}: {status}' + for (path, method), enforced in self.enforcement.items() + if not enforced + for status, component in (('401', MIDDLEWARE_401), + ('403', MIDDLEWARE_403)) + if _response_ref(self._spec['paths'][path][method], + status) == component + ) + self.assertEqual( + overclaimed, [], + f'under require_auth_for={self.POLICY!r}, ' + f'{len(overclaimed)} operation/status pairs name the middleware ' + f'refusal component on an operation the middleware admits: ' + f'{overclaimed[:20]}') + + def test_a_refused_operation_declares_both_refusal_statuses(self): + """Where the middleware does refuse, the caller can read both statuses. + + Satisfied by the middleware's own component or by a route-declared + response of the same status - the two are interchangeable to a caller + reading which statuses an operation can answer, and only the first + carries the middleware's headers. + """ + undeclared = sorted( + f'{method.upper()} {path}: {status}' + for (path, method), enforced in self.enforcement.items() + if enforced + for status in ('401', '403') + if status not in self._spec['paths'][path][method].get( + 'responses', {}) + ) + self.assertEqual( + undeclared, [], + f'under require_auth_for={self.POLICY!r}, ' + f'{len(undeclared)} operation/status pairs are reachable through ' + f'the middleware but undeclared: {undeclared[:20]}') + + def test_auth_endpoints_are_published_as_reachable_without_a_token(self): + """`/auth/*` is exempt under every policy, and says so. + + Checked from the document rather than the wire: these handlers answer + RFC 6749 errors themselves, in the body shape `_middleware_refused` + reads, so a probe here cannot tell the two apart. + """ + for path, method, operation in self._operations(): + if not path.startswith(AUTH_PATH_PREFIX): + continue + with self.subTest(path=path, method=method): + self.assertEqual( + operation.get('security'), [], + f'{method.upper()} {path} is exempted by every policy but ' + f'publishes {operation.get("security")!r}') + self.assertNotEqual(_response_ref(operation, '401'), + MIDDLEWARE_401) + self.assertNotEqual(_response_ref(operation, '403'), + MIDDLEWARE_403) + + def test_the_probe_covers_the_maximal_route_surface(self): + """Anti-vacuous: the assertions above ran against a full gateway. + + The fixture turns every optional gate on, so a document that had + collapsed to the handful of always-on endpoints - or a probe that + skipped everything - would leave the loops above passing on nothing. + """ + self.assertGreater( + len(self.enforcement), 200, + f'only {len(self.enforcement)} operations probed under ' + f'{self.POLICY!r}') + + def test_a_scoped_docs_item_publishes_the_role_of_the_route_it_names(self): + """The `/docs` sub-documents follow the policy too. + + `test_openapi_contract` compares a built item against its templated + sibling, but its fixture runs with authentication off, so `security` is + `None` on both sides there and the comparison passes without ever + testing the dimension it was written for. This is the only place in the + suite that fetches a scoped sub-document from an authenticated gateway, + and it runs once per `require_auth_for` value. + + `x-sovd-name` marks a built item; the projection at the same key has + none. Without that check a discarded item would leave the projection + behind and every assertion here would pass on it. + """ + built = 0 + offenders = [] + for entity_type in ('apps', 'components'): + listing = self.session.get(f'{self.base_url}/{entity_type}', + headers={'Authorization': + f'Bearer {self.token}'}, + timeout=15) + if listing.status_code != 200: + continue + for entity in listing.json().get('items', []): + for collection, parameter in (('data', 'data_id'), + ('operations', 'operation_id')): + doc = self._authed_json( + f'/{entity_type}/{entity["id"]}/{collection}/docs') + if doc is None: + continue + paths = doc.get('paths', {}) + template = (f'/{entity_type}/{entity["id"]}/{collection}/' + f'{{{parameter}}}') + sibling = paths.get(template, {}) + for key, path_item in paths.items(): + if 'x-sovd-name' not in path_item: + continue + built += 1 + for method, operation in path_item.items(): + if method not in HTTP_METHODS: + continue + expected = sibling.get(method, {}).get('security') + if operation.get('security') != expected: + offenders.append( + f'{self.POLICY}: {method.upper()} {key} ' + f'security {operation.get("security")} != ' + f'{expected}') + self.assertEqual( + offenders, [], + f'built items disagreeing with their route: {offenders[:10]}') + self.assertGreater( + built, 0, + f'under {self.POLICY!r} no scoped sub-document published a ' + f'cache-derived item; this test checked nothing') + + def _authed_json(self, path): + """GET `path` with this class's token, or None when not served.""" + response = self.session.get( + f'{self.base_url}{path}', + headers={'Authorization': f'Bearer {self.token}'}, timeout=15) + if response.status_code != 200: + return None + return response.json() + + +class TestAuthPolicyAll(AuthPolicyContractBase, unittest.TestCase): + """`require_auth_for: all` - every path outside `/auth/*` is checked.""" + + POLICY = 'all' + + def test_every_probed_operation_is_enforced(self): + """The policy's own behaviour, stated rather than inferred. + + Every other assertion in this file is a consistency check, and + consistency cannot notice a policy change: a policy that stopped + enforcing anything would move the document with it and leave the suite + green. These three tests are the second opinion. + """ + unenforced = sorted(f'{m.upper()} {p}' + for (p, m), e in self.enforcement.items() + if not e) + self.assertEqual(unenforced, [], f'not refused under `all`: ' + f'{unenforced[:20]}') + + +class TestAuthPolicyWrite(AuthPolicyContractBase, unittest.TestCase): + """`require_auth_for: write` - writes are checked, reads are not.""" + + POLICY = 'write' + + def test_reads_are_open_and_writes_are_checked(self): + """The split this policy exists for, measured on the wire.""" + by_method = {} + for (path, method), enforced in self.enforcement.items(): + by_method.setdefault(method, set()).add(enforced) + self.assertEqual(by_method.get('get'), {False}, + 'a GET was refused under `write`') + for method in ('post', 'put', 'patch', 'delete'): + if method in by_method: + self.assertEqual( + by_method[method], {True}, + f'a {method.upper()} was admitted under `write`') + # Both halves non-empty, or the assertions above are vacuous. + self.assertIn('get', by_method) + self.assertTrue(by_method.keys() & {'post', 'put', 'patch', 'delete'}) + + +class TestAuthPolicyNone(AuthPolicyContractBase, unittest.TestCase): + """`require_auth_for: none` - `auth.enabled` on, nothing checked.""" + + POLICY = 'none' + + def test_no_probed_operation_is_enforced(self): + """Authentication configured and issuing tokens, enforcing nothing.""" + enforced = sorted(f'{m.upper()} {p}' + for (p, m), e in self.enforcement.items() if e) + self.assertEqual(enforced, [], f'refused under `none`: {enforced[:20]}') + + def test_tokens_are_still_issued(self): + """`none` disables enforcement, not the auth endpoints themselves. + + Without this the class above would also pass on a gateway that had + turned authentication off altogether, which is a different + configuration with a different document (no document-level + requirement at all). + """ + self.assertTrue(self.token) + self.assertTrue(self._spec.get('security'), + 'auth.enabled is on, so the document keeps its ' + 'document-level security requirement') + + +@launch_testing.post_shutdown_test() +class TestAuthPolicyShutdown(unittest.TestCase): + """All three gateways exit cleanly.""" + + def test_exit_codes(self, proc_info): + """Check that every gateway process exited with an allowed code.""" + launch_testing.asserts.assertExitCodes( + proc_info, allowable_exit_codes=ALLOWED_EXIT_CODES) 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 7372de512..903a42b74 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 @@ -132,10 +132,16 @@ def test_root_includes_apps_endpoints(self): def test_endpoint_list_names_each_route_once(self): """The endpoints list names each mounted route exactly once. - It is the route registry's list plus a short hand-written tail for the - routes mounted outside the registry. Moving a route into the registry + It is the route registry's list, plus every route a loaded plugin + mounts straight onto the HTTP server, plus a short hand-written tail + for what is mounted outside both. Moving a route into the registry without deleting its hand-written entry lists it twice. + Whether the list *agrees with the document* is a different question and + is checked in + ``test_openapi_contract.test.py::test_the_root_list_and_the_document_agree``; + this one only asks that nothing is named twice. + Compared with parameter *names* erased, not as literal strings. The entry this was written for was spelled ``{entity-path}`` by hand and ``{entity_path}`` by the registry, so a literal comparison would have diff --git a/src/ros2_medkit_integration_tests/test/features/test_locking_disabled_contract.test.py b/src/ros2_medkit_integration_tests/test/features/test_locking_disabled_contract.test.py new file mode 100644 index 000000000..e0790c56a --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/features/test_locking_disabled_contract.test.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +# Copyright 2026 bburda +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""A gateway without a LockManager does not sell locking in its document. + +``locking.enabled`` is a deployment setting: ``GatewayNode`` builds the +``LockManager`` only when it is set, and with it off +``HandlerContext::validate_lock_access`` returns success without reading +``X-Client-Id``. No write can then be refused for a lock. + +``RouteEntry::lock_guarded()`` is a registration-time declaration and cannot +see that. It used to publish its three pieces - the +``x-medkit-lock-guarded`` marker, the ``X-Client-Id`` parameter and the 409 - +on every gateway, so a gateway whose own root reported +``capabilities.locking: false`` still described serialised writes on 44 +operations. A client trusting that description gets silent lost updates. + +The locking-*on* half of this pair is +``test_openapi_contract.test.py::test_lock_guarded_set_matches_the_handlers``, +which runs the shipped default. This file is the only fixture in the suite +with locking off, which is why the claim needs its own launch. + +No ``@verifies``: this is a claim about the generated document matching the +deployment, not about a SOVD locking endpoint behaving as the spec requires. +``test_locking.test.py`` carries the endpoint requirements. +""" + +import tempfile +import time +import unittest + +import launch +import launch_testing +import launch_testing.actions +import pytest +import requests + +from ros2_medkit_test_utils.constants import ( + ALLOWED_EXIT_CODES, + API_BASE_PATH, + get_test_port, +) +from ros2_medkit_test_utils.launch_helpers import ( + create_gateway_node, + full_feature_gateway_params, +) + +LOCK_PORT = get_test_port() +BASE_URL = f'http://127.0.0.1:{LOCK_PORT}{API_BASE_PATH}' + +HTTP_METHODS = {'get', 'post', 'put', 'delete', 'patch'} + +# The lock CRUD itself. These routes are served whatever `locking.enabled` +# says - they answer 501 when it is off, which they already declare - and they +# declare an `X-Client-Id` of their own that has nothing to do with +# `lock_guarded()`. Matched on the path, not on the marker, precisely because +# the marker is what this file expects to be absent. +LOCK_COLLECTION_SUFFIXES = ('/locks', '/locks/{lock_id}') + +_SCRIPTS_DIR = tempfile.mkdtemp(prefix='medkit-nolock-') + + +@pytest.mark.launch_test +def generate_test_description(): + """Launch one full-feature gateway with locking turned off.""" + params = dict(full_feature_gateway_params(_SCRIPTS_DIR)) + # `full_feature_gateway_params` turns every optional gate on, which is what + # gives this file the maximal route surface. Locking is the one gate it + # exists to turn back off - spread first, override second, so a future + # change to that helper cannot silently re-enable it here. + params['locking.enabled'] = False + + gateway_node = create_gateway_node( + port=LOCK_PORT, + name='gateway_no_locking', + extra_params={'server.host': '127.0.0.1', **params}, + ) + + return launch.LaunchDescription([ + gateway_node, + launch_testing.actions.ReadyToTest(), + ]), {'gateway_node': gateway_node} + + +def _is_lock_collection(path): + """Report whether `path` is one of the lock CRUD routes.""" + return path.endswith(LOCK_COLLECTION_SUFFIXES) + + +class TestLockingDisabledContract(unittest.TestCase): + """With no LockManager, nothing in the document promises one.""" + + @classmethod + def setUpClass(cls): + """Wait for the gateway, then read its root and its document.""" + cls.session = requests.Session() + deadline = time.time() + 60.0 + last = None + while time.time() < deadline: + try: + response = cls.session.get(f'{BASE_URL}/health', timeout=5) + if response.status_code == 200: + break + last = response.status_code + except requests.RequestException as exc: + last = str(exc) + time.sleep(0.5) + else: + raise AssertionError(f'gateway not ready within 60s (last: {last})') + + cls.root = cls.session.get(BASE_URL, timeout=15).json() + cls.spec = cls.session.get(f'{BASE_URL}/docs', timeout=15).json() + + @classmethod + def tearDownClass(cls): + cls.session.close() + + def operations(self): + """Yield (path, method, operation) for every documented operation.""" + for path, item in self.spec['paths'].items(): + for method, operation in item.items(): + if method in HTTP_METHODS: + yield path, method, operation + + def test_the_gateway_really_has_no_lock_manager(self): + """Grounding, in the document's own terms and on the wire. + + Every assertion below is about the absence of something, and absence + passes for free on the wrong gateway. This is what makes the rest mean + anything: the root reports the same `get_lock_manager() != nullptr` + the registry now reads, and a lock acquired against a *real* entity + comes back 501 rather than 404 - which is the handler's + `check_locking_enabled` speaking, not a missing route. + """ + self.assertFalse(self.root['capabilities']['locking']) + + apps = self.session.get(f'{BASE_URL}/apps', timeout=15).json() + items = apps.get('items', []) + self.assertTrue(items, 'no apps discovered; the probe below would 404 ' + 'for the wrong reason') + entity_id = items[0]['id'] + response = self.session.post( + f'{BASE_URL}/apps/{entity_id}/locks', + json={'lock_expiration': 60}, + headers={'X-Client-Id': 'locking-disabled-probe'}, + timeout=15, + ) + self.assertEqual( + response.status_code, 501, + f'POST /apps/{entity_id}/locks answered {response.status_code}, so ' + f'this gateway is not the locking-off gateway: {response.text[:200]}') + + def test_no_operation_carries_the_lock_guarded_marker(self): + """The marker says a 409 can arrive. None can.""" + marked = sorted(f'{m.upper()} {p}' for p, m, op in self.operations() + if op.get('x-medkit-lock-guarded')) + self.assertEqual( + marked, [], + f'{len(marked)} operations publish x-medkit-lock-guarded on a ' + f'gateway with no LockManager: {marked[:20]}') + + def test_no_operation_outside_the_lock_crud_reads_a_client_id(self): + """`X-Client-Id` is only declared where it is still read. + + The lock CRUD keeps its own - those routes are served and answer 501, + and their parameter was never `lock_guarded()`'s. Everywhere else the + header is now ignored, and a parameter description promising that a + competing client is answered 409 would be the most directly misleading + piece of the three. + """ + offenders = sorted( + f'{m.upper()} {p}' for p, m, op in self.operations() + if not _is_lock_collection(p) + and any(q.get('name') == 'X-Client-Id' + for q in op.get('parameters', [])) + ) + self.assertEqual( + offenders, [], + f'{len(offenders)} operations outside the lock CRUD still declare ' + f'X-Client-Id: {offenders[:20]}') + + def test_no_operation_declares_a_lock_conflict(self): + """A 409 that travels with `X-Client-Id` is a lock 409. + + The three pieces come from one call, so this is the shape the defect + had: 44 operations declaring a 409 *and* the header that decides it. + Other 409s survive and should - a script execution already running, an + update in the wrong state, a lifecycle transition in flight - and this + is keyed so as not to touch them. + """ + offenders = sorted( + f'{m.upper()} {p}' for p, m, op in self.operations() + if not _is_lock_collection(p) + and '409' in op.get('responses', {}) + and any(q.get('name') == 'X-Client-Id' + for q in op.get('parameters', [])) + ) + self.assertEqual( + offenders, [], + f'{len(offenders)} operations declare a lock conflict: ' + f'{offenders[:20]}') + + def test_the_lock_crud_is_still_documented_and_still_served(self): + """Anti-vacuous, and the boundary of the change. + + Turning locking off removes the *claims other routes made about it*, + not the lock endpoints. If this fixture had simply dropped every lock + route, all three assertions above would pass having checked nothing. + """ + lock_paths = sorted({p for p, _, _ in self.operations() + if _is_lock_collection(p)}) + self.assertTrue( + lock_paths, 'the lock CRUD is absent from the document') + # Their own X-Client-Id survived the filter that removed the marker's. + acquire = [op for p, m, op in self.operations() + if _is_lock_collection(p) and m == 'post'] + self.assertTrue(acquire, 'no lock acquire operation documented') + self.assertTrue( + any(q.get('name') == 'X-Client-Id' + for op in acquire for q in op.get('parameters', [])), + 'the lock CRUD lost the X-Client-Id it declares itself') + # And the surface really is the full one, so the loops above ran over + # the gated routes rather than the handful of always-on endpoints. + self.assertGreater(len(list(self.operations())), 200) + + +@launch_testing.post_shutdown_test() +class TestLockingDisabledShutdown(unittest.TestCase): + """The gateway exits cleanly.""" + + def test_exit_codes(self, proc_info): + """Check that the gateway process exited with an allowed code.""" + launch_testing.asserts.assertExitCodes( + proc_info, allowable_exit_codes=ALLOWED_EXIT_CODES) diff --git a/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py b/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py index d350040cc..10017b487 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py @@ -1254,6 +1254,253 @@ def test_every_advertised_collection_is_served(self): covered.get(entity_type, 0), 8, f'{entity_type}: only {covered.get(entity_type, 0)} hrefs followed') + def test_the_root_list_and_the_document_agree(self): + """`GET /api/v1` and `GET /api/v1/docs` describe the same gateway. + + The two lists answer different questions and are allowed to differ - + the root says what is *mounted*, the document says what is + *documented* - but the difference has a rule, and until this test + nothing compared them. They had drifted in both directions: four + `hidden()` bulk-data routes were advertised and undocumented, and the + plugin-served route was documented and unadvertised. + + The rule, checked here rather than asserted from a hand-maintained + list of exceptions: + + * every documented operation is advertised - there is no such thing as + a documented route that is not mounted; + * every advertised endpoint that is *not* documented answers 405 - + which is what `hidden()` is for and the only reason a mounted route + has nothing to document. + + The second half is driven on the wire, so a route that stops answering + 405 - or a route hidden for some other reason - fails here instead of + being absorbed by an exception list. + """ + root = self.get_json('') + advertised = set(root['endpoints']) + documented = { + f'{method.upper()} /api/v1{path}' + for path, method, _ in self.operations() + } + + # Anti-vacuous: both sides must be the real surface. + self.assertGreater(len(advertised), 200, 'root advertised almost nothing') + self.assertGreater(len(documented), 200, 'document described almost nothing') + + undocumented = sorted(advertised - documented) + unadvertised = sorted(documented - advertised) + + self.assertEqual( + unadvertised, [], + f'documented but not advertised by the root: {unadvertised}') + + # Swagger UI is mounted outside the registry and has no operation, so + # it is advertised and undocumented by construction. + undocumented = [e for e in undocumented if not e.endswith('/swagger-ui')] + + not_405 = [] + for endpoint in undocumented: + method, path = endpoint.split(' ', 1) + probe = re.sub(r'\{[^}]+\}', 'rootlistprobe', path) + response = requests.request( + method, f'{self.BASE_URL}{probe[len("/api/v1"):]}', + json={}, timeout=10) + if response.status_code != 405: + not_405.append(f'{endpoint} -> {response.status_code}') + self.assertEqual( + not_405, [], + f'advertised, undocumented, and not a 405 route: {not_405}') + # And the hidden set is not empty, or the loop above proved nothing. + self.assertTrue( + undocumented, + 'no advertised-but-undocumented endpoint; the 405 check is vacuous') + + def test_a_scoped_item_says_what_its_templated_sibling_says(self): + """A built item never contradicts the route it names, at either scope. + + The cache-derived data and operation items are the only paths in a + scoped spec that are built rather than projected. They carry the + concrete path and the ``x-sovd-*`` extensions; everything a client acts + on - the role, the responses, the request body, the lock contract - is + inherited from the projected route they describe, because they *are* + that route. + + Both scopes are compared, not merely visited. ``project()`` substitutes + the ids it was given into the path **keys**, so at collection scope the + sibling is still templated (``/data/{data_id}``) while at + specific-resource scope it has already become concrete + (``/data/temperature``). An earlier version used the collection + document only as a source of siblings, which meant every + collection-scope item could be published completely un-inherited and + this test stayed green. + + Read from the document alone: the sibling states the contract and the + built item must match it, so no second source is needed and none is + trusted. + """ + compared = 0 + built_items = {'data': 0, 'operations': 0} + offenders = [] + for entity_type in ('apps', 'components'): + items = self.get_json(f'/{entity_type}').get('items', []) + if not items: + continue + entity_id = items[0]['id'] + for collection in ('data', 'operations'): + base = f'/{entity_type}/{entity_id}/{collection}' + collection_doc = self.get_json(f'{base}/docs') + collection_paths = collection_doc.get('paths', {}) + # The item parameter is read from the served document, the way + # `CapabilityGenerator` reads it from the registry. Spelling + # `data_id`/`operation_id` here was a second copy of the fact + # that fix removed from production: renaming the registry + # parameter left production working and this guard green having + # compared nothing. + template = self._item_template(collection_paths, base) + self.assertIsNotNone( + template, + f'{base}/docs publishes no templated item route; the ' + f'comparisons below would have no sibling') + + # Collection scope: every built item in the collection document + # against that one template. + for key, path_item in collection_paths.items(): + if 'x-sovd-name' not in path_item: + continue + built_items[collection] += 1 + for method, operation in path_item.items(): + if method not in HTTP_METHODS: + continue + problems, did_compare = self._framework_mismatch( + collection_paths, template, key, method, operation) + compared += did_compare + offenders.extend(problems) + + # Specific-resource scope: the item is republished at the key + # the projection occupied, so it is fetched per resource. + listing = requests.get(f'{self.BASE_URL}{base}', timeout=10) + if listing.status_code != 200: + continue + for entry in listing.json().get('items', []): + resource_id = entry['id'] + scoped = requests.get( + f'{self.BASE_URL}{base}/{resource_id}/docs', timeout=10) + if scoped.status_code != 200: + continue + key = f'{base}/{resource_id.lstrip("/")}' + path_item = scoped.json().get('paths', {}).get(key, {}) + # `x-sovd-name` is written only by `PathBuilder`, so it is + # what tells a *built* item from the projection that sits + # at the same key at this scope. Counting the key alone + # made this test unfalsifiable. + if 'x-sovd-name' not in path_item: + continue + built_items[collection] += 1 + for method, operation in path_item.items(): + if method not in HTTP_METHODS: + continue + problems, did_compare = self._framework_mismatch( + collection_paths, template, key, method, operation) + compared += did_compare + offenders.extend(problems) + + self.assertEqual( + offenders, [], + f'built items contradicting their route: {offenders[:12]}') + # A cache-derived item must *exist*, and a comparison must actually have + # happened. `compared` counts comparisons performed, not operations + # visited: a sibling that is missing is a miss, not a pass, so a guard + # that found no sibling can no longer satisfy this by counting the + # operations it skipped. + # Per collection, not in total. Both are built by the same code down + # different branches, so one can vanish entirely while the other keeps + # the count above zero - which is what happened when a built verb the + # sibling lacked made every *data* item get discarded and this stayed + # green on operations alone. + for collection, count in sorted(built_items.items()): + self.assertGreater( + count, 0, + f'no {collection} sub-document published a cache-derived item ' + f'(none carried x-sovd-name); every comparison over ' + f'{collection} was vacuous') + self.assertGreater( + compared, 0, + 'no built operation was compared against a sibling; the guard ran ' + 'over nothing') + + @staticmethod + def _item_template(collection_paths, base): + """Return the templated item route under `base`, or None. + + The item route is the one key that extends the collection by exactly + one segment and whose segment is a whole ``{param}`` - the same rule + `CapabilityGenerator::single_parameter_segment_under` applies. + """ + for key in collection_paths: + if not key.startswith(base + '/'): + continue + tail = key[len(base) + 1:] + if '/' not in tail and tail.startswith('{') and tail.endswith('}'): + return key + return None + + def _framework_mismatch(self, collection_paths, template, key, method, + operation): + """Compare one built operation against its templated sibling. + + Returns ``(problems, compared)``. A missing sibling is reported rather + than skipped: it used to return no problems, so a lookup that found + nothing counted as a pass everywhere it was called. + """ + sibling = collection_paths.get(template, {}).get(method) + if sibling is None: + return ([f'{method.upper()} {key}: no sibling {method.upper()} at ' + f'{template} to inherit from'], 0) + problems = [] + if operation.get('security') != sibling.get('security'): + problems.append( + f'{method.upper()} {key}: security ' + f'{operation.get("security")} != {sibling.get("security")}') + # Every status, 2xx included. An earlier version carved 2xx out as + # "the payload, meant to differ", which is true of a request body and + # false of a response: the gateway envelopes every read - `DataValue`, + # `OperationDetail` - so a built 200 was a second, contradictory answer + # for one route rather than a more specific one. + built_statuses = set(operation.get('responses', {})) + sibling_statuses = set(sibling.get('responses', {})) + if built_statuses != sibling_statuses: + problems.append( + f'{method.upper()} {key}: statuses {sorted(built_statuses)} != ' + f'{sorted(sibling_statuses)}') + for status in built_statuses & sibling_statuses: + if (operation['responses'][status].get('content') + != sibling['responses'][status].get('content')): + problems.append( + f'{method.upper()} {key}: {status} body differs from the ' + f'route it names') + # The request body, which had no test at any level. Deleting the line + # that inherits it left every suite green while a built `PUT` lost its + # body entirely and a client following the document got back + # `400 "type: missing required field; data: missing required field"` - + # the very defect the inheritance was added to remove. + if operation.get('requestBody') != sibling.get('requestBody'): + problems.append( + f'{method.upper()} {key}: requestBody differs from the route ' + f'it names') + if (operation.get('x-medkit-lock-guarded') + != sibling.get('x-medkit-lock-guarded')): + problems.append(f'{method.upper()} {key}: lock marker differs') + built_headers = {p['name'] for p in operation.get('parameters', []) + if p.get('in') != 'path'} + sibling_headers = {p['name'] for p in sibling.get('parameters', []) + if p.get('in') != 'path'} + if built_headers != sibling_headers: + problems.append( + f'{method.upper()} {key}: non-path parameters ' + f'{sorted(built_headers)} != {sorted(sibling_headers)}') + return (problems, 1) + @launch_testing.post_shutdown_test() class TestShutdown(unittest.TestCase): From 6f259c6ecdb2c268a9e9f2d7257845dbc3a9299c Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Wed, 5 Aug 2026 09:05:37 +0200 Subject: [PATCH 15/17] build(graph-provider): export compile_commands so the plugin can be linted The package set no CMAKE_EXPORT_COMPILE_COMMANDS, so it produced no compile_commands.json and clang-tidy run standalone could not resolve the gateway headers graph_provider_plugin_exports.cpp includes - reporting them as missing on a file that compiles clean. Nine other packages already set it; this one was the gap, and it only surfaced now because this work touches that file. --- .../ros2_medkit_graph_provider/CMakeLists.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ros2_medkit_plugins/ros2_medkit_graph_provider/CMakeLists.txt b/src/ros2_medkit_plugins/ros2_medkit_graph_provider/CMakeLists.txt index eca2db8eb..7f948f20c 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_graph_provider/CMakeLists.txt +++ b/src/ros2_medkit_plugins/ros2_medkit_graph_provider/CMakeLists.txt @@ -18,6 +18,11 @@ project(ros2_medkit_graph_provider) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) +# Without this the package exports no compile_commands.json, so clang-tidy run +# standalone - as the pre-push hook runs it - cannot resolve the gateway headers +# this plugin includes and reports them as missing on files that compile clean. +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + # Shared cmake modules (multi-distro compat) find_package(ros2_medkit_cmake REQUIRED) include(ROS2MedkitCompat) From 9429c5cf3cb02cb60136d27fa2f73812d48ef43d Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Wed, 5 Aug 2026 09:30:13 +0200 Subject: [PATCH 16/17] fix(log-bridge): clamp two node parameters at the width they are read in declare_parameter yields int64_t, so narrowing to int before the range check let a value past INT_MAX truncate into range and pass it - severity_floor would have been accepted, and max_tracked_nodes could wrap to a small positive bound. Both now clamp as int64_t and narrow afterwards. The -Wconversion warnings these raised are pre-existing on main; this branch already touches the file, and the gate is zero warnings. --- .../src/log_bridge_node.cpp | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/ros2_medkit_log_bridge/src/log_bridge_node.cpp b/src/ros2_medkit_log_bridge/src/log_bridge_node.cpp index 10c980d74..e6b085cc5 100644 --- a/src/ros2_medkit_log_bridge/src/log_bridge_node.cpp +++ b/src/ros2_medkit_log_bridge/src/log_bridge_node.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include "ros2_medkit_msgs/msg/fault.hpp" @@ -65,13 +66,15 @@ void LogBridgeNode::load_parameters() { // Default floor is WARN. WARN passes through each node's FaultReporter // LocalFilter (threshold/window debounce); ERROR/FATAL bypass it. Raise to 40 // (ERROR) on chatty / constrained targets to cut volume. - const int floor = declare_parameter("severity_floor", kLevelWarn); + // declare_parameter yields int64_t, so clamp in that width first: a + // value past INT_MAX would otherwise truncate into range and pass the check. + const int64_t floor = declare_parameter("severity_floor", kLevelWarn); // int -> uint8_t silently wraps; clamp so a bad value cannot pass everything. if (floor < 0 || floor > kLevelFatal) { - RCLCPP_WARN(get_logger(), "severity_floor=%d out of range [0,%u], clamping", floor, + RCLCPP_WARN(get_logger(), "severity_floor=%ld out of range [0,%u], clamping", static_cast(floor), static_cast(kLevelFatal)); } - severity_floor_ = static_cast(std::clamp(floor, 0, static_cast(kLevelFatal))); + severity_floor_ = static_cast(std::clamp(floor, 0, kLevelFatal)); // Normalize the prefix so a non-conforming value cannot yield a fault_code // violating medkit's [A-Z0-9_] charset. code_prefix_ = to_upper_snake(declare_parameter("code_prefix", "LOG"), 32); @@ -80,10 +83,10 @@ void LogBridgeNode::load_parameters() { } exclude_nodes_ = declare_parameter>("exclude_nodes", std::vector{}); include_only_nodes_ = declare_parameter>("include_only_nodes", std::vector{}); - max_tracked_nodes_ = declare_parameter("max_tracked_nodes", 512); - if (max_tracked_nodes_ < 1) { - max_tracked_nodes_ = 1; - } + // Same width caveat: clamp as int64_t so a value past INT_MAX cannot wrap + // into a small positive bound. + const int64_t tracked = declare_parameter("max_tracked_nodes", 512); + max_tracked_nodes_ = static_cast(std::clamp(tracked, 1, std::numeric_limits::max())); report_cooldown_sec_ = declare_parameter("report_cooldown_sec", 5.0); if (report_cooldown_sec_ < 0.0) { report_cooldown_sec_ = 0.0; From d70e4631a3f27dec33bc0e2c847ba71af76b5201 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Wed, 5 Aug 2026 12:05:10 +0200 Subject: [PATCH 17/17] fix(gateway): drain in-flight update tasks before the notifier is freed UpdateManager runs prepare/execute on their own std::async threads and each calls ResourceChangeNotifier::notify() once the backend returns, so a task can still be inside the backend when the gateway tears down. ~UpdateManager is what joins those tasks, but update_mgr_ is declared before resource_change_notifier_ and C++ destroys members in reverse declaration order, so the notifier was freed first. A task waking up afterwards wrote to freed memory - ASan reported heap-use-after-free on the fetch_add that opens notify(), from run_prepare() on a std::async thread. The counter is the function's own lifetime guard, so it cannot help here: it only protects a call already in progress, not one that starts after the object is gone. Give UpdateManager an explicit shutdown() that stops accepting work and joins every in-flight task, and call it from ~GatewayNode before the notifier is shut down. Draining at a defined point makes the ordering independent of member declaration order. The destructor delegates to shutdown() so standalone use keeps the previous join behaviour, and moving the futures out makes repeat calls a no-op. The stopped_ check in the three start_*() entry points moves under mutex_. Previously it was read before the lock, leaving a window where a call could pass the check and then launch after shutdown() had already snapshotted the futures, escaping the drain. Covered by test_openapi_response_drift under ASan, which is what first drove this path, plus unit tests pinning that shutdown() blocks while a task is in flight and is safe to call twice. --- src/ros2_medkit_gateway/design/index.rst | 8 ++ .../core/managers/update_manager.hpp | 11 +++ .../src/core/managers/update_manager.cpp | 30 ++++-- src/ros2_medkit_gateway/src/gateway_node.cpp | 12 +++ .../test/test_update_manager.cpp | 99 +++++++++++++++++++ 5 files changed, 152 insertions(+), 8 deletions(-) diff --git a/src/ros2_medkit_gateway/design/index.rst b/src/ros2_medkit_gateway/design/index.rst index cded4e2ad..8ddb2102d 100644 --- a/src/ros2_medkit_gateway/design/index.rst +++ b/src/ros2_medkit_gateway/design/index.rst @@ -640,6 +640,14 @@ It consists of five main components: - Observers (TriggerManager) register callbacks with filters - ``notify()`` is non-blocking - pushes to an internal queue processed by a dedicated worker thread - Filters support collection, entity_id, and resource_path matching + - **Teardown contract:** producers hold a non-owning ``ResourceChangeNotifier *``, so every producer must be + quiesced before the notifier is destroyed. ``notify()``'s internal drain guard only protects calls that are + already in progress - it cannot help a call that starts after the object is freed. Most producers notify from + executor callbacks, which have already stopped by the time ``~GatewayNode`` runs. ``UpdateManager`` is the + exception: its prepare/execute tasks run on their own ``std::async`` threads and notify after the backend + returns, so ``~GatewayNode`` calls ``UpdateManager::shutdown()`` (step 4b) to join them before step 5 shuts the + notifier down. Relying on member declaration order alone is not sufficient - ``update_mgr_`` is declared before + ``resource_change_notifier_``, so reverse destruction frees the notifier first. 3. **ConditionRegistry** ``[gateway_core]`` - Thread-safe registry for condition evaluators. diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/managers/update_manager.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/managers/update_manager.hpp index cc0a93a5e..e90f3ef49 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/managers/update_manager.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/managers/update_manager.hpp @@ -74,8 +74,19 @@ class UpdateManager { void set_backend(UpdateProvider * backend); /// Set optional notifier for broadcasting update status changes to trigger subsystem. + /// The notifier is non-owning and MUST outlive every in-flight update task. + /// Owners guarantee that by calling shutdown() before the notifier goes away. void set_notifier(ResourceChangeNotifier * notifier); + /// Stop accepting new operations and wait for every in-flight task to finish. + /// + /// Update tasks run on their own std::async threads and call + /// ResourceChangeNotifier::notify() after the backend returns, so they can + /// outlive whatever owns the notifier. Owners must call this before the + /// notifier (or the backend) is destroyed. Idempotent; also called from the + /// destructor, which is the backstop for standalone use. + void shutdown(); + /// Check if a backend is loaded bool has_backend() const; diff --git a/src/ros2_medkit_gateway/src/core/managers/update_manager.cpp b/src/ros2_medkit_gateway/src/core/managers/update_manager.cpp index cd8bd31ad..5e9d5f103 100644 --- a/src/ros2_medkit_gateway/src/core/managers/update_manager.cpp +++ b/src/ros2_medkit_gateway/src/core/managers/update_manager.cpp @@ -21,14 +21,21 @@ namespace ros2_medkit_gateway { UpdateManager::UpdateManager() = default; UpdateManager::~UpdateManager() { - // Signal background tasks to stop accepting new work - stopped_ = true; + shutdown(); +} +void UpdateManager::shutdown() { // Collect all valid futures, then wait OUTSIDE the lock to avoid // deadlock (async tasks also acquire mutex_ during execution). std::vector> futures; { std::lock_guard lock(mutex_); + // Signal background tasks to stop accepting new work. Setting the flag + // under mutex_ closes the window where a start_*() call has already passed + // its stopped_ check but has not yet launched: those functions re-check the + // flag under this same lock, so nothing can be launched past this point and + // escape the drain below. + stopped_ = true; for (auto & [id, state] : states_) { if (state && state->active_task.valid()) { futures.push_back(std::move(state->active_task)); @@ -38,6 +45,9 @@ UpdateManager::~UpdateManager() { for (auto & f : futures) { f.wait(); } + // Moving the futures out above leaves every active_task invalid, so a second + // call collects nothing and returns immediately - shutdown() is idempotent + // without needing a separate guard flag. } void UpdateManager::set_backend(UpdateProvider * backend) { @@ -188,12 +198,14 @@ tl::expected UpdateManager::start_prepare(const std::string & if (!backend_) { return tl::make_unexpected(UpdateError{UpdateErrorCode::NoBackend, "No update backend loaded"}); } + std::lock_guard lock(mutex_); + + // Checked under mutex_ so a task can never be launched after shutdown() has + // taken its snapshot of the in-flight futures (see UpdateManager::shutdown). if (stopped_) { return tl::make_unexpected(UpdateError{UpdateErrorCode::Internal, "UpdateManager is shutting down"}); } - std::lock_guard lock(mutex_); - // Verify package exists while holding lock to prevent concurrent deletion auto pkg = backend_->get_update(id); if (!pkg) { @@ -224,12 +236,13 @@ tl::expected UpdateManager::start_execute(const std::string & if (!backend_) { return tl::make_unexpected(UpdateError{UpdateErrorCode::NoBackend, "No update backend loaded"}); } + std::lock_guard lock(mutex_); + + // Checked under mutex_ - see the note in start_prepare(). if (stopped_) { return tl::make_unexpected(UpdateError{UpdateErrorCode::Internal, "UpdateManager is shutting down"}); } - std::lock_guard lock(mutex_); - auto pkg = backend_->get_update(id); if (!pkg) { return tl::make_unexpected(UpdateError{UpdateErrorCode::NotFound, pkg.error().message}); @@ -255,12 +268,13 @@ tl::expected UpdateManager::start_automated(const std::string if (!backend_) { return tl::make_unexpected(UpdateError{UpdateErrorCode::NoBackend, "No update backend loaded"}); } + std::lock_guard lock(mutex_); + + // Checked under mutex_ - see the note in start_prepare(). if (stopped_) { return tl::make_unexpected(UpdateError{UpdateErrorCode::Internal, "UpdateManager is shutting down"}); } - std::lock_guard lock(mutex_); - auto supported = backend_->supports_automated(id); if (!supported) { return tl::make_unexpected(UpdateError{UpdateErrorCode::NotFound, supported.error().message}); diff --git a/src/ros2_medkit_gateway/src/gateway_node.cpp b/src/ros2_medkit_gateway/src/gateway_node.cpp index 03c6ea9f7..57fbbc30b 100644 --- a/src/ros2_medkit_gateway/src/gateway_node.cpp +++ b/src/ros2_medkit_gateway/src/gateway_node.cpp @@ -1534,6 +1534,18 @@ GatewayNode::~GatewayNode() { if (trigger_mgr_) { trigger_mgr_->shutdown(); } + // 4b. Drain in-flight update tasks BEFORE the notifier is shut down and + // freed. Update tasks run on their own std::async threads (not the + // executor), and each calls ResourceChangeNotifier::notify() once the + // backend returns. ~UpdateManager is what joins them, but update_mgr_ is + // declared before resource_change_notifier_, so reverse member + // destruction frees the notifier FIRST - a task still inside prepare() + // then wrote to freed memory (ASan heap-use-after-free in notify()). + // Draining here makes the join order explicit instead of a consequence of + // declaration order. + if (update_mgr_) { + update_mgr_->shutdown(); + } // 5. Shutdown resource change notifier (stops worker thread) if (resource_change_notifier_) { resource_change_notifier_->shutdown(); diff --git a/src/ros2_medkit_gateway/test/test_update_manager.cpp b/src/ros2_medkit_gateway/test/test_update_manager.cpp index 1bb078f82..f9384e4f8 100644 --- a/src/ros2_medkit_gateway/test/test_update_manager.cpp +++ b/src/ros2_medkit_gateway/test/test_update_manager.cpp @@ -14,10 +14,15 @@ #include +#include #include +#include +#include +#include #include #include "ros2_medkit_gateway/core/managers/update_manager.hpp" +#include "ros2_medkit_gateway/core/resource_change_notifier.hpp" using namespace ros2_medkit_gateway; using json = nlohmann::json; @@ -698,3 +703,97 @@ TEST(UpdateStatusToJson, EmitsNonePhaseForFreshlyRegistered) { ASSERT_TRUE(j.contains("x-medkit")); EXPECT_EQ(j.at("x-medkit").at("phase"), "none"); } + +/// Backend that parks inside prepare() until the test releases it, so a task +/// can be held demonstrably in flight across a shutdown() call. +class GatedUpdateBackend : public MockUpdateBackend { + public: + tl::expected prepare(const std::string & /*id*/, + UpdateProgressReporter & /*reporter*/) override { + entered_.set_value(); + released_.get_future().wait(); + return {}; + } + + /// Blocks until prepare() is actually running on the async task thread. + void wait_until_in_prepare() { + entered_.get_future().wait(); + } + + /// Lets prepare() return. + void release() { + released_.set_value(); + } + + private: + std::promise entered_; + std::promise released_; +}; + +// Update tasks run on their own std::async threads and call +// ResourceChangeNotifier::notify() after the backend returns, so they can +// outlive the notifier. shutdown() is the drain owners rely on: GatewayNode +// calls it before the notifier is shut down and freed. If shutdown() stops +// waiting for in-flight tasks, the notify below lands on a destroyed notifier +// - which is exactly the heap-use-after-free ASan reported from run_prepare(). +TEST(UpdateManagerLifetime, ShutdownDrainsInFlightTasksBeforeNotifierIsDestroyed) { + GatedUpdateBackend backend; + auto notifier = std::make_unique(); + + std::atomic terminal_notifications{0}; + notifier->subscribe(NotifierFilter{"updates", "", ""}, [&terminal_notifications](const ResourceChange & change) { + if (change.value.value("status", std::string{}) == "completed") { + terminal_notifications.fetch_add(1); + } + }); + + UpdateManager manager; + manager.set_backend(&backend); + manager.set_notifier(notifier.get()); + + ASSERT_TRUE(manager.register_update(json{{"id", "gated-pkg"}, {"update_name", "Gated"}}).has_value()); + ASSERT_TRUE(manager.start_prepare("gated-pkg").has_value()); + backend.wait_until_in_prepare(); + + // The task is now parked inside prepare(), so it has NOT yet notified. + // shutdown() must block until it has. Releasing from another thread after a + // delay makes that the measured quantity: if shutdown() fails to drain it + // returns while the task is still parked, and the check below fires. Without + // the delay the task wins the race on its own and the test proves nothing. + std::atomic shutdown_returned{false}; + std::thread releaser([&backend, &shutdown_returned]() { + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + EXPECT_FALSE(shutdown_returned.load()) << "shutdown() returned while a task was still inside prepare()"; + backend.release(); + }); + + manager.shutdown(); + shutdown_returned.store(true); + releaser.join(); + + // Draining the notifier delivers everything notify() enqueued. worker_loop() + // only exits once the queue is empty, so after this the count is final. + notifier->shutdown(); + EXPECT_EQ(terminal_notifications.load(), 1) << "shutdown() returned before the in-flight task notified"; + + // With the task drained, destroying the notifier while the manager is still + // alive is safe - the ordering GatewayNode relies on. + notifier.reset(); +} + +// shutdown() must be safe to call more than once: GatewayNode calls it during +// teardown and ~UpdateManager calls it again as the standalone backstop. +TEST(UpdateManagerLifetime, ShutdownIsIdempotentAndRejectsLaterStarts) { + MockUpdateBackend backend; + UpdateManager manager; + manager.set_backend(&backend); + + ASSERT_TRUE(manager.register_update(json{{"id", "pkg"}, {"update_name", "Test"}}).has_value()); + + manager.shutdown(); + manager.shutdown(); + + auto prep = manager.start_prepare("pkg"); + ASSERT_FALSE(prep.has_value()); + EXPECT_EQ(prep.error().code, UpdateErrorCode::Internal); +}