Skip to content

Commit 44bab19

Browse files
committed
fix(gateway): forward remote-entity writes; harden DTO request/response contract
Aggregation forwarding only proxied GET and SSE: the typed body, alternates, delete-alternates, multipart, and binary wrappers never installed the ForwardResponseScope, so writes and uploads to a remote entity returned an empty no-op response instead of the peer's. Install the scope uniformly in every wrapper (matching the documented "around every typed handler invocation" invariant) and add forwarding regression tests for PUT, POST-alternates, DELETE-alternates, and multipart routes. Tighten the DTO contract: - JsonReader treats an explicit JSON null as a value for free-form json and optional<json> members, so PUT data/configurations with {"data": null} no longer returns 400; null on other member types is still treated as absent. - ScriptControlRequest.action uses plain field() instead of field_enum so plugin script backends may accept actions beyond the built-in stop/forced_termination; the provider validates the value. - Restore the FaultListItem.severity_label response enum (now including UNKNOWN, which fault_to_json emits for out-of-range severities) and pin fault_to_json output to the FaultListItem schema with a round-trip test, so the verbatim fault-list wire cannot drift from its published schema. - JsonWriter's value encoder ends in a static_assert like the reader and schema visitors, so an unsupported field type fails to compile instead of silently taking the scalar branch. - Drop a no-op multipart parts initialization. Document the synchronous operation service-call failure error-envelope normalization and the fan-out re-serialization semantics in the changelog.
1 parent a17938c commit 44bab19

12 files changed

Lines changed: 362 additions & 25 deletions

File tree

src/ros2_medkit_gateway/CHANGELOG.rst

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,24 @@ Unreleased
4848
covered by this PR - its handlers continue to run on the pre-typed
4949
HandlerContext surface and will be migrated separately
5050
(`#403 <https://github.com/selfpatch/ros2_medkit/issues/403>`_)
51+
* Synchronous operation-execution service-call failures
52+
(``POST /api/v1/{entity-path}/operations/{id}/executions`` when the underlying
53+
ROS 2 service call fails) now return the standard SOVD ``GenericError`` envelope
54+
(``{"error_code": "vendor-error", "vendor_code":
55+
"x-medkit-ros2-service-unavailable", "message": "Service call failed", ...}``,
56+
HTTP status 500 unchanged) instead of the previous bespoke nested
57+
``{"error": {"code", "message", "details"}}`` object. This aligns the one
58+
remaining non-standard error path with every other gateway error; clients that
59+
parsed ``error.code`` / ``error.details`` for this specific failure must read
60+
``vendor_code`` / ``parameters`` instead
61+
(`#403 <https://github.com/selfpatch/ros2_medkit/issues/403>`_)
5162
* ``ros2_medkit_msgs/srv/ClearFault`` request gains a ``bool skip_correlation_auto_clear`` field (see the per-entity fault scope entry below for the in-tree motivation). Adding a request field changes the service type hash, so out-of-tree callers that invoke the service directly (for example ``ros2 service call /fault_manager/clear_fault ros2_medkit_msgs/srv/ClearFault ...`` as documented in the ``ros2_medkit_fault_manager`` README) must rebuild against the new ``ros2_medkit_msgs`` to keep talking to ``fault_manager``. The in-tree gateway client and server are updated together (`#395 <https://github.com/selfpatch/ros2_medkit/issues/395>`_)
5263
* Per-entity fault routes are now correctly scoped to the entity's hosted apps. ``GET /api/v1/{entity-path}/faults/{fault_code}``, ``DELETE /api/v1/{entity-path}/faults/{fault_code}``, ``GET /api/v1/{entity-path}/faults``, and ``DELETE /api/v1/{entity-path}/faults`` previously fell back to a prefix match against the entity's ``namespace_path``; when that was empty (host-derived / synthetic components, manifest components without a ``namespace`` field, Areas, Functions, and Apps with a wildcard ``ros_binding.namespace_pattern``) the scope filter was silently disabled and the routes exposed - and on ``DELETE``, cleared - faults reported by apps that belonged to entirely different entities. All four handlers now resolve the addressed entity to its hosted-app FQN set (via the new ``HandlerContext::resolve_entity_source_fqns`` helper) and apply a strict all-sources scope check: a fault counts as in scope only when **every** entry in its ``reporting_sources`` is owned by the entity (exact FQN match, or strict path-child via ``<fqn>/...``). Per-fault routes return ``404 Resource Not Found`` for any fault that fails the check; collection routes return an empty ``items`` array. The underlying ``GetFault.srv`` contract is unchanged; ``ClearFault.srv`` gains a new ``skip_correlation_auto_clear`` request flag so per-entity DELETE can opt out of cascade-clearing correlated symptom fault codes that may live in other entities. Per-entity collection responses no longer include the global ``muted_count`` / ``cluster_count`` / ``muted_faults`` / ``clusters`` correlation metadata; those remain on the global ``GET /api/v1/faults`` route. Behavior changes visible to clients: (a) faults reported by apps outside the addressed entity are no longer returned or cleared via that entity's route, (b) **mixed-source** faults that include at least one out-of-entity reporter are likewise rejected with ``404`` on per-fault routes and excluded from per-entity collection responses (use the global ``GET /api/v1/faults`` to see them), (c) per-entity DELETE no longer cascade-clears correlated symptoms outside the entity (`#395 <https://github.com/selfpatch/ros2_medkit/issues/395>`_)
5364
* ``GET /api/v1/updates/{id}/status`` no longer returns ``404`` for a registered-but-idle package; ``POST /api/v1/updates`` now seeds a ``pending`` status, so the endpoint returns ``200 {"status": "pending"}`` immediately after registration. ``404`` is reserved for packages that are not registered. Clients that used ``404`` as a signal for "registered but nothing started yet" must adapt (`#378 <https://github.com/selfpatch/ros2_medkit/issues/378>`_)
5465

5566
**Features:**
5667

57-
* Typed ``fan_out_collection<T>`` aggregating helper replaces raw-JSON ``merge_peer_items`` on the typed collection routes (data, operations, config, logs). Peer items are decoded via ``dto::JsonReader<T>``; items that fail validation are removed from the merged ``items`` array, recorded in ``x-medkit.peer_dropped_items`` with the JsonReader error plus a best-effort ``source_id``, and logged at ``WARN``. Previously, malformed peer items silently disappeared into the merged response; fleet operators can now detect inter-gateway schema drift directly on the wire (`#403 <https://github.com/selfpatch/ros2_medkit/issues/403>`_)
68+
* Typed ``fan_out_collection<T>`` aggregating helper replaces raw-JSON ``merge_peer_items`` on the typed collection routes (data, operations, config, logs). Peer items are decoded via ``dto::JsonReader<T>``; items that fail validation are removed from the merged ``items`` array, recorded in ``x-medkit.peer_dropped_items`` with the JsonReader error plus a best-effort ``source_id``, and logged at ``WARN``. Items that parse successfully are re-serialized through the local ``dto::JsonWriter<T>``, so any peer-supplied fields outside the local DTO schema are dropped from the merged response (the previous raw passthrough preserved unknown peer fields verbatim). Previously, malformed peer items silently disappeared into the merged response; fleet operators can now detect inter-gateway schema drift directly on the wire (`#403 <https://github.com/selfpatch/ros2_medkit/issues/403>`_)
5869
* ``Collection<T, XMedkitT>`` is now a 2-parameter template. Domain list endpoints (faults, config, data, logs) reference their richer per-domain collection x-medkit struct (``FaultListXMedkit``, ``ConfigListXMedkit``, ``DataListXMedkit``, ``LogListXMedkit``) directly in the published schema instead of the generic ``XMedkitCollection``, so generated clients see aggregation counts, peer provenance, and ``peer_dropped_items`` from the schema (`#403 <https://github.com/selfpatch/ros2_medkit/issues/403>`_)
5970
* New ``opaque_object("key", &T::field)`` DTO field descriptor in ``dto/contract.hpp``. Binds a ``nlohmann::json`` member as a typed "any JSON object" field: ``JsonWriter`` emits it verbatim, ``JsonReader`` rejects scalars / arrays / null, ``SchemaWriter`` emits ``{type: object, additionalProperties: true, x-medkit-opaque: true}``. Used for fields whose runtime shape is decided by an upstream component the gateway cannot introspect (live ROS message payloads, plugin-defined fault envelopes, action results) (`#403 <https://github.com/selfpatch/ros2_medkit/issues/403>`_)
6071
* ``GET /api/v1/faults/stream`` event payloads now carry an optional ``x-medkit`` SOVD payload-extension object with ``entity_type`` and ``entity_id`` fields. When the gateway can resolve the fault's first reporting source back to a SOVD entity (via the manifest-mode linking index, or a runtime-mode last-segment match against an existing App), consumers can hit ``/{entity_type}/{entity_id}/bulk-data/rosbags/{fault_code}`` directly instead of HEAD-probing every entity. Resolution is snapshotted at event arrival, so a discovery refresh between enqueue and stream-out cannot retroactively change the entity reported to consumers. The ``x-medkit`` object is omitted entirely when no entity can be resolved, so existing SSE consumers ignore the addition (`#380 <https://github.com/selfpatch/ros2_medkit/issues/380>`_)

src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/enums.hpp

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ namespace dto {
2222
/// Entity types (used in entity detail responses).
2323
inline constexpr std::string_view kEntityTypeValues[] = {"area", "component", "app", "function"};
2424

25-
/// Fault severity label (fault_list_item_schema / fault_detail_schema).
26-
inline constexpr std::string_view kFaultSeverityLabelValues[] = {"INFO", "WARN", "ERROR", "CRITICAL"};
25+
/// Fault severity label (FaultListItem.severity_label). Must include "UNKNOWN":
26+
/// fault_to_json emits it for any severity outside the four known levels
27+
/// (fault_msg_conversions.cpp default branch).
28+
inline constexpr std::string_view kFaultSeverityLabelValues[] = {"INFO", "WARN", "ERROR", "CRITICAL", "UNKNOWN"};
2729

2830
/// Fault aggregated status (fault_detail_schema - status.aggregatedStatus).
2931
inline constexpr std::string_view kFaultAggregatedStatusValues[] = {"active", "passive", "cleared"};
@@ -53,8 +55,5 @@ inline constexpr std::string_view kLogSeverityFilterValues[] = {"debug", "info",
5355
/// Execution control capability (execution_update_request_schema).
5456
inline constexpr std::string_view kExecutionCapabilityValues[] = {"stop", "execute", "freeze", "reset"};
5557

56-
/// Script control action (script_control_request_schema).
57-
inline constexpr std::string_view kScriptControlActionValues[] = {"stop", "forced_termination"};
58-
5958
} // namespace dto
6059
} // namespace ros2_medkit_gateway

src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/faults.hpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ inline constexpr auto dto_fields<FaultListItem> = std::make_tuple(
6262
field("description", &FaultListItem::description), field("first_occurred", &FaultListItem::first_occurred),
6363
field("last_occurred", &FaultListItem::last_occurred), field("occurrence_count", &FaultListItem::occurrence_count),
6464
field("status", &FaultListItem::status), field("reporting_sources", &FaultListItem::reporting_sources),
65-
field("severity_label", &FaultListItem::severity_label));
65+
field_enum("severity_label", &FaultListItem::severity_label, kFaultSeverityLabelValues));
6666

6767
template <>
6868
inline constexpr std::string_view dto_name<FaultListItem> = "FaultListItem";

src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/json_reader.hpp

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,20 @@ struct JsonReader {
133133
auto & member = obj.*(f.ptr);
134134
using MemberT = std::decay_t<decltype(member)>;
135135
const auto it = j.find(key);
136-
if (it == j.end() || it->is_null()) {
136+
// A present-but-null JSON value is a legitimate value for free-form json
137+
// members (e.g. PUT .../data/{id} or .../configurations/{id} with
138+
// {"data": null} to set a parameter to null). For every other member type
139+
// null cannot be coerced, so it is treated like an absent field.
140+
const bool present = (it != j.end());
141+
bool treat_as_absent = !present;
142+
if (present && it->is_null()) {
143+
if constexpr (is_optional_v<MemberT>) {
144+
treat_as_absent = !std::is_same_v<typename MemberT::value_type, nlohmann::json>;
145+
} else {
146+
treat_as_absent = !std::is_same_v<MemberT, nlohmann::json>;
147+
}
148+
}
149+
if (treat_as_absent) {
137150
if (f.presence == Presence::kRequired) {
138151
errs.push_back({key, "missing required field"});
139152
}

src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/json_writer.hpp

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,15 @@ nlohmann::json encode_value(const U & v) {
4444
return encode_value(alt);
4545
},
4646
v);
47-
} else {
47+
} else if constexpr (std::is_same_v<U, std::string> || std::is_same_v<U, bool> || std::is_integral_v<U> ||
48+
std::is_floating_point_v<U> || std::is_same_v<U, nlohmann::json>) {
4849
// string / bool / integral / floating / nlohmann::json passthrough
4950
return nlohmann::json(v);
51+
} else {
52+
// Mirror decode_value / schema_of: fail loud if a field type leaks here that
53+
// is neither a DTO, container, variant, nor a supported scalar (e.g. a DTO
54+
// sentinel used in a TU that never saw its dto_fields specialization).
55+
static_assert(sizeof(U) == 0, "encode_value: unsupported field type");
5056
}
5157
}
5258

src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/scripts.hpp

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323

2424
#include "ros2_medkit_gateway/dto/contract.hpp"
2525
#include "ros2_medkit_gateway/dto/entities.hpp"
26-
#include "ros2_medkit_gateway/dto/enums.hpp"
2726

2827
namespace ros2_medkit_gateway {
2928
namespace dto {
@@ -193,20 +192,23 @@ inline constexpr std::string_view dto_name<ScriptUploadResponse> = "ScriptUpload
193192
//
194193
// Wire shape (from script_control_request_schema() + handle_control_execution()):
195194
// action - control action to apply (required)
196-
// enum: "stop" | "forced_termination"
197-
//
198-
// Uses field_enum: the control handler validates only that "action" is present
199-
// and non-empty (ERR_INVALID_REQUEST for missing). It does NOT perform bespoke
200-
// value-range validation with ERR_INVALID_PARAMETER; parse_body provides the
201-
// enum check instead.
195+
// built-in backend: "stop" | "forced_termination"
196+
//
197+
// Uses plain field() (NOT field_enum): control_execution forwards `action`
198+
// verbatim to the ScriptProvider, which may be a plugin backend supporting
199+
// actions beyond the built-in stop/forced_termination (e.g. pause/resume).
200+
// Constraining the value at parse time would block those plugins at the
201+
// gateway. The handler validates presence (ERR_INVALID_REQUEST when missing);
202+
// the provider validates the value. (Same reasoning as
203+
// ExecutionUpdateRequest.capability.)
202204
// =============================================================================
203205
struct ScriptControlRequest {
204-
std::string action; // enum: stop | forced_termination
206+
std::string action; // built-in backend: stop | forced_termination; plugins may extend
205207
};
206208

207209
template <>
208210
inline constexpr auto dto_fields<ScriptControlRequest> =
209-
std::make_tuple(field_enum("action", &ScriptControlRequest::action, kScriptControlActionValues));
211+
std::make_tuple(field("action", &ScriptControlRequest::action));
210212

211213
template <>
212214
inline constexpr std::string_view dto_name<ScriptControlRequest> = "ScriptControlRequest";

src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,9 @@ RouteRegistry::binary_download(const std::string & openapi_path,
267267
std::function<http::Result<http::BinaryResponse>(http::TypedRequest)> handler) {
268268
auto renderer = std::make_shared<ErrorRenderer>(ErrorRenderer::kSovdGenericError);
269269
HandlerFn fn = [handler = std::move(handler), renderer](const httplib::Request & req, httplib::Response & res) {
270+
// Forwarding scope: entity-scoped binary downloads (bulk-data, scripts) on a
271+
// remote peer must proxy through validate_entity_for_route (see sse / wrap_body_less).
272+
http::detail::ForwardResponseScope forward_scope(&res);
270273
http::TypedRequest typed_req(req);
271274
auto outcome = handler(typed_req);
272275
if (!outcome.has_value()) {
@@ -302,6 +305,9 @@ RouteEntry & RouteRegistry::static_asset(const std::string & openapi_path,
302305
std::function<http::Result<http::StaticAsset>(http::TypedRequest)> handler) {
303306
auto renderer = std::make_shared<ErrorRenderer>(ErrorRenderer::kSovdGenericError);
304307
HandlerFn fn = [handler = std::move(handler), renderer](const httplib::Request & req, httplib::Response & res) {
308+
// Forwarding scope kept uniform across wrappers (static assets are not
309+
// entity-scoped, so this never forwards; see the comment at the top of this file).
310+
http::detail::ForwardResponseScope forward_scope(&res);
305311
http::TypedRequest typed_req(req);
306312
auto outcome = handler(typed_req);
307313
if (!outcome.has_value()) {
@@ -326,6 +332,8 @@ RouteEntry & RouteRegistry::docs_endpoint(const std::string & openapi_path,
326332
std::function<http::Result<nlohmann::json>(http::TypedRequest)> handler) {
327333
auto renderer = std::make_shared<ErrorRenderer>(ErrorRenderer::kSovdGenericError);
328334
HandlerFn fn = [handler = std::move(handler), renderer](const httplib::Request & req, httplib::Response & res) {
335+
// Forwarding scope kept uniform across wrappers (see the comment at the top of this file).
336+
http::detail::ForwardResponseScope forward_scope(&res);
329337
http::TypedRequest typed_req(req);
330338
auto outcome = handler(typed_req);
331339
if (!outcome.has_value()) {

src/ros2_medkit_gateway/src/openapi/route_registry.hpp

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,11 @@ HandlerFn RouteRegistry::wrap_with_body(std::function<http::Result<TResponse>(ht
556556
std::shared_ptr<ErrorRenderer> renderer) {
557557
return [handler = std::move(handler), renderer = std::move(renderer)](const httplib::Request & req,
558558
httplib::Response & res) {
559+
// Forwarding scope: lets the typed validate_entity_for_route stream a
560+
// proxied response when the entity is owned by a remote peer (see
561+
// wrap_body_less). Without it, remote-entity writes return Forwarded with
562+
// no sink and the client gets an empty body instead of the peer response.
563+
http::detail::ForwardResponseScope forward_scope(&res);
559564
auto body = detail::parse_request_body<TBody>(req);
560565
if (!body.has_value()) {
561566
write_typed_error(res, body.error(), renderer);
@@ -577,6 +582,8 @@ HandlerFn RouteRegistry::wrap_with_body_attachments(
577582
std::shared_ptr<ErrorRenderer> renderer) {
578583
return [handler = std::move(handler), renderer = std::move(renderer)](const httplib::Request & req,
579584
httplib::Response & res) {
585+
// Forwarding scope for remote-peer entities (see wrap_body_less / wrap_with_body).
586+
http::detail::ForwardResponseScope forward_scope(&res);
580587
auto body = detail::parse_request_body<TBody>(req);
581588
if (!body.has_value()) {
582589
write_typed_error(res, body.error(), renderer);
@@ -601,6 +608,8 @@ HandlerFn RouteRegistry::wrap_post_alternates(
601608
std::shared_ptr<ErrorRenderer> renderer) {
602609
return [handler = std::move(handler), renderer = std::move(renderer)](const httplib::Request & req,
603610
httplib::Response & res) {
611+
// Forwarding scope for remote-peer entities (see wrap_body_less / wrap_with_body).
612+
http::detail::ForwardResponseScope forward_scope(&res);
604613
auto body = detail::parse_request_body<TBody>(req);
605614
if (!body.has_value()) {
606615
write_typed_error(res, body.error(), renderer);
@@ -629,6 +638,8 @@ HandlerFn RouteRegistry::wrap_post_alternates_with_attachments(
629638
std::shared_ptr<ErrorRenderer> renderer) {
630639
return [handler = std::move(handler), renderer = std::move(renderer)](const httplib::Request & req,
631640
httplib::Response & res) {
641+
// Forwarding scope for remote-peer entities (see wrap_body_less / wrap_with_body).
642+
http::detail::ForwardResponseScope forward_scope(&res);
632643
auto body = detail::parse_request_body<TBody>(req);
633644
if (!body.has_value()) {
634645
write_typed_error(res, body.error(), renderer);
@@ -659,6 +670,8 @@ RouteRegistry::wrap_del_alternates(std::function<http::Result<std::variant<TAlt.
659670
std::shared_ptr<ErrorRenderer> renderer) {
660671
return [handler = std::move(handler), renderer = std::move(renderer)](const httplib::Request & req,
661672
httplib::Response & res) {
673+
// Forwarding scope for remote-peer entities (see wrap_body_less / wrap_with_body).
674+
http::detail::ForwardResponseScope forward_scope(&res);
662675
http::TypedRequest typed_req(req);
663676
auto outcome = handler(typed_req);
664677
if (outcome.has_value()) {
@@ -980,8 +993,10 @@ RouteEntry & RouteRegistry::multipart_upload(
980993
"multipart_upload<T>: T must be a DTO (or NoContent)");
981994
auto renderer = std::make_shared<ErrorRenderer>(ErrorRenderer::kSovdGenericError);
982995
HandlerFn fn = [handler = std::move(handler), renderer](const httplib::Request & req, httplib::Response & res) {
996+
// Forwarding scope for remote-peer entities (see wrap_body_less / wrap_with_body).
997+
http::detail::ForwardResponseScope forward_scope(&res);
983998
http::MultipartBody body;
984-
body.parts = req.files.empty() ? httplib::MultipartFormDataItems{} : httplib::MultipartFormDataItems{};
999+
// body.parts default-constructs empty; the loop below populates it from req.files.
9851000
// cpp-httplib exposes parsed multipart entries via `req.files`; surface
9861001
// them through MultipartBody.parts as MultipartFormData entries.
9871002
for (const auto & [name, file] : req.files) {

0 commit comments

Comments
 (0)