Skip to content

Commit 9b9cb29

Browse files
author
Michał Fąferek
committed
[#19] feat: add GET /components/{component_id}/data/{topic_name} endpoint
Add endpoint to read specific topic data from a component. Fix topic path construction to use namespace_path instead of fqn for correct topic discovery.
1 parent 44ac9f9 commit 9b9cb29

File tree

5 files changed

+481
-9
lines changed

5 files changed

+481
-9
lines changed

postman/collections/ros2-medkit-gateway.postman_collection.json

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@
8888
"name": "Component Data",
8989
"item": [
9090
{
91-
"name": "GET Component Data",
91+
"name": "GET Component Data (All Topics)",
9292
"request": {
9393
"method": "GET",
9494
"header": [],
@@ -106,6 +106,69 @@
106106
"description": "Read all topic data from a specific component. Returns array of topic samples with metadata (topic, timestamp, data). Change 'temp_sensor' to other component IDs like 'rpm_sensor', 'pressure_sensor', etc."
107107
},
108108
"response": []
109+
},
110+
{
111+
"name": "GET Component Topic Data (Temperature)",
112+
"request": {
113+
"method": "GET",
114+
"header": [],
115+
"url": {
116+
"raw": "{{base_url}}/components/temp_sensor/data/temperature",
117+
"host": [
118+
"{{base_url}}"
119+
],
120+
"path": [
121+
"components",
122+
"temp_sensor",
123+
"data",
124+
"temperature"
125+
]
126+
},
127+
"description": "Read specific topic data from a component. Returns single topic sample with topic path, timestamp, and data. The topic_name is relative to the component's namespace."
128+
},
129+
"response": []
130+
},
131+
{
132+
"name": "GET Component Topic Data (RPM)",
133+
"request": {
134+
"method": "GET",
135+
"header": [],
136+
"url": {
137+
"raw": "{{base_url}}/components/rpm_sensor/data/rpm",
138+
"host": [
139+
"{{base_url}}"
140+
],
141+
"path": [
142+
"components",
143+
"rpm_sensor",
144+
"data",
145+
"rpm"
146+
]
147+
},
148+
"description": "Read RPM topic data from the rpm_sensor component."
149+
},
150+
"response": []
151+
},
152+
{
153+
"name": "GET Component Topic Data (Pressure)",
154+
"request": {
155+
"method": "GET",
156+
"header": [],
157+
"url": {
158+
"raw": "{{base_url}}/components/pressure_sensor/data/pressure",
159+
"host": [
160+
"{{base_url}}"
161+
],
162+
"path": [
163+
"components",
164+
"pressure_sensor",
165+
"data",
166+
"pressure"
167+
]
168+
},
169+
"description": "Read brake pressure topic data from the pressure_sensor component."
170+
},
171+
"response": []
109172
}
110173
]
111174
}

src/ros2_medkit_gateway/README.md

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ The ROS 2 Medkit Gateway exposes ROS 2 system information and data through a RES
2525
### Component Data Endpoints
2626

2727
- `GET /components/{component_id}/data` - Read all topic data from a component
28+
- `GET /components/{component_id}/data/{topic_name}` - Read specific topic data from a component
2829

2930
### API Reference
3031

@@ -197,11 +198,91 @@ curl http://localhost:8080/components/nonexistent/data
197198
- Data logging - Periodic sampling of component data
198199

199200
**Performance Considerations:**
200-
- Topic sampling is currently **sequential** (one topic at a time)
201-
- Response time scales linearly: `(number of topics) × (timeout per topic)`
202-
- Example: A component with 10 topics could take up to 30 seconds (10 × 3s)
203-
- For components with many topics, consider querying specific topics individually
204-
- **Future improvement**: Parallel topic sampling will be implemented to reduce latency
201+
- Topic sampling uses **parallel execution** with configurable concurrency
202+
- Default: up to 10 topics sampled in parallel (configurable via `max_parallel_topic_samples`)
203+
- Response time scales with batch count: `ceil(topics / batch_size) × timeout`
204+
- 3-second timeout per topic to accommodate slow-publishing topics
205+
206+
#### GET /components/{component_id}/data/{topic_name}
207+
208+
Read data from a specific topic within a component.
209+
210+
**Example:**
211+
```bash
212+
curl http://localhost:8080/components/temp_sensor/data/temperature
213+
```
214+
215+
**Response (200 OK):**
216+
```json
217+
{
218+
"topic": "/powertrain/engine/temperature",
219+
"timestamp": 1732377600000000000,
220+
"data": {
221+
"temperature": 85.5,
222+
"variance": 0.0
223+
}
224+
}
225+
```
226+
227+
**Example (Error - Topic Not Found):**
228+
```bash
229+
curl http://localhost:8080/components/temp_sensor/data/nonexistent
230+
```
231+
232+
**Response (404 Not Found):**
233+
```json
234+
{
235+
"error": "Topic not found or not publishing",
236+
"component_id": "temp_sensor",
237+
"topic_name": "nonexistent"
238+
}
239+
```
240+
241+
**Example (Error - Component Not Found):**
242+
```bash
243+
curl http://localhost:8080/components/nonexistent/data/temperature
244+
```
245+
246+
**Response (404 Not Found):**
247+
```json
248+
{
249+
"error": "Component not found",
250+
"component_id": "nonexistent"
251+
}
252+
```
253+
254+
**Example (Error - Invalid Topic Name):**
255+
```bash
256+
curl http://localhost:8080/components/temp_sensor/data/invalid-name
257+
```
258+
259+
**Response (400 Bad Request):**
260+
```json
261+
{
262+
"error": "Invalid topic name",
263+
"details": "Entity ID contains invalid character: '-'. Only alphanumeric and underscore are allowed",
264+
"topic_name": "invalid-name"
265+
}
266+
```
267+
268+
**URL Parameters:**
269+
- `component_id` - Component identifier (e.g., `temp_sensor`, `rpm_sensor`)
270+
- `topic_name` - Topic name within the component (e.g., `temperature`, `rpm`)
271+
272+
**Response Fields:**
273+
- `topic` - Full topic path (e.g., `/powertrain/engine/temperature`)
274+
- `timestamp` - Unix timestamp (nanoseconds since epoch) when data was sampled
275+
- `data` - Topic message data as JSON object
276+
277+
**Validation:**
278+
- Both `component_id` and `topic_name` follow ROS 2 naming conventions
279+
- Allowed characters: alphanumeric (a-z, A-Z, 0-9), underscore (_)
280+
- Hyphens, special characters, and escape sequences are rejected
281+
282+
**Use Cases:**
283+
- Read specific sensor value (e.g., just temperature, not all engine data)
284+
- Lower latency than reading all component data
285+
- Targeted monitoring of specific metrics
205286

206287
## Quick Start
207288

src/ros2_medkit_gateway/include/ros2_medkit_gateway/rest_server.hpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ class RESTServer {
4444
void handle_list_components(const httplib::Request& req, httplib::Response& res);
4545
void handle_area_components(const httplib::Request& req, httplib::Response& res);
4646
void handle_component_data(const httplib::Request& req, httplib::Response& res);
47+
void handle_component_topic_data(const httplib::Request& req, httplib::Response& res);
4748

4849
// Helper methods
4950
std::expected<void, std::string> validate_entity_id(const std::string& entity_id) const;

src/ros2_medkit_gateway/src/rest_server.cpp

Lines changed: 129 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,13 @@ void RESTServer::setup_routes() {
6060
handle_area_components(req, res);
6161
});
6262

63-
// Component data
64-
server_->Get(R"(/components/([^/]+)/data)", [this](const httplib::Request& req, httplib::Response& res) {
63+
// Component topic data (specific topic) - register before general route
64+
server_->Get(R"(/components/([^/]+)/data/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
65+
handle_component_topic_data(req, res);
66+
});
67+
68+
// Component data (all topics)
69+
server_->Get(R"(/components/([^/]+)/data$)", [this](const httplib::Request& req, httplib::Response& res) {
6570
handle_component_data(req, res);
6671
});
6772
}
@@ -330,7 +335,7 @@ void RESTServer::handle_component_data(const httplib::Request& req, httplib::Res
330335

331336
for (const auto& component : cache.components) {
332337
if (component.id == component_id) {
333-
component_namespace = component.fqn;
338+
component_namespace = component.namespace_path;
334339
component_found = true;
335340
break;
336341
}
@@ -349,6 +354,7 @@ void RESTServer::handle_component_data(const httplib::Request& req, httplib::Res
349354
}
350355

351356
// Get component data from DataAccessManager
357+
// Use namespace_path to find topics (topics are relative to namespace, not FQN)
352358
auto data_access_mgr = node_->get_data_access_manager();
353359
json component_data = data_access_mgr->get_component_data(component_namespace);
354360

@@ -372,4 +378,124 @@ void RESTServer::handle_component_data(const httplib::Request& req, httplib::Res
372378
}
373379
}
374380

381+
void RESTServer::handle_component_topic_data(const httplib::Request& req, httplib::Response& res) {
382+
std::string component_id;
383+
std::string topic_name;
384+
try {
385+
// Extract component_id and topic_name from URL path
386+
if (req.matches.size() < 3) {
387+
res.status = StatusCode::BadRequest_400;
388+
res.set_content(
389+
json{{"error", "Invalid request"}}.dump(2),
390+
"application/json"
391+
);
392+
return;
393+
}
394+
395+
component_id = req.matches[1];
396+
topic_name = req.matches[2];
397+
398+
// Validate component_id
399+
auto component_validation = validate_entity_id(component_id);
400+
if (!component_validation) {
401+
res.status = StatusCode::BadRequest_400;
402+
res.set_content(
403+
json{
404+
{"error", "Invalid component ID"},
405+
{"details", component_validation.error()},
406+
{"component_id", component_id}
407+
}.dump(2),
408+
"application/json"
409+
);
410+
return;
411+
}
412+
413+
// Validate topic_name
414+
auto topic_validation = validate_entity_id(topic_name);
415+
if (!topic_validation) {
416+
res.status = StatusCode::BadRequest_400;
417+
res.set_content(
418+
json{
419+
{"error", "Invalid topic name"},
420+
{"details", topic_validation.error()},
421+
{"topic_name", topic_name}
422+
}.dump(2),
423+
"application/json"
424+
);
425+
return;
426+
}
427+
428+
const auto cache = node_->get_entity_cache();
429+
430+
// Find component in cache
431+
std::string component_namespace;
432+
bool component_found = false;
433+
434+
for (const auto& component : cache.components) {
435+
if (component.id == component_id) {
436+
component_namespace = component.namespace_path;
437+
component_found = true;
438+
break;
439+
}
440+
}
441+
442+
if (!component_found) {
443+
res.status = StatusCode::NotFound_404;
444+
res.set_content(
445+
json{
446+
{"error", "Component not found"},
447+
{"component_id", component_id}
448+
}.dump(2),
449+
"application/json"
450+
);
451+
return;
452+
}
453+
454+
// Construct full topic path: {namespace_path}/{topic_name}
455+
// Handle root namespace case to avoid double slash (//topic_name)
456+
std::string full_topic_path = (component_namespace == "/")
457+
? "/" + topic_name
458+
: component_namespace + "/" + topic_name;
459+
460+
// Get topic data from DataAccessManager
461+
auto data_access_mgr = node_->get_data_access_manager();
462+
json topic_data = data_access_mgr->get_topic_sample(full_topic_path);
463+
464+
res.set_content(topic_data.dump(2), "application/json");
465+
} catch (const std::exception& e) {
466+
// Check if it's a "topic not found" type error
467+
std::string error_msg = e.what();
468+
if (error_msg.find("not available") != std::string::npos ||
469+
error_msg.find("timeout") != std::string::npos) {
470+
res.status = StatusCode::NotFound_404;
471+
res.set_content(
472+
json{
473+
{"error", "Topic not found or not publishing"},
474+
{"component_id", component_id},
475+
{"topic_name", topic_name}
476+
}.dump(2),
477+
"application/json"
478+
);
479+
} else {
480+
res.status = StatusCode::InternalServerError_500;
481+
res.set_content(
482+
json{
483+
{"error", "Failed to retrieve topic data"},
484+
{"details", e.what()},
485+
{"component_id", component_id},
486+
{"topic_name", topic_name}
487+
}.dump(2),
488+
"application/json"
489+
);
490+
}
491+
RCLCPP_ERROR(
492+
rclcpp::get_logger("rest_server"),
493+
"Error in handle_component_topic_data for component '%s', topic '%s': %s",
494+
component_id.c_str(),
495+
topic_name.c_str(),
496+
e.what()
497+
);
498+
}
499+
}
500+
375501
} // namespace ros2_medkit_gateway

0 commit comments

Comments
 (0)