Skip to content

Commit 4aeadc5

Browse files
committed
fix(discovery): add input validation and improve test reliability
- Add empty area/pattern validation in apply_component_name_pattern() with WARN logging and safe fallbacks - Add DEBUG logging for duplicate node detection in discover_node_components() - Refactor config_handlers.cpp to use unified get_entity_info() lookup, reducing code duplication (~80 lines) - Increase action test timeout to 30s and use shared ACTION_TIMEOUT constant - Add documentation for expected entities in integration tests
1 parent 3cac909 commit 4aeadc5

File tree

3 files changed

+74
-130
lines changed

3 files changed

+74
-130
lines changed

src/ros2_medkit_gateway/src/discovery/runtime_discovery.cpp

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ std::vector<Component> RuntimeDiscoveryStrategy::discover_node_components() {
128128

129129
// Skip duplicate nodes - ROS 2 RMW may report same node multiple times
130130
if (seen_fqns.count(fqn) > 0) {
131+
RCLCPP_DEBUG(node_->get_logger(), "Skipping duplicate node: %s", fqn.c_str());
131132
continue;
132133
}
133134
seen_fqns.insert(fqn);
@@ -611,6 +612,17 @@ std::string RuntimeDiscoveryStrategy::derive_component_id(const Component & node
611612
}
612613

613614
std::string RuntimeDiscoveryStrategy::apply_component_name_pattern(const std::string & area) {
615+
// Validate inputs to prevent unexpected empty component IDs
616+
if (area.empty()) {
617+
RCLCPP_WARN(node_->get_logger(), "apply_component_name_pattern called with empty area, using 'unknown'");
618+
return apply_component_name_pattern("unknown");
619+
}
620+
621+
if (config_.synthetic_component_name_pattern.empty()) {
622+
RCLCPP_WARN(node_->get_logger(), "Empty synthetic_component_name_pattern, using area directly: %s", area.c_str());
623+
return area;
624+
}
625+
614626
std::string result = config_.synthetic_component_name_pattern;
615627

616628
// Replace {area} placeholder

src/ros2_medkit_gateway/src/http/handlers/config_handlers.cpp

Lines changed: 41 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -96,56 +96,36 @@ void ConfigHandlers::handle_get_configuration(const httplib::Request & req, http
9696
return;
9797
}
9898

99-
const auto cache = ctx_.node()->get_entity_cache();
100-
101-
std::string node_name;
102-
bool entity_found = false;
103-
std::string id_field = "component_id";
104-
105-
// Try components first
106-
for (const auto & component : cache.components) {
107-
if (component.id == entity_id) {
108-
node_name = component.fqn;
109-
entity_found = true;
110-
break;
111-
}
112-
}
113-
114-
// If not found in components, try apps
115-
if (!entity_found) {
116-
for (const auto & app : cache.apps) {
117-
if (app.id == entity_id) {
118-
node_name = app.bound_fqn.value_or("/" + app.id);
119-
entity_found = true;
120-
id_field = "app_id";
121-
break;
122-
}
123-
}
99+
// Use unified entity lookup
100+
auto entity_info = ctx_.get_entity_info(entity_id);
101+
if (entity_info.type == EntityType::UNKNOWN) {
102+
HandlerContext::send_error(res, StatusCode::NotFound_404, "Entity not found", {{"entity_id", entity_id}});
103+
return;
124104
}
125105

126-
if (!entity_found) {
127-
std::string error_msg = (id_field == "app_id") ? "App not found" : "Component not found";
128-
HandlerContext::send_error(res, StatusCode::NotFound_404, error_msg, {{id_field, entity_id}});
129-
return;
106+
// Get node name for parameter access
107+
std::string node_name = entity_info.fqn;
108+
if (node_name.empty()) {
109+
node_name = "/" + entity_id;
130110
}
131111

132112
auto config_mgr = ctx_.node()->get_configuration_manager();
133113
auto result = config_mgr->get_parameter(node_name, param_name);
134114

135115
if (result.success) {
136-
json response = {{id_field, entity_id}, {"parameter", result.data}};
116+
json response = {{entity_info.id_field, entity_id}, {"parameter", result.data}};
137117
HandlerContext::send_json(res, response);
138118
} else {
139119
// Check if it's a "not found" error
140120
if (result.error_message.find("not found") != std::string::npos ||
141121
result.error_message.find("Parameter not found") != std::string::npos) {
142122
HandlerContext::send_error(
143123
res, StatusCode::NotFound_404, "Failed to get parameter",
144-
{{"details", result.error_message}, {id_field, entity_id}, {"param_name", param_name}});
124+
{{"details", result.error_message}, {entity_info.id_field, entity_id}, {"param_name", param_name}});
145125
} else {
146126
HandlerContext::send_error(
147127
res, StatusCode::ServiceUnavailable_503, "Failed to get parameter",
148-
{{"details", result.error_message}, {id_field, entity_id}, {"param_name", param_name}});
128+
{{"details", result.error_message}, {entity_info.id_field, entity_id}, {"param_name", param_name}});
149129
}
150130
}
151131
} catch (const std::exception & e) {
@@ -200,44 +180,24 @@ void ConfigHandlers::handle_set_configuration(const httplib::Request & req, http
200180

201181
json value = body["value"];
202182

203-
const auto cache = ctx_.node()->get_entity_cache();
204-
205-
std::string node_name;
206-
bool entity_found = false;
207-
std::string id_field = "component_id";
208-
209-
// Try components first
210-
for (const auto & component : cache.components) {
211-
if (component.id == entity_id) {
212-
node_name = component.fqn;
213-
entity_found = true;
214-
break;
215-
}
216-
}
217-
218-
// If not found in components, try apps
219-
if (!entity_found) {
220-
for (const auto & app : cache.apps) {
221-
if (app.id == entity_id) {
222-
node_name = app.bound_fqn.value_or("/" + app.id);
223-
entity_found = true;
224-
id_field = "app_id";
225-
break;
226-
}
227-
}
183+
// Use unified entity lookup
184+
auto entity_info = ctx_.get_entity_info(entity_id);
185+
if (entity_info.type == EntityType::UNKNOWN) {
186+
HandlerContext::send_error(res, StatusCode::NotFound_404, "Entity not found", {{"entity_id", entity_id}});
187+
return;
228188
}
229189

230-
if (!entity_found) {
231-
std::string error_msg = (id_field == "app_id") ? "App not found" : "Component not found";
232-
HandlerContext::send_error(res, StatusCode::NotFound_404, error_msg, {{id_field, entity_id}});
233-
return;
190+
// Get node name for parameter access
191+
std::string node_name = entity_info.fqn;
192+
if (node_name.empty()) {
193+
node_name = "/" + entity_id;
234194
}
235195

236196
auto config_mgr = ctx_.node()->get_configuration_manager();
237197
auto result = config_mgr->set_parameter(node_name, param_name, value);
238198

239199
if (result.success) {
240-
json response = {{"status", "success"}, {id_field, entity_id}, {"parameter", result.data}};
200+
json response = {{"status", "success"}, {entity_info.id_field, entity_id}, {"parameter", result.data}};
241201
HandlerContext::send_json(res, response);
242202
} else {
243203
// Check if it's a read-only, not found, or service unavailable error
@@ -257,7 +217,7 @@ void ConfigHandlers::handle_set_configuration(const httplib::Request & req, http
257217
}
258218
HandlerContext::send_error(
259219
res, status_code, "Failed to set parameter",
260-
{{"details", result.error_message}, {id_field, entity_id}, {"param_name", param_name}});
220+
{{"details", result.error_message}, {entity_info.id_field, entity_id}, {"param_name", param_name}});
261221
}
262222
} catch (const std::exception & e) {
263223
HandlerContext::send_error(res, StatusCode::InternalServerError_500, "Failed to set configuration",
@@ -287,38 +247,17 @@ void ConfigHandlers::handle_delete_configuration(const httplib::Request & req, h
287247
return;
288248
}
289249

290-
const auto cache = ctx_.node()->get_entity_cache();
291-
292-
// Find entity to get its namespace and node name
293-
std::string node_name;
294-
bool entity_found = false;
295-
std::string id_field = "component_id";
296-
297-
// Try components first
298-
for (const auto & component : cache.components) {
299-
if (component.id == entity_id) {
300-
node_name = component.fqn;
301-
entity_found = true;
302-
break;
303-
}
304-
}
305-
306-
// If not found in components, try apps
307-
if (!entity_found) {
308-
for (const auto & app : cache.apps) {
309-
if (app.id == entity_id) {
310-
node_name = app.bound_fqn.value_or("/" + app.id);
311-
entity_found = true;
312-
id_field = "app_id";
313-
break;
314-
}
315-
}
250+
// Use unified entity lookup
251+
auto entity_info = ctx_.get_entity_info(entity_id);
252+
if (entity_info.type == EntityType::UNKNOWN) {
253+
HandlerContext::send_error(res, StatusCode::NotFound_404, "Entity not found", {{"entity_id", entity_id}});
254+
return;
316255
}
317256

318-
if (!entity_found) {
319-
std::string error_msg = (id_field == "app_id") ? "App not found" : "Component not found";
320-
HandlerContext::send_error(res, StatusCode::NotFound_404, error_msg, {{id_field, entity_id}});
321-
return;
257+
// Get node name for parameter access
258+
std::string node_name = entity_info.fqn;
259+
if (node_name.empty()) {
260+
node_name = "/" + entity_id;
322261
}
323262

324263
auto config_mgr = ctx_.node()->get_configuration_manager();
@@ -356,38 +295,17 @@ void ConfigHandlers::handle_delete_all_configurations(const httplib::Request & r
356295
return;
357296
}
358297

359-
const auto cache = ctx_.node()->get_entity_cache();
360-
361-
// Find entity to get its namespace and node name
362-
std::string node_name;
363-
bool entity_found = false;
364-
std::string id_field = "component_id";
365-
366-
// Try components first
367-
for (const auto & component : cache.components) {
368-
if (component.id == entity_id) {
369-
node_name = component.fqn;
370-
entity_found = true;
371-
break;
372-
}
373-
}
374-
375-
// If not found in components, try apps
376-
if (!entity_found) {
377-
for (const auto & app : cache.apps) {
378-
if (app.id == entity_id) {
379-
node_name = app.bound_fqn.value_or("/" + app.id);
380-
entity_found = true;
381-
id_field = "app_id";
382-
break;
383-
}
384-
}
298+
// Use unified entity lookup
299+
auto entity_info = ctx_.get_entity_info(entity_id);
300+
if (entity_info.type == EntityType::UNKNOWN) {
301+
HandlerContext::send_error(res, StatusCode::NotFound_404, "Entity not found", {{"entity_id", entity_id}});
302+
return;
385303
}
386304

387-
if (!entity_found) {
388-
std::string error_msg = (id_field == "app_id") ? "App not found" : "Component not found";
389-
HandlerContext::send_error(res, StatusCode::NotFound_404, error_msg, {{id_field, entity_id}});
390-
return;
305+
// Get node name for parameter access
306+
std::string node_name = entity_info.fqn;
307+
if (node_name.empty()) {
308+
node_name = "/" + entity_id;
391309
}
392310

393311
auto config_mgr = ctx_.node()->get_configuration_manager();

src/ros2_medkit_gateway/test/test_integration.test.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -257,18 +257,30 @@ def generate_test_description():
257257
# API version prefix - must match rest_server.cpp
258258
API_BASE_PATH = '/api/v1'
259259

260+
# Action timeout - 30s should be sufficient, if still flaky then code has performance issues
261+
ACTION_TIMEOUT = 30.0
262+
260263

261264
class TestROS2MedkitGatewayIntegration(unittest.TestCase):
262265
"""Integration tests for ROS 2 Medkit Gateway REST API and discovery."""
263266

264267
BASE_URL = f'http://localhost:8080{API_BASE_PATH}'
268+
269+
# Expected entities from demo_nodes.launch.py:
270+
# - Apps: temp_sensor, rpm_sensor, pressure_sensor, status_sensor, actuator,
271+
# controller, calibration, long_calibration, lidar_sensor (9 total)
272+
# - Areas: powertrain, chassis, body, perception, root (5 total)
273+
# - Components: Synthetic groupings by area (powertrain, chassis, body, perception)
274+
# created from nodes in those namespaces
275+
#
265276
# Minimum expected components (synthetic, grouped by top-level namespace)
266-
# With default config: powertrain, chassis, body, perception, root
277+
# With default config: powertrain, chassis, body, perception (root may not have synthetic)
267278
MIN_EXPECTED_COMPONENTS = 4
268-
# Minimum expected apps (ROS 2 nodes)
279+
# Minimum expected apps (ROS 2 nodes from demo launch)
269280
MIN_EXPECTED_APPS = 8
270-
# Minimum expected areas (powertrain, chassis, body + root)
281+
# Minimum expected areas (powertrain, chassis, body, perception + root)
271282
MIN_EXPECTED_AREAS = 4
283+
272284
# Maximum time to wait for discovery (seconds)
273285
MAX_DISCOVERY_WAIT = 60.0
274286
# Interval between discovery checks (seconds)
@@ -321,7 +333,7 @@ def _get_json(self, endpoint: str, timeout: int = 10):
321333
return response.json()
322334

323335
def _wait_for_action_status(
324-
self, goal_id: str, target_statuses: list, max_wait: float = 15.0
336+
self, goal_id: str, target_statuses: list, max_wait: float = None
325337
) -> dict:
326338
"""
327339
Poll action status until it reaches one of the target statuses.
@@ -333,7 +345,7 @@ def _wait_for_action_status(
333345
target_statuses : list
334346
List of status strings to wait for (e.g., ['succeeded', 'aborted']).
335347
max_wait : float
336-
Maximum time to wait in seconds (default: 15.0).
348+
Maximum time to wait in seconds. Defaults to ACTION_TIMEOUT (30s).
337349
338350
Returns
339351
-------
@@ -346,6 +358,8 @@ def _wait_for_action_status(
346358
If target status is not reached within max_wait.
347359
348360
"""
361+
if max_wait is None:
362+
max_wait = ACTION_TIMEOUT
349363
start_time = time.time()
350364
last_status = None
351365
while time.time() - start_time < max_wait:
@@ -1483,7 +1497,7 @@ def test_41_action_status_after_completion(self):
14831497

14841498
# Poll for completion instead of fixed sleep (handles CI timing variance)
14851499
data = self._wait_for_action_status(
1486-
goal_id, ['succeeded', 'aborted'], max_wait=15.0
1500+
goal_id, ['succeeded', 'aborted'], max_wait=ACTION_TIMEOUT
14871501
)
14881502

14891503
self.assertIn('goal_id', data)
@@ -1511,7 +1525,7 @@ def test_42_action_cancel_endpoint(self):
15111525
# Poll until action is executing (handles CI timing variance)
15121526
try:
15131527
self._wait_for_action_status(
1514-
goal_id, ['executing'], max_wait=10.0
1528+
goal_id, ['executing'], max_wait=ACTION_TIMEOUT
15151529
)
15161530
except AssertionError:
15171531
# If action already completed or is still in accepted, try cancel anyway

0 commit comments

Comments
 (0)