Skip to content

Commit 26d7af8

Browse files
author
Michał Fąferek
committed
[#40] feat: implement PUT /components/{component_id}/data/{topic_name} endpoint
- Add publish_to_topic() method to DataAccessManager using ros2 topic pub --once - Implement PUT handler with input validation: - Required fields: type (message type), data (payload) - Message type format validation (package/msg/Type) - Component existence check - Add 6 integration tests for publish endpoint (REQ_INTEROP_020) - Update Postman collection with PUT examples - Standardize endpoint list format with HTTP method prefixes
1 parent 9984654 commit 26d7af8

File tree

7 files changed

+484
-19
lines changed

7 files changed

+484
-19
lines changed

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,66 @@
187187
"description": "Read brake pressure topic data from the pressure_sensor component."
188188
},
189189
"response": []
190+
},
191+
{
192+
"name": "PUT Publish Brake Command",
193+
"request": {
194+
"method": "PUT",
195+
"header": [
196+
{
197+
"key": "Content-Type",
198+
"value": "application/json"
199+
}
200+
],
201+
"body": {
202+
"mode": "raw",
203+
"raw": "{\n \"type\": \"std_msgs/msg/Float32\",\n \"data\": {\n \"data\": 50.0\n }\n}"
204+
},
205+
"url": {
206+
"raw": "{{base_url}}/components/actuator/data/command",
207+
"host": [
208+
"{{base_url}}"
209+
],
210+
"path": [
211+
"components",
212+
"actuator",
213+
"data",
214+
"command"
215+
]
216+
},
217+
"description": "Publish data to a component topic (REQ_INTEROP_020). Request body must contain 'type' (ROS 2 message type) and 'data' (message payload). Returns publish confirmation with topic, type, status, and timestamp."
218+
},
219+
"response": []
220+
},
221+
{
222+
"name": "PUT Publish Temperature Value",
223+
"request": {
224+
"method": "PUT",
225+
"header": [
226+
{
227+
"key": "Content-Type",
228+
"value": "application/json"
229+
}
230+
],
231+
"body": {
232+
"mode": "raw",
233+
"raw": "{\n \"type\": \"sensor_msgs/msg/Temperature\",\n \"data\": {\n \"temperature\": 85.5,\n \"variance\": 0.1\n }\n}"
234+
},
235+
"url": {
236+
"raw": "{{base_url}}/components/temp_sensor/data/temperature",
237+
"host": [
238+
"{{base_url}}"
239+
],
240+
"path": [
241+
"components",
242+
"temp_sensor",
243+
"data",
244+
"temperature"
245+
]
246+
},
247+
"description": "Publish temperature data to the temp_sensor component. Example of publishing sensor_msgs/msg/Temperature type."
248+
},
249+
"response": []
190250
}
191251
]
192252
}

src/ros2_medkit_gateway/include/ros2_medkit_gateway/data_access_manager.hpp

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,21 @@ class DataAccessManager {
4848
double timeout_sec = 3.0
4949
);
5050

51+
/**
52+
* @brief Publish data to a specific topic
53+
* @param topic_path Full topic path (e.g., /chassis/brakes/command)
54+
* @param msg_type ROS 2 message type (e.g., std_msgs/msg/Float32)
55+
* @param data JSON data to publish
56+
* @param timeout_sec Timeout for the publish operation
57+
* @return JSON with publish status
58+
*/
59+
json publish_to_topic(
60+
const std::string& topic_path,
61+
const std::string& msg_type,
62+
const json& data,
63+
double timeout_sec = 5.0
64+
);
65+
5166
private:
5267
/**
5368
* @brief Find all topics under a given namespace

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
@@ -46,6 +46,7 @@ class RESTServer {
4646
void handle_area_components(const httplib::Request& req, httplib::Response& res);
4747
void handle_component_data(const httplib::Request& req, httplib::Response& res);
4848
void handle_component_topic_data(const httplib::Request& req, httplib::Response& res);
49+
void handle_component_topic_publish(const httplib::Request& req, httplib::Response& res);
4950

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

src/ros2_medkit_gateway/src/data_access_manager.cpp

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,4 +207,56 @@ json DataAccessManager::get_component_data(
207207
return result;
208208
}
209209

210+
json DataAccessManager::publish_to_topic(
211+
const std::string& topic_path,
212+
const std::string& msg_type,
213+
const json& data,
214+
double timeout_sec
215+
) {
216+
try {
217+
// Convert JSON data to string for ros2 topic pub
218+
// Note: data.dump() produces JSON format, but ROS 2 CLI accepts JSON
219+
// as valid YAML (JSON is a subset of YAML 1.2)
220+
std::string yaml_data = data.dump();
221+
222+
// TODO(mfaferek93) #32: Check timeout command availability
223+
// GNU coreutils 'timeout' may not be available on all systems (BSD, containers)
224+
// Should check in constructor or provide fallback mechanism
225+
std::ostringstream cmd;
226+
cmd << "timeout " << static_cast<int>(std::ceil(timeout_sec)) << "s "
227+
<< "ros2 topic pub --once "
228+
<< ROS2CLIWrapper::escape_shell_arg(topic_path) << " "
229+
<< ROS2CLIWrapper::escape_shell_arg(msg_type) << " "
230+
<< ROS2CLIWrapper::escape_shell_arg(yaml_data);
231+
232+
RCLCPP_INFO(node_->get_logger(), "Executing: %s", cmd.str().c_str());
233+
234+
std::string output = cli_wrapper_->exec(cmd.str());
235+
236+
RCLCPP_INFO(node_->get_logger(),
237+
"Published to topic '%s' with type '%s'",
238+
topic_path.c_str(),
239+
msg_type.c_str());
240+
241+
json result = {
242+
{"topic", topic_path},
243+
{"type", msg_type},
244+
{"status", "published"},
245+
{"timestamp", std::chrono::duration_cast<std::chrono::nanoseconds>(
246+
std::chrono::system_clock::now().time_since_epoch()
247+
).count()}
248+
};
249+
250+
return result;
251+
} catch (const std::exception& e) {
252+
RCLCPP_ERROR(node_->get_logger(),
253+
"Failed to publish to topic '%s': %s",
254+
topic_path.c_str(),
255+
e.what());
256+
throw std::runtime_error(
257+
"Failed to publish to topic '" + topic_path + "': " + e.what()
258+
);
259+
}
260+
}
261+
210262
} // namespace ros2_medkit_gateway

src/ros2_medkit_gateway/src/rest_server.cpp

Lines changed: 190 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ void RESTServer::setup_routes() {
7878
server_->Get(R"(/components/([^/]+)/data$)", [this](const httplib::Request& req, httplib::Response& res) {
7979
handle_component_data(req, res);
8080
});
81+
82+
// Component topic publish (PUT)
83+
server_->Put(R"(/components/([^/]+)/data/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
84+
handle_component_topic_publish(req, res);
85+
});
8186
}
8287

8388
void RESTServer::start() {
@@ -170,13 +175,14 @@ void RESTServer::handle_root(const httplib::Request& req, httplib::Response& res
170175
{"name", "ROS 2 Medkit Gateway"},
171176
{"version", "0.1.0"},
172177
{"endpoints", json::array({
173-
"/health",
174-
"/version-info",
175-
"/areas",
176-
"/components",
177-
"/areas/{area_id}/components",
178-
"/components/{component_id}/data",
179-
"/components/{component_id}/data/{topic_name}"
178+
"GET /health",
179+
"GET /version-info",
180+
"GET /areas",
181+
"GET /components",
182+
"GET /areas/{area_id}/components",
183+
"GET /components/{component_id}/data",
184+
"GET /components/{component_id}/data/{topic_name}",
185+
"PUT /components/{component_id}/data/{topic_name}"
180186
})},
181187
{"capabilities", {
182188
{"discovery", true},
@@ -546,4 +552,181 @@ void RESTServer::handle_component_topic_data(const httplib::Request& req, httpli
546552
}
547553
}
548554

555+
void RESTServer::handle_component_topic_publish(
556+
const httplib::Request& req,
557+
httplib::Response& res
558+
) {
559+
std::string component_id;
560+
std::string topic_name;
561+
try {
562+
// Extract component_id and topic_name from URL path
563+
if (req.matches.size() < 3) {
564+
res.status = StatusCode::BadRequest_400;
565+
res.set_content(
566+
json{{"error", "Invalid request"}}.dump(2),
567+
"application/json"
568+
);
569+
return;
570+
}
571+
572+
component_id = req.matches[1];
573+
topic_name = req.matches[2];
574+
575+
// Validate component_id
576+
auto component_validation = validate_entity_id(component_id);
577+
if (!component_validation) {
578+
res.status = StatusCode::BadRequest_400;
579+
res.set_content(
580+
json{
581+
{"error", "Invalid component ID"},
582+
{"details", component_validation.error()},
583+
{"component_id", component_id}
584+
}.dump(2),
585+
"application/json"
586+
);
587+
return;
588+
}
589+
590+
// Validate topic_name
591+
auto topic_validation = validate_entity_id(topic_name);
592+
if (!topic_validation) {
593+
res.status = StatusCode::BadRequest_400;
594+
res.set_content(
595+
json{
596+
{"error", "Invalid topic name"},
597+
{"details", topic_validation.error()},
598+
{"topic_name", topic_name}
599+
}.dump(2),
600+
"application/json"
601+
);
602+
return;
603+
}
604+
605+
// Parse request body
606+
json body;
607+
try {
608+
body = json::parse(req.body);
609+
} catch (const json::parse_error& e) {
610+
res.status = StatusCode::BadRequest_400;
611+
res.set_content(
612+
json{
613+
{"error", "Invalid JSON in request body"},
614+
{"details", e.what()}
615+
}.dump(2),
616+
"application/json"
617+
);
618+
return;
619+
}
620+
621+
// Validate required fields: type and data
622+
if (!body.contains("type") || !body["type"].is_string()) {
623+
res.status = StatusCode::BadRequest_400;
624+
res.set_content(
625+
json{
626+
{"error", "Missing or invalid 'type' field"},
627+
{"details", "Request body must contain 'type' string field"}
628+
}.dump(2),
629+
"application/json"
630+
);
631+
return;
632+
}
633+
634+
if (!body.contains("data")) {
635+
res.status = StatusCode::BadRequest_400;
636+
res.set_content(
637+
json{
638+
{"error", "Missing 'data' field"},
639+
{"details", "Request body must contain 'data' field"}
640+
}.dump(2),
641+
"application/json"
642+
);
643+
return;
644+
}
645+
646+
std::string msg_type = body["type"].get<std::string>();
647+
json data = body["data"];
648+
649+
// Validate message type format (e.g., std_msgs/msg/Float32)
650+
// Expected format: package/msg/Type (exactly 2 slashes)
651+
size_t first_slash = msg_type.find('/');
652+
size_t last_slash = msg_type.rfind('/');
653+
bool valid_format = (first_slash != std::string::npos) &&
654+
(last_slash != std::string::npos) &&
655+
(first_slash != last_slash) &&
656+
(msg_type.find("/msg/") != std::string::npos);
657+
658+
if (!valid_format) {
659+
res.status = StatusCode::BadRequest_400;
660+
res.set_content(
661+
json{
662+
{"error", "Invalid message type format"},
663+
{"details", "Message type should be in format: package/msg/Type"},
664+
{"type", msg_type}
665+
}.dump(2),
666+
"application/json"
667+
);
668+
return;
669+
}
670+
671+
const auto cache = node_->get_entity_cache();
672+
673+
// Find component in cache
674+
std::string component_namespace;
675+
bool component_found = false;
676+
677+
for (const auto& component : cache.components) {
678+
if (component.id == component_id) {
679+
component_namespace = component.namespace_path;
680+
component_found = true;
681+
break;
682+
}
683+
}
684+
685+
if (!component_found) {
686+
res.status = StatusCode::NotFound_404;
687+
res.set_content(
688+
json{
689+
{"error", "Component not found"},
690+
{"component_id", component_id}
691+
}.dump(2),
692+
"application/json"
693+
);
694+
return;
695+
}
696+
697+
// Construct full topic path
698+
std::string full_topic_path = (component_namespace == "/")
699+
? "/" + topic_name
700+
: component_namespace + "/" + topic_name;
701+
702+
// Publish data using DataAccessManager
703+
auto data_access_mgr = node_->get_data_access_manager();
704+
json result = data_access_mgr->publish_to_topic(full_topic_path, msg_type, data);
705+
706+
// Add component info to result
707+
result["component_id"] = component_id;
708+
result["topic_name"] = topic_name;
709+
710+
res.set_content(result.dump(2), "application/json");
711+
} catch (const std::exception& e) {
712+
res.status = StatusCode::InternalServerError_500;
713+
res.set_content(
714+
json{
715+
{"error", "Failed to publish to topic"},
716+
{"details", e.what()},
717+
{"component_id", component_id},
718+
{"topic_name", topic_name}
719+
}.dump(2),
720+
"application/json"
721+
);
722+
RCLCPP_ERROR(
723+
rclcpp::get_logger("rest_server"),
724+
"Error in handle_component_topic_publish for component '%s', topic '%s': %s",
725+
component_id.c_str(),
726+
topic_name.c_str(),
727+
e.what()
728+
);
729+
}
730+
}
731+
549732
} // namespace ros2_medkit_gateway

0 commit comments

Comments
 (0)