diff --git a/docs/api/locking.rst b/docs/api/locking.rst index cec70ed3c..50c382633 100644 --- a/docs/api/locking.rst +++ b/docs/api/locking.rst @@ -147,6 +147,106 @@ 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. + +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`` +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. + +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 663a0875c..7397fbecb 100644 --- a/docs/api/rest.rst +++ b/docs/api/rest.rst @@ -13,6 +13,53 @@ 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. + + 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 + 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 ------------------- @@ -197,9 +244,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 ~~~~~~~~~~ @@ -677,12 +725,29 @@ 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. + 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 @@ -755,7 +820,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``) @@ -803,7 +873,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:** @@ -977,14 +1049,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. @@ -1223,20 +1314,50 @@ 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`` +**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:** .. code-block:: bash 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 ~~~~~~~~~~~~~~~~ @@ -1508,6 +1629,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:** @@ -1627,6 +1751,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 ~~~~~~~~~~~~~ @@ -1689,7 +1855,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 @@ -1777,6 +1943,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 @@ -1954,26 +2123,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: @@ -2086,8 +2285,15 @@ 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. + 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 @@ -2095,7 +2301,6 @@ way as every other endpoint. "items": [ { "id": "ftr_1", - "app_id": "tank_process", "data_name": "level", "operator": ">=", "threshold": 80.0, @@ -2110,18 +2315,23 @@ 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 - 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; the correlation cascade is skipped so the clear stays scoped to the rule's own fault. +.. _rate-limiting: + Rate Limiting ------------- @@ -2159,7 +2369,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, @@ -2168,15 +2378,80 @@ 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``. + +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. @@ -2468,23 +2743,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 @@ -2492,30 +2792,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 - - Insufficient permissions for this operation + - A lifecycle provider refused the transition (``AccessDenied``). Not a + lock error - the lock routes never emit this code. + * - ``forbidden`` + - 403 + - 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. @@ -2529,12 +2870,226 @@ 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: + +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 ~~~~~~~~~~~~~~~~~~~~~~~~ @@ -2590,7 +3145,40 @@ 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``, 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 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 +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 @@ -2608,6 +3196,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 @@ -2650,12 +3250,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 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 + ``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: @@ -2740,30 +3369,150 @@ 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. + +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 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``) -- 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) +- 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``, + ``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. +- 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/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/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/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_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/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/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_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/CMakeLists.txt b/src/ros2_medkit_gateway/CMakeLists.txt index 0d887ad71..078e43b9b 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) @@ -351,6 +363,34 @@ 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") + + # ─── 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 @@ -1000,6 +1040,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/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/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/design/aggregation.rst b/src/ros2_medkit_gateway/design/aggregation.rst index 388a1229a..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. @@ -414,15 +421,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 af00242ac..bf3651976 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 ------------ @@ -87,11 +96,11 @@ 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) - + docs_subtree(regex, handler) + + docs_subtree(openapi_path, regex, handler) } class OpenApiSpecBuilder { @@ -127,6 +136,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 +154,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 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -276,7 +334,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 @@ -322,27 +380,107 @@ 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-from-the-return-type: + +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_location(api_path("/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. + +``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`` +(``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 ~~~~~~~~~~~~~~~~~~~~~~ @@ -352,19 +490,506 @@ 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"). + +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, +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 ``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 +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. + +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. 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 + 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. +- ``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. +- ``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 + ``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. +- ``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 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 + 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 ``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 +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. + +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 +``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. + +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. + +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 +----------------------- + +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 - + 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 + 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 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, + 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 -------------- @@ -384,18 +1009,80 @@ 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 *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``, + 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, 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 - ``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. + + 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 @@ -404,16 +1091,23 @@ 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 ``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()``) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -463,8 +1157,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", @@ -482,11 +1182,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 @@ -573,7 +1300,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, @@ -581,7 +1313,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``). @@ -591,6 +1323,32 @@ 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. + +``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 @@ -707,13 +1465,74 @@ 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 - the new route. -6. Add URI field to entity detail response if the new route is a resource +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. 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. -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..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. @@ -676,5 +684,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/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/design/openapi_derivation.rst b/src/ros2_medkit_gateway/design/openapi_derivation.rst new file mode 100644 index 000000000..fb12b2cf3 --- /dev/null +++ b/src/ros2_medkit_gateway/design/openapi_derivation.rst @@ -0,0 +1,602 @@ +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`` 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 +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..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 @@ -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 @@ -107,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 @@ -194,6 +230,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/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/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/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/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/http/handlers/bulkdata_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/bulkdata_handlers.hpp index ef4266420..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 @@ -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); @@ -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/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/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/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..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 @@ -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,10 @@ 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, 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/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/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/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/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/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/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/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..90a305ecc --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/openapi/document_checks.hpp @@ -0,0 +1,59 @@ +// 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 + +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); + +/// 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..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 @@ -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; @@ -227,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; @@ -265,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/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/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/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/entities.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/entities.hpp index 98a2da99c..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 @@ -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,17 +377,24 @@ 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; 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; - 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 +404,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/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/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 fe6d8fe93..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"; @@ -248,7 +254,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..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,7 +27,9 @@ #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" #include "ros2_medkit_gateway/dto/health.hpp" #include "ros2_medkit_gateway/dto/lifecycle.hpp" @@ -36,6 +38,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" @@ -47,11 +50,14 @@ namespace dto { /// (Phase 2/3) appends its types here. Order is irrelevant. using AllDtos = std::tuple, Collection, - Collection, Collection, FaultListItem, Collection, + 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, 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 +65,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 +79,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..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 { @@ -67,52 +74,88 @@ 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(); + // 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); } - props[std::string(f.key)] = prop; - if (f.presence == Presence::kRequired) { - required.push_back(std::string(f.key)); + }; + 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])); } + 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) { + required.push_back(std::string(f.key)); } - }); - nlohmann::json schema = {{"type", "object"}, {"properties", props}}; - if (!required.empty()) { - schema["required"] = required; } - 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..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 @@ -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 { @@ -86,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 { @@ -103,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"; @@ -135,6 +156,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/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/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/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 new file mode 100644 index 000000000..c362a09a3 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/detail/status_recorder.hpp @@ -0,0 +1,154 @@ +// 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: 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. + */ + +#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/handler_result.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handler_result.hpp index 5296b90ad..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 @@ -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. @@ -91,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 ca6db0823..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 @@ -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,9 +68,9 @@ class CyclicSubscriptionHandlers { /// POST /{entity}/cyclic-subscriptions - create subscription. /// - /// On success returns the new `CyclicSubscription` body with a 201 status - /// override. - http::Result> + /// 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. 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/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/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/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/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/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/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 f6e25af0c..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 @@ -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 { @@ -57,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; @@ -66,11 +75,76 @@ 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}; + +/// 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: + 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; + // 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; +} + +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; + out.reserve(kAllParameterErrorCodes.size()); + 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/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/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/document_checks.cpp b/src/ros2_medkit_gateway/src/core/openapi/document_checks.cpp new file mode 100644 index 000000000..ca5cfd970 --- /dev/null +++ b/src/ros2_medkit_gateway/src/core/openapi/document_checks.cpp @@ -0,0 +1,160 @@ +// 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 +#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; +} + +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/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 1e15e6cd1..53b301050 100644 --- a/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp +++ b/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp @@ -15,15 +15,27 @@ #include "route_registry.hpp" #include +#include +#include +#include +#include #include +#include #include #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 { @@ -39,6 +51,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 @@ -73,12 +145,81 @@ 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) { + 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` would describe a body + // shape nothing validates. + schema_on_non_json_statuses_.push_back(status_code); + } + responses_[status_code] = {desc, schema_is_publishable ? schema : nlohmann::json{}, {}, 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}; 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::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; @@ -135,6 +276,173 @@ RouteEntry & RouteEntry::hidden() { return *this; } +RouteEntry & RouteEntry::mark_alternates() { + alternates_ = true; + 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) { + info.desc = 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()) { + // 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) { + 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 + // 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::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. + 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. + 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(); + only_status_ = true; + // 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 @@ -143,6 +451,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 // ----------------------------------------------------------------------------- @@ -218,6 +538,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) // ----------------------------------------------------------------------------- @@ -225,8 +562,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 @@ -234,6 +572,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()) { @@ -263,20 +604,40 @@ RouteEntry & RouteRegistry::sse(const std::string & openapi_path, }; auto & entry = add_route("get", openapi_path, std::move(fn)); entry.error_renderer_ = renderer; - // 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"); + 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 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 + // 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; } 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); - 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()) { @@ -288,6 +649,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), @@ -304,17 +669,62 @@ RouteRegistry::binary_download(const std::string & openapi_path, }; auto & entry = add_route("get", openapi_path, std::move(fn)); entry.error_renderer_ = renderer; - entry.response(200, "Binary download", nlohmann::json{{"type", "string"}, {"format", "binary"}}); + entry.gate_ = gate; + // The handler never assigns `res.status`, so cpp-httplib decides it: 200, or + // 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 + // 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; } 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()) { @@ -331,35 +741,47 @@ 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; } 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); - 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()) { 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; } @@ -412,6 +834,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 // ----------------------------------------------------------------------------- @@ -419,7 +852,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); @@ -439,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(); @@ -462,10 +929,62 @@ 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; + } + if (route.partial_content_) { + // 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; + } + // `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; + } // 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 @@ -473,8 +992,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()); } } @@ -487,26 +1015,90 @@ 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. + // + // "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 + // ::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; @@ -514,7 +1106,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(); } @@ -531,6 +1130,22 @@ 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_; + } + // 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. + for (const auto & extra : route.extra_bodies_) { + if (!extra.schema.empty()) { + operation["requestBody"]["content"][extra.content_type]["schema"] = extra.schema; + } + } operation["requestBody"]["required"] = true; } @@ -539,8 +1154,33 @@ 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; - 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. 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) { + 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) { + auto & header_obj = operation["responses"][code_str]["headers"][header.name]; + header_obj["description"] = header.description; + header_obj["schema"] = header.schema; } } } else { @@ -551,19 +1191,145 @@ 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}}; } }; + // 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"); + }; + + // 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)); + } - add_error_ref("400"); - add_error_ref("404"); - add_error_ref("500"); + // `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"); + } + + // 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 + // 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_enforced_on(route)) { + add_response_ref("401", "Unauthorized"); + add_response_ref("403", "Forbidden"); + } + if (rate_limit_enabled_) { + add_response_ref("429", "RateLimited"); + } - if (auth_enabled_) { - add_error_ref("401"); - add_error_ref("403"); + // 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_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 + // 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 @@ -601,16 +1367,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()); @@ -647,22 +1629,94 @@ 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"}); } + // 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"}); + } + + // 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. + 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)"}); + } + + // 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"}); + } + + // 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; @@ -673,14 +1727,49 @@ 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; + } + } - // 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 +1780,8 @@ 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 && !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 { @@ -701,8 +1791,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/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/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/src/http/handlers/bulkdata_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/bulkdata_handlers.cpp index e89268ee6..deda05cdf 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)) { @@ -451,7 +460,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 +572,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_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/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/cyclic_subscription_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/cyclic_subscription_handlers.cpp index fa67b6584..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 @@ -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); @@ -201,8 +201,10 @@ 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)); + // 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(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/discovery_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp index 2aa6802dc..776eaa361 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,23 +1551,35 @@ 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"; 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"; 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()); 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/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/handlers/health_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp index 8eb91f987..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,7 +164,43 @@ http::Result HealthHandlers::get_root(const http::TypedReques } } - // Read docs.enabled parameter (defaults to true) + // 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 + // 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 +209,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/handlers/lifecycle_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/lifecycle_handlers.cpp index 18001ff90..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_) { @@ -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) { @@ -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,8 +219,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_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..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; @@ -168,8 +169,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 +236,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_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) { 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..a2e76865f 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp @@ -192,6 +192,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, @@ -497,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; @@ -542,12 +567,16 @@ 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 location = - 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 = child_resource_path(req.path(), 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)}; @@ -611,45 +640,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)); @@ -686,11 +722,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; @@ -750,11 +784,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); @@ -786,9 +818,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) { @@ -821,11 +853,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 @@ -834,18 +864,21 @@ 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 location = - 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. + // 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; 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_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..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) { @@ -174,7 +193,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")); @@ -241,16 +260,18 @@ 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; 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_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 +374,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")); @@ -422,13 +443,12 @@ 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_status(202).with_header("Location", exec_path); - return std::make_pair(execution_info_to_dto(*result), std::move(att)); + 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/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/handlers/trigger_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/trigger_handlers.cpp index d5d5d5a7a..5dbd40606 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/trigger_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/trigger_handlers.cpp @@ -106,7 +106,7 @@ TriggerHandlers::TriggerHandlers(HandlerContext & ctx, TriggerManager & trigger_ // --------------------------------------------------------------------------- // POST - create trigger // --------------------------------------------------------------------------- -http::Result> +http::Result, http::ResponseAttachments>> TriggerHandlers::post_trigger(const http::TypedRequest & req, dto::TriggerCreateRequest body) { auto id_result = read_entity_id(req); if (!id_result) { @@ -272,8 +272,10 @@ 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)); + // 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(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/handlers/update_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/update_handlers.cpp index 83918b2a2..5d391c3e6 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_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_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_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_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..8c571e0a7 100644 --- a/src/ros2_medkit_gateway/src/http/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/http/rest_server.cpp @@ -22,8 +22,12 @@ #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/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" #include "../openapi/route_registry.hpp" @@ -141,6 +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_); @@ -198,9 +216,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"); @@ -225,7 +287,7 @@ void RESTServer::setup_pre_routing_handler() { } else { res.status = 403; } - return httplib::Server::HandlerResponse::Handled; + return handled(req, res); } } @@ -235,7 +297,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); } } @@ -249,7 +311,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); } } @@ -290,6 +352,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() { @@ -302,15 +373,101 @@ 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 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 " + "`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 + // 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 @@ -339,12 +496,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{}; @@ -356,22 +523,26 @@ 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 " "and auto-clears on recovery.") .operation_id("listFaultTriggers") .path_param("app_id", "App (entity) the rules are scoped to") - .response(200, "Rule list", - nlohmann::json{{"type", "object"}, - {"properties", {{"items", {{"type", "array"}, {"items", {{"type", "object"}}}}}}}}); + // 501 when the engine is not running (feature off, or no plugin loaded). + .errors({501}) + // `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", - [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{}; @@ -385,23 +556,53 @@ 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` + // 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", child_resource_path(req.path, created->id)); res.status = 201; 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 " "(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", - nlohmann::json{{"type", "object"}, {"additionalProperties", true}}) - .response(201, "Created rule", nlohmann::json{{"type", "object"}}) + .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") + .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", @@ -409,10 +610,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{}; @@ -424,6 +625,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 " @@ -431,6 +633,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"); } @@ -455,6 +659,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"); @@ -464,6 +669,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"); @@ -473,8 +679,11 @@ 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). + .fan_out_aware() .operation_id("getVersionInfo"); // === Discovery - entity collections === @@ -488,6 +697,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"); @@ -498,6 +708,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"); @@ -508,6 +719,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"); @@ -518,6 +730,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"); @@ -568,8 +781,13 @@ 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 + // 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}", @@ -577,29 +795,38 @@ 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")) + // DataHandlers::put_data_item -> HandlerContext::validate_lock_access("data"). + .lock_guarded() .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); }) .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") .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); }) .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") .operation_id(std::string("list") + capitalize(et.singular) + "DataGroups"); // Data collection (all topics). Returns the opaque `DataListResult` envelope @@ -613,8 +840,11 @@ 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. + .fan_out_aware() .operation_id(std::string("list") + capitalize(et.singular) + "Data"); // --- Operations --- @@ -634,8 +864,11 @@ 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. + .fan_out_aware() .operation_id(std::string("list") + capitalize(et.singular) + "Operations"); reg.get(entity_path + "/operations/{operation_id}", @@ -643,6 +876,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 + ".") @@ -660,8 +894,17 @@ 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 + // `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"); reg.get>( @@ -670,6 +913,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"); @@ -679,22 +923,26 @@ 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"); - 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") + .requires_role(UserRole::OPERATOR) .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)") + // 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}", @@ -702,8 +950,11 @@ 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"). + .lock_guarded() .operation_id(std::string("cancel") + capitalize(et.singular) + "Execution"); // --- Configurations --- @@ -724,8 +975,18 @@ 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. + .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}", @@ -733,8 +994,16 @@ 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`, + // 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( @@ -744,8 +1013,21 @@ 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 + // 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`, + // 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}", @@ -753,8 +1035,18 @@ 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"). + .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( @@ -766,8 +1058,11 @@ 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"). + .lock_guarded() .operation_id(std::string("deleteAll") + capitalize(et.singular) + "Configurations"); // --- Faults --- @@ -787,8 +1082,15 @@ 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. + .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(); @@ -797,8 +1099,12 @@ 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 + // routes, and equally out of the recorder's reach. + .errors({503}) .operation_id(std::string("get") + capitalize(et.singular) + "Fault"); reg.del_alternates( @@ -808,8 +1114,14 @@ 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"). + .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", @@ -817,8 +1129,14 @@ 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"). + .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 --- @@ -833,8 +1151,25 @@ 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 + ".") + .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 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 + // 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(); @@ -843,8 +1178,10 @@ 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. .operation_id(std::string("get") + capitalize(et.singular) + "LogConfiguration"); reg.put( @@ -853,8 +1190,12 @@ 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"). + .lock_guarded() + .errors({503}) // No LogManager attached - see the list route above. .operation_id(std::string("set") + capitalize(et.singular) + "LogConfiguration"); // --- Bulk Data --- @@ -873,6 +1214,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"); @@ -883,15 +1225,19 @@ 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"); - 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") + .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"); @@ -899,16 +1245,36 @@ 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") + .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 + ".") - .response(201, "File uploaded", SB::ref("BulkDataDescriptor")) + // 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() + // 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}", @@ -916,8 +1282,11 @@ 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"). + .lock_guarded() .operation_id(std::string("delete") + capitalize(et.singular) + "BulkData"); } else { // 405 stub routes for entity types that cannot host uploaded bulk-data @@ -934,6 +1303,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") @@ -948,6 +1318,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") @@ -963,97 +1334,110 @@ 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") + .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 + ".") + .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 + // never opens enough concurrent streams. + .errors({503}) .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> { - if (!trigger_handlers_) { - return tl::unexpected(make_not_available_error()); - } + [this](http::TypedRequest req, dto::TriggerCreateRequest body) + -> http::Result, http::ResponseAttachments>> { 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 + ".") - .response(201, "Trigger created", SB::ref("Trigger")) + .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 + // 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>( 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") + .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) .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") + .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) .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") + .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) .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") + .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) .operation_id(std::string("delete") + capitalize(et.singular) + "Trigger"); } @@ -1075,20 +1459,34 @@ 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 + ".") + .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}) .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> { + -> http::Result, http::ResponseAttachments>> { 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 + ".") - .response(201, "Subscription created", SB::ref("CyclicSubscription")) + .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>( @@ -1097,6 +1495,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"); @@ -1106,6 +1505,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"); @@ -1117,6 +1517,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"); @@ -1126,6 +1527,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"); @@ -1141,17 +1543,32 @@ 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") + .requires_role(UserRole::OPERATOR) .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) - .response(201, "Lock acquired", SB::ref("Lock")) + .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 + // `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", @@ -1159,10 +1576,12 @@ 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", 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}", @@ -1170,10 +1589,12 @@ 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", false, client_id_schema) + .errors({501}) // Locking disabled: LockHandlers::check_locking_enabled .operation_id(std::string("get") + capitalize(et.singular) + "Lock"); reg.put( @@ -1182,9 +1603,17 @@ 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) + // 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}", @@ -1192,9 +1621,17 @@ 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) + // 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"); } @@ -1209,17 +1646,62 @@ 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( + 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") + .requires_role(UserRole::CONFIGURATOR) .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")) + // 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}) .operation_id(std::string("upload") + capitalize(et.singular) + "Script"); reg.get(entity_path + "/scripts", @@ -1227,8 +1709,11 @@ 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 + .errors({501}) .operation_id(std::string("list") + capitalize(et.singular) + "Scripts"); reg.get(entity_path + "/scripts/{script_id}", @@ -1236,8 +1721,11 @@ 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 + .errors({501}) .operation_id(std::string("get") + capitalize(et.singular) + "Script"); reg.del(entity_path + "/scripts/{script_id}", @@ -1245,20 +1733,37 @@ 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 + .errors({409, 501}) .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") + .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 + ".") - .request_body("Execution parameters", SB::generic_object_schema()) - .response(202, "Execution started", SB::ref("ScriptExecution")) + // 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") + // `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}) .operation_id(std::string("start") + capitalize(et.singular) + "ScriptExecution"); reg.get(entity_path + "/scripts/{script_id}/executions/{execution_id}", @@ -1266,8 +1771,11 @@ 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 + .errors({501}) .operation_id(std::string("get") + capitalize(et.singular) + "ScriptExecution"); reg.put( @@ -1277,8 +1785,11 @@ 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 + .errors({409, 501}) .operation_id(std::string("control") + capitalize(et.singular) + "ScriptExecution"); reg.del(entity_path + "/scripts/{script_id}/executions/{execution_id}", @@ -1286,8 +1797,11 @@ 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 + .errors({409, 501}) .operation_id(std::string("remove") + capitalize(et.singular) + "ScriptExecution"); } @@ -1299,6 +1813,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"); @@ -1309,6 +1824,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"); @@ -1319,8 +1835,16 @@ void RESTServer::setup_routes() { return discovery_handlers_->get_area_contains(req); }) .tag("Discovery") + .requires_role(UserRole::VIEWER) .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"); } @@ -1331,6 +1855,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"); @@ -1341,6 +1866,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"); @@ -1351,6 +1877,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"); @@ -1363,6 +1890,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"); @@ -1373,6 +1901,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 " @@ -1385,6 +1914,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"); @@ -1397,8 +1927,13 @@ void RESTServer::setup_routes() { return discovery_handlers_->get_function_hosts(req); }) .tag("Discovery") + .requires_role(UserRole::VIEWER) .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"); } @@ -1412,6 +1947,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.") @@ -1422,6 +1958,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.") @@ -1432,6 +1969,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.") @@ -1442,6 +1980,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.") @@ -1461,6 +2000,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"); @@ -1471,15 +2011,19 @@ 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"); - 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") + .requires_role(UserRole::VIEWER) .summary("Download bulk-data file for subarea") .description("Downloads a bulk-data file for a subarea.") .operation_id("downloadSubareaBulkData"); @@ -1490,6 +2034,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"); @@ -1500,15 +2045,19 @@ 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"); - 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") + .requires_role(UserRole::VIEWER) .summary("Download bulk-data file for subcomponent") .description("Downloads a bulk-data file for a subcomponent.") .operation_id("downloadSubcomponentBulkData"); @@ -1531,8 +2080,23 @@ 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.") + .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", @@ -1540,8 +2104,21 @@ 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 + // 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}) .operation_id("listAllFaults") .query(); @@ -1551,17 +2128,54 @@ void RESTServer::setup_routes() { return fault_handlers_->clear_all_faults_global(req); }) .tag("Faults") + .requires_role(UserRole::OPERATOR) .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. + .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. + // `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") .query(); // === 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) @@ -1578,111 +2192,129 @@ 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") + .requires_role(UserRole::VIEWER) .summary("List software updates") .description("Lists all registered software updates.") + .gated_on(updates_available, kUpdate501) .operation_id("listUpdates") .query(); - reg.post( + reg.post>( "/updates", [this](http::TypedRequest req, dto::UpdateRegisterRequest body) - -> http::Result> { - if (!update_handlers_) { - return tl::unexpected(kUpdate501); - } + -> http::Result, http::ResponseAttachments>> { 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.") - .response(201, "Update registered", SB::ref("UpdateRegisterResponse")) + .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") + .requires_role(UserRole::VIEWER) .summary("Get update status") .description("Returns the current status and progress of an update.") + .gated_on(updates_available, kUpdate501) .operation_id("getUpdateStatus"); - reg.put( + reg.put>( "/updates/{update_id}/prepare", - [this](http::TypedRequest req) -> http::Result> { - if (!update_handlers_) { - return tl::unexpected(kUpdate501); - } + [this](http::TypedRequest req) + -> http::Result, http::ResponseAttachments>> { 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).") - .response(202, "Update preparation started") + .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( + reg.put>( "/updates/{update_id}/execute", - [this](http::TypedRequest req) -> http::Result> { - if (!update_handlers_) { - return tl::unexpected(kUpdate501); - } + [this](http::TypedRequest req) + -> http::Result, http::ResponseAttachments>> { return update_handlers_->put_execute(req); }) .tag("Updates") + .requires_role(UserRole::CONFIGURATOR) .summary("Execute update") .description("Starts executing a prepared update.") - .response(202, "Update execution started") + .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( + reg.put>( "/updates/{update_id}/automated", - [this](http::TypedRequest req) -> http::Result> { - if (!update_handlers_) { - return tl::unexpected(kUpdate501); - } + [this](http::TypedRequest req) + -> http::Result, http::ResponseAttachments>> { return update_handlers_->put_automated(req); }) .tag("Updates") + .requires_role(UserRole::CONFIGURATOR) .summary("Run automated update") .description("Runs a fully automated update (prepare + execute).") - .response(202, "Automated update started") + .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}", [this](http::TypedRequest req) -> http::Result { - if (!update_handlers_) { - return tl::unexpected(kUpdate501); - } 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) .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") + .requires_role(UserRole::CONFIGURATOR) .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 === @@ -1698,10 +2330,19 @@ 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")) + // 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", @@ -1709,10 +2350,15 @@ 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")) + .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", @@ -1720,8 +2366,12 @@ 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: + // 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); @@ -1737,6 +2387,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; @@ -1748,14 +2406,33 @@ 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") + .requires_role(destructive_transition ? UserRole::CONFIGURATOR : UserRole::OPERATOR) .summary(std::string("Request lifecycle transition '") + action + "'") - .response(202, "Lifecycle transition accepted") + .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 + // (`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)); } @@ -1764,12 +2441,87 @@ 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 " + "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}) .operation_id(std::string("get") + entity_cap + "Status"); } // 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(); +} + +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/capability_generator.cpp b/src/ros2_medkit_gateway/src/openapi/capability_generator.cpp index 8c94a7175..d7f5cf1fa 100644 --- a/src/ros2_medkit_gateway/src/openapi/capability_generator.cpp +++ b/src/ros2_medkit_gateway/src/openapi/capability_generator.cpp @@ -14,16 +14,20 @@ #include "capability_generator.hpp" +#include +#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" #include "ros2_medkit_gateway/core/version.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" @@ -32,14 +36,87 @@ 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 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. 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_it = paths->begin(); path_it != paths->end(); ++path_it) { + if (!path_it.value().is_object()) { + continue; + } + 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()); + } + } +} + +} // 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()) { @@ -47,13 +124,104 @@ 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; } - return result; + + // `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; + } + + // 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. 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()) { + 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); switch (resolved.category) { @@ -99,6 +267,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( @@ -107,35 +331,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; @@ -144,75 +370,198 @@ 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; } // ----------------------------------------------------------------------------- -// 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)); +} + +// ----------------------------------------------------------------------------- +// Specific entity spec (e.g., /apps/my_app) +// ----------------------------------------------------------------------------- + +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)); } // ----------------------------------------------------------------------------- @@ -220,200 +569,350 @@ nlohmann::json CapabilityGenerator::generate_specific_entity(const ResolvedPath // ----------------------------------------------------------------------------- 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(); - } + auto [prefix, bindings] = entity_template(resolved, true); + const std::string entity_path = concrete_path(prefix, bindings); + prefix += "/" + resolved.resource_collection; - PathBuilder path_builder(schema_builder_, ctx_.auth_config().enabled); - nlohmann::json paths; + const nlohmann::json served = served_paths(); + auto paths = project(served, prefix, bindings); + add_cache_derived_items(paths, resolved, entity_path); - // 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; + return build_subtree_document(resolved.entity_id + "/" + resolved.resource_collection, paths); +} - std::string collection_path = entity_path + "/" + resolved.resource_collection; - const auto & cache = node_.get_thread_safe_cache(); +// ----------------------------------------------------------------------------- +// Specific resource spec (e.g., /apps/my_app/data/temperature) +// ----------------------------------------------------------------------------- - 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); +nlohmann::json CapabilityGenerator::generate_specific_resource(const ResolvedPath & resolved) const { + 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 { - // 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); + prefix = collection_prefix + "/" + resolved.resource_id; } - 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); + auto paths = project(served, prefix, bindings); + add_cache_derived_items(paths, resolved, entity_path); - return builder.build(); + return build_subtree_document(resolved.resource_id, paths); } -// ----------------------------------------------------------------------------- -// Specific resource spec (e.g., /apps/my_app/data/temperature) -// ----------------------------------------------------------------------------- - -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(); +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(); - PathBuilder path_builder(schema_builder_, ctx_.auth_config().enabled); - nlohmann::json paths; + const auto security = projected->find("security"); + if (security != projected->end()) { + operation["security"] = *security; + } - // Build full path - std::string entity_path; - for (const auto & parent : resolved.parent_chain) { - entity_path += "/" + parent.entity_type + "/" + parent.entity_id; + // `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(); + } } - entity_path += "/" + resolved.entity_type + "/" + resolved.entity_id; + return true; +} - 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_); + 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. + // 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 && !names_match(topic.name, resolved.resource_id)) { + continue; } + emit(one_item ? sibling_key : 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); - } - - 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; + return; + } + + 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 && !names_match(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); + 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 && !names_match(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); + emit(one_item ? sibling_key : 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` 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 - 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; + } + // 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 + // 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. + 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; + } + } + 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; } } @@ -425,6 +924,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(); } @@ -571,20 +1073,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"; @@ -628,113 +1116,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); +// ----------------------------------------------------------------------------- +// Cache helpers +// ----------------------------------------------------------------------------- - paths[logs_path + "/configuration"] = std::move(config_path_item); +void CapabilityGenerator::clear_cache_locked() const { + spec_cache_.clear(); + cache_bytes_ = 0; } -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; - case ResourceCollection::DATA_LISTS: - case ResourceCollection::LOCKS: - case ResourceCollection::MODES: - case ResourceCollection::COMMUNICATION_LOGS: - case ResourceCollection::TRIGGERS: - case ResourceCollection::SCRIPTS: - 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; - } - } +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(); @@ -742,14 +1141,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()) { @@ -758,12 +1157,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..5532a408c 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,143 @@ 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; + + /// 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; - /// 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 0c5beda60..30fffb7eb 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 { @@ -65,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; } @@ -129,18 +133,81 @@ 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"] = SchemaBuilder::ref("GenericError"); - // 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()}}); + // 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"]; + + // 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("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("OAuth2Error"); + + // 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). 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. + // + // 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) { + 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..6f6e6e8a1 100644 --- a/src/ros2_medkit_gateway/src/openapi/path_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/path_builder.cpp @@ -19,132 +19,7 @@ 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; +PathBuilder::PathBuilder(const SchemaBuilder & schema_builder) : schema_builder_(schema_builder) { } // ----------------------------------------------------------------------------- @@ -158,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()) { @@ -175,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()) { @@ -196,33 +89,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) // ----------------------------------------------------------------------------- @@ -236,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()) { @@ -247,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; } @@ -281,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()) { @@ -293,237 +156,20 @@ 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; } -// ----------------------------------------------------------------------------- -// 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 // ----------------------------------------------------------------------------- @@ -540,54 +186,8 @@ 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; } -// ----------------------------------------------------------------------------- -// 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..a424989f4 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,43 +25,56 @@ 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, 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); - // 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 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: - 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_; }; } // 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 1c5a015b2..57ee818b6 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 @@ -28,6 +29,8 @@ #include #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" @@ -53,6 +56,64 @@ 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>; + +/// 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"}}; +}; + +/// 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: @@ -61,9 +122,74 @@ 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"); + /// 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); + + /// 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) { @@ -96,6 +222,176 @@ 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(); + + /// 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(...))`: + /// 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); + + /// 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 + /// 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. + 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 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 + /// `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); + + /// 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. @@ -111,6 +407,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_; @@ -122,18 +447,85 @@ 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 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 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 + /// 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_; + /// 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)}; + /// 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; + /// 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{}; + /// 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. + 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_; + /// 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; @@ -141,6 +533,25 @@ 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_{}; + + /// 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_; }; @@ -305,8 +716,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 @@ -324,14 +749,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. @@ -354,16 +798,117 @@ 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(); } - /// 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. + void set_rate_limit_enabled(bool enabled) { + 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; + } + + /// 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 @@ -386,8 +931,29 @@ 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 + /// `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. @@ -422,35 +988,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 @@ -461,14 +1035,50 @@ 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); }; +// ============================================================================= +// 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 // ============================================================================= @@ -513,36 +1123,120 @@ 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"; + } +} + +/// 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 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); } } 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()) { @@ -556,15 +1250,21 @@ 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()) { 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; @@ -575,9 +1275,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 @@ -588,6 +1288,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()) { @@ -601,9 +1307,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); @@ -611,11 +1317,18 @@ 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()) { 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; @@ -627,9 +1340,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); @@ -637,6 +1350,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()) { @@ -657,9 +1374,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); @@ -667,6 +1384,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()) { @@ -689,11 +1410,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()) { @@ -717,15 +1441,15 @@ 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)"); + 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_); - if constexpr (!std::is_same_v) { - entry.template response(200, ""); - } else { - entry.response(204, "No content"); - } + entry.handler_ = wrap_body_less(std::move(handler), entry.error_renderer_, entry.gate_); + detail::declare_derived_response(entry); return entry; } @@ -733,15 +1457,12 @@ 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, ""); - } else { - entry.response(204, "No content"); - } + entry.handler_ = wrap_body_less_with_attachments(std::move(handler), entry.error_renderer_, entry.gate_); + detail::declare_derived_response(entry); return entry; } @@ -749,16 +1470,16 @@ 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)"); + 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.handler_ = wrap_with_body(std::move(handler), entry.error_renderer_, entry.gate_); entry.template request_body(""); - if constexpr (!std::is_same_v) { - entry.template response(200, ""); - } else { - entry.response(204, "No content"); - } + detail::declare_derived_response(entry); return entry; } @@ -767,35 +1488,32 @@ 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.handler_ = wrap_with_body_attachments(std::move(handler), entry.error_renderer_, entry.gate_); entry.template request_body(""); - if constexpr (!std::is_same_v) { - entry.template response(200, ""); - } else { - entry.response(204, "No content"); - } + detail::declare_derived_response(entry); return entry; } 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)"); + 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.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) { - entry.template response(200, ""); - } else { - entry.response(204, "No content"); - } + detail::declare_derived_response(entry); return entry; } @@ -803,15 +1521,12 @@ 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, ""); - } else { - entry.response(204, "No content"); - } + entry.handler_ = wrap_body_less_with_attachments(std::move(handler), entry.error_renderer_, entry.gate_); + detail::declare_derived_response(entry); return entry; } @@ -819,16 +1534,16 @@ 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)"); + 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.handler_ = wrap_with_body(std::move(handler), entry.error_renderer_, entry.gate_); entry.template request_body(""); - if constexpr (!std::is_same_v) { - entry.template response(200, ""); - } else { - entry.response(204, "No content"); - } + detail::declare_derived_response(entry); return entry; } @@ -837,33 +1552,35 @@ 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.handler_ = wrap_with_body_attachments(std::move(handler), entry.error_renderer_, entry.gate_); entry.template request_body(""); - if constexpr (!std::is_same_v) { - entry.template response(200, ""); - } else { - entry.response(204, "No content"); - } + detail::declare_derived_response(entry); return entry; } 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)"); + 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_); - // 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, ""); - } else { - entry.response(204, "No content"); - } + entry.handler_ = wrap_body_less(std::move(handler), entry.error_renderer_, entry.gate_); + // 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; } @@ -871,15 +1588,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, ""); - } else { - entry.response(204, "No content"); - } + entry.handler_ = wrap_body_less_with_attachments(std::move(handler), entry.error_renderer_, entry.gate_); + // 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; } @@ -887,16 +1607,16 @@ 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)"); + 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.handler_ = wrap_with_body(std::move(handler), entry.error_renderer_, entry.gate_); entry.template request_body(""); - if constexpr (!std::is_same_v) { - entry.template response(200, ""); - } else { - entry.response(204, "No content"); - } + detail::declare_derived_response(entry); return entry; } @@ -905,31 +1625,28 @@ 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.handler_ = wrap_with_body_attachments(std::move(handler), entry.error_renderer_, entry.gate_); entry.template request_body(""); - if constexpr (!std::is_same_v) { - entry.template response(200, ""); - } else { - entry.response(204, "No content"); - } + detail::declare_derived_response(entry); return entry; } 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)"); + 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_); - if constexpr (!std::is_same_v) { - entry.template response(200, ""); - } else { - entry.response(204, "No content"); - } + entry.handler_ = wrap_body_less(std::move(handler), entry.error_renderer_, entry.gate_); + detail::declare_derived_response(entry); return entry; } @@ -937,15 +1654,12 @@ 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, ""); - } else { - entry.response(204, "No content"); - } + entry.handler_ = wrap_body_less_with_attachments(std::move(handler), entry.error_renderer_, entry.gate_); + detail::declare_derived_response(entry); return entry; } @@ -956,12 +1670,13 @@ 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)); } + declare_location_header(entry, status); } } // namespace detail @@ -971,10 +1686,14 @@ 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.handler_ = wrap_post_alternates(std::move(handler), entry.error_renderer_, entry.gate_); entry.template request_body(""); (detail::add_alternate_response(entry), ...); + entry.mark_alternates(); return entry; } @@ -985,9 +1704,11 @@ 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(); return entry; } @@ -995,9 +1716,13 @@ 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.handler_ = wrap_del_alternates(std::move(handler), entry.error_renderer_, entry.gate_); (detail::add_alternate_response(entry), ...); + entry.mark_alternates(); return entry; } @@ -1011,12 +1736,17 @@ 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) { + 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 @@ -1036,7 +1766,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; @@ -1045,13 +1778,10 @@ 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) { - entry.template response(200, ""); - } else { - entry.response(204, "No content"); - } + detail::declare_derived_response(entry); return entry; } 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/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/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_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_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..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 // ============================================================================= @@ -328,6 +352,161 @@ 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. +// +// 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 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 * phantom : {"/data-lists", "/modes", "/updates", "/communication-logs"}) { + EXPECT_FALSE(result->at("paths").contains(entity_path + phantom)) << phantom; + } + // ... 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; + } +} + +// data-categories and data-groups are registered with `.only_status(501, ...)`, +// 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 & 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; + } +} + // ============================================================================= // Resource collection - validates entity existence // ============================================================================= @@ -541,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_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_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_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_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_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_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_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_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_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_path_builder.cpp b/src/ros2_medkit_gateway/test/test_path_builder.cpp index 6a3d44d9c..2fda9eb06 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 // ============================================================================= @@ -153,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) { @@ -187,55 +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")); -} - -// ============================================================================= -// 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"); +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")); + // 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, 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()); +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) { @@ -254,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) { @@ -269,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) { @@ -281,153 +169,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 // ============================================================================= @@ -442,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_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 bf3700cb0..9b08119db 100644 --- a/src/ros2_medkit_gateway/test/test_route_registry.cpp +++ b/src/ros2_medkit_gateway/test/test_route_registry.cpp @@ -54,6 +54,9 @@ 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::RoutePermissions; +using ros2_medkit_gateway::UserRole; using ros2_medkit_gateway::dto::FaultEntityListQuery; using ros2_medkit_gateway::dto::FaultListQuery; using ros2_medkit_gateway::dto::RouteRegistryTestSeedDto; @@ -559,6 +562,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) // ============================================================================= @@ -615,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"}}); @@ -643,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; @@ -656,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; @@ -682,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; @@ -738,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); } // ============================================================================= @@ -763,3 +931,716 @@ 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") + .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; + } +} + +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")); +} + +// ============================================================================= +// 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")); +} + +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 +// ============================================================================= + +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")); +} + +// ============================================================================= +// 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"; +} + +// ============================================================================= +// 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_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_script_handlers.cpp b/src/ros2_medkit_gateway/test/test_script_handlers.cpp index b86120d05..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; @@ -408,13 +424,13 @@ 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; 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); @@ -515,15 +531,20 @@ 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; 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); @@ -558,21 +579,24 @@ 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); 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) { 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_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_gateway/test/test_typed_route_registry.cpp b/src/ros2_medkit_gateway/test/test_typed_route_registry.cpp index a2e0ececf..c6f79cdd1 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"); } @@ -441,20 +453,141 @@ 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/foo/bar/docs"); + ASSERT_TRUE(r); + EXPECT_EQ(r->status, 200); + 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"); +} + +// ============================================================================= +// 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; + // 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 + +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/docs/foo/bar.html"); + auto r = cli.Get("/api/v1/test/blob"); ASSERT_TRUE(r); EXPECT_EQ(r->status, 200); - EXPECT_EQ(r->body, "docs:foo/bar.html"); + 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_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); +} 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/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/ros2_medkit_test_utils/launch_helpers.py b/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/launch_helpers.py index d1d25f7d8..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 @@ -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,68 @@ def create_demo_nodes(nodes=None, *, lidar_faulty=True, coverage=True, return actions +# --------------------------------------------------------------------------- +# 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. + + 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. + + 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 + 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=...)``. + + """ + return { + 'updates.enabled': True, + 'scripts.scripts_dir': scripts_dir, + 'triggers.enabled': True, + 'locking.enabled': True, + **graph_provider_params(), + } + + # --------------------------------------------------------------------------- # Factory: complete test launch description # --------------------------------------------------------------------------- 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..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 @@ -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,61 @@ 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_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``: + 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. + + 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( + '/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'), [], + '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.""" response = requests.post( 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_configuration_api.test.py b/src/ros2_medkit_integration_tests/test/features/test_configuration_api.test.py index a835cd659..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. @@ -287,6 +320,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_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_fault_triggers_api.test.py b/src/ros2_medkit_integration_tests/test/features/test_fault_triggers_api.test.py index 2cab2654c..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): @@ -115,6 +136,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]) @@ -186,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_faults_api.test.py b/src/ros2_medkit_integration_tests/test/features/test_faults_api.test.py index afaf1b117..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 @@ -135,6 +135,113 @@ 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. + 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_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 913e141cb..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 @@ -21,6 +21,7 @@ """ +import re import unittest import launch_testing @@ -128,6 +129,38 @@ 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 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 + 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. @@ -193,12 +226,27 @@ 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(): + # 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 + # 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') 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_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 new file mode 100644 index 000000000..10017b487 --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/features/test_openapi_contract.test.py @@ -0,0 +1,1514 @@ +#!/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 +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', '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', +} + +# 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' + + +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_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', [])} + 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_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] + 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 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: + 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 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 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. + + 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 = {} + for path, method, op in self.operations(): + content = op.get('responses', {}).get('200', {}).get('content', {}) + if 'text/event-stream' not in content: + continue + where = f'{method.upper()} {path}' + self.assertEqual( + list(content), ['text/event-stream'], + 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. + + ``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. + + 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() + 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}') + + 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') + + 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 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 + 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, item in subtree['paths'].items() + if 'get' in item} + 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') + + 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): + + 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/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..6c2842afe --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/features/test_openapi_error_coverage.test.py @@ -0,0 +1,442 @@ +#!/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), 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 +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. + + 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. + """ + 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. + 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/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. 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..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 @@ -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,158 @@ 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. + + 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_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. 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_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.""" 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.""" 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 b0a5d4fb0..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 @@ -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. @@ -418,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): 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 # ========================================================================= 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..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; @@ -177,7 +180,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_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) | 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) 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(); +} 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")