Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions postman/collections/ros2-medkit-gateway.postman_collection.json
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,66 @@
"description": "Read brake pressure topic data from the pressure_sensor component."
},
"response": []
},
{
"name": "PUT Publish Brake Command",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"type\": \"std_msgs/msg/Float32\",\n \"data\": {\n \"data\": 50.0\n }\n}"
},
"url": {
"raw": "{{base_url}}/components/actuator/data/command",
"host": [
"{{base_url}}"
],
"path": [
"components",
"actuator",
"data",
"command"
]
},
"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."
},
"response": []
},
{
"name": "PUT Publish Temperature Value",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"type\": \"sensor_msgs/msg/Temperature\",\n \"data\": {\n \"temperature\": 85.5,\n \"variance\": 0.1\n }\n}"
},
"url": {
"raw": "{{base_url}}/components/temp_sensor/data/temperature",
"host": [
"{{base_url}}"
],
"path": [
"components",
"temp_sensor",
"data",
"temperature"
]
},
"description": "Publish temperature data to the temp_sensor component. Example of publishing sensor_msgs/msg/Temperature type."
},
"response": []
}
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@ class DataAccessManager {
double timeout_sec = 3.0
);

/**
* @brief Publish data to a specific topic
* @param topic_path Full topic path (e.g., /chassis/brakes/command)
* @param msg_type ROS 2 message type (e.g., std_msgs/msg/Float32)
* @param data JSON data to publish
* @param timeout_sec Timeout for the publish operation
* @return JSON with publish status
*/
json publish_to_topic(
const std::string& topic_path,
const std::string& msg_type,
const json& data,
double timeout_sec = 5.0
);

private:
/**
* @brief Find all topics under a given namespace
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class RESTServer {
void handle_area_components(const httplib::Request& req, httplib::Response& res);
void handle_component_data(const httplib::Request& req, httplib::Response& res);
void handle_component_topic_data(const httplib::Request& req, httplib::Response& res);
void handle_component_topic_publish(const httplib::Request& req, httplib::Response& res);

// Helper methods
std::expected<void, std::string> validate_entity_id(const std::string& entity_id) const;
Expand Down
52 changes: 52 additions & 0 deletions src/ros2_medkit_gateway/src/data_access_manager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -207,4 +207,56 @@ json DataAccessManager::get_component_data(
return result;
}

json DataAccessManager::publish_to_topic(
const std::string& topic_path,
const std::string& msg_type,
const json& data,
double timeout_sec
) {
try {
// Convert JSON data to string for ros2 topic pub
// Note: data.dump() produces JSON format, but ROS 2 CLI accepts JSON
// as valid YAML (JSON is a subset of YAML 1.2)
std::string yaml_data = data.dump();

// TODO(mfaferek93) #32: Check timeout command availability
// GNU coreutils 'timeout' may not be available on all systems (BSD, containers)
// Should check in constructor or provide fallback mechanism
std::ostringstream cmd;
cmd << "timeout " << static_cast<int>(std::ceil(timeout_sec)) << "s "
<< "ros2 topic pub --once -w 0 "
<< ROS2CLIWrapper::escape_shell_arg(topic_path) << " "
<< ROS2CLIWrapper::escape_shell_arg(msg_type) << " "
<< ROS2CLIWrapper::escape_shell_arg(yaml_data);

RCLCPP_INFO(node_->get_logger(), "Executing: %s", cmd.str().c_str());

std::string output = cli_wrapper_->exec(cmd.str());

RCLCPP_INFO(node_->get_logger(),
"Published to topic '%s' with type '%s'",
topic_path.c_str(),
msg_type.c_str());

json result = {
{"topic", topic_path},
{"type", msg_type},
{"status", "published"},
{"timestamp", std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::system_clock::now().time_since_epoch()
).count()}
};

return result;
} catch (const std::exception& e) {
RCLCPP_ERROR(node_->get_logger(),
"Failed to publish to topic '%s': %s",
topic_path.c_str(),
e.what());
throw std::runtime_error(
"Failed to publish to topic '" + topic_path + "': " + e.what()
);
}
}

} // namespace ros2_medkit_gateway
198 changes: 191 additions & 7 deletions src/ros2_medkit_gateway/src/rest_server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

#include "ros2_medkit_gateway/rest_server.hpp"

#include <algorithm>
#include <iomanip>
#include <sstream>

Expand Down Expand Up @@ -78,6 +79,11 @@ void RESTServer::setup_routes() {
server_->Get(R"(/components/([^/]+)/data$)", [this](const httplib::Request& req, httplib::Response& res) {
handle_component_data(req, res);
});

// Component topic publish (PUT)
server_->Put(R"(/components/([^/]+)/data/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
handle_component_topic_publish(req, res);
});
}

void RESTServer::start() {
Expand Down Expand Up @@ -170,13 +176,14 @@ void RESTServer::handle_root(const httplib::Request& req, httplib::Response& res
{"name", "ROS 2 Medkit Gateway"},
{"version", "0.1.0"},
{"endpoints", json::array({
"/health",
"/version-info",
"/areas",
"/components",
"/areas/{area_id}/components",
"/components/{component_id}/data",
"/components/{component_id}/data/{topic_name}"
"GET /health",
"GET /version-info",
"GET /areas",
"GET /components",
"GET /areas/{area_id}/components",
"GET /components/{component_id}/data",
"GET /components/{component_id}/data/{topic_name}",
"PUT /components/{component_id}/data/{topic_name}"
})},
{"capabilities", {
{"discovery", true},
Expand Down Expand Up @@ -546,4 +553,181 @@ void RESTServer::handle_component_topic_data(const httplib::Request& req, httpli
}
}

void RESTServer::handle_component_topic_publish(
const httplib::Request& req,
httplib::Response& res
) {
std::string component_id;
std::string topic_name;
try {
// Extract component_id and topic_name from URL path
if (req.matches.size() < 3) {
res.status = StatusCode::BadRequest_400;
res.set_content(
json{{"error", "Invalid request"}}.dump(2),
"application/json"
);
return;
}

component_id = req.matches[1];
topic_name = req.matches[2];

// Validate component_id
auto component_validation = validate_entity_id(component_id);
if (!component_validation) {
res.status = StatusCode::BadRequest_400;
res.set_content(
json{
{"error", "Invalid component ID"},
{"details", component_validation.error()},
{"component_id", component_id}
}.dump(2),
"application/json"
);
return;
}

// Validate topic_name
auto topic_validation = validate_entity_id(topic_name);
if (!topic_validation) {
res.status = StatusCode::BadRequest_400;
res.set_content(
json{
{"error", "Invalid topic name"},
{"details", topic_validation.error()},
{"topic_name", topic_name}
}.dump(2),
"application/json"
);
return;
}

// Parse request body
json body;
try {
body = json::parse(req.body);
} catch (const json::parse_error& e) {
res.status = StatusCode::BadRequest_400;
res.set_content(
json{
{"error", "Invalid JSON in request body"},
{"details", e.what()}
}.dump(2),
"application/json"
);
return;
}

// Validate required fields: type and data
if (!body.contains("type") || !body["type"].is_string()) {
res.status = StatusCode::BadRequest_400;
res.set_content(
json{
{"error", "Missing or invalid 'type' field"},
{"details", "Request body must contain 'type' string field"}
}.dump(2),
"application/json"
);
return;
}

if (!body.contains("data")) {
res.status = StatusCode::BadRequest_400;
res.set_content(
json{
{"error", "Missing 'data' field"},
{"details", "Request body must contain 'data' field"}
}.dump(2),
"application/json"
);
return;
}

std::string msg_type = body["type"].get<std::string>();
json data = body["data"];

// Validate message type format (e.g., std_msgs/msg/Float32)
// Expected format: package/msg/Type (exactly 2 slashes)
size_t slash_count = std::count(msg_type.begin(), msg_type.end(), '/');
size_t msg_pos = msg_type.find("/msg/");
bool valid_format = (slash_count == 2) &&
(msg_pos != std::string::npos) &&
(msg_pos > 0) && // package before /msg/
(msg_pos + 5 < msg_type.length()); // Type after /msg/

if (!valid_format) {
res.status = StatusCode::BadRequest_400;
res.set_content(
json{
{"error", "Invalid message type format"},
{"details", "Message type should be in format: package/msg/Type"},
{"type", msg_type}
}.dump(2),
"application/json"
);
return;
}

const auto cache = node_->get_entity_cache();

// Find component in cache
std::string component_namespace;
bool component_found = false;

for (const auto& component : cache.components) {
if (component.id == component_id) {
component_namespace = component.namespace_path;
component_found = true;
break;
}
}

if (!component_found) {
res.status = StatusCode::NotFound_404;
res.set_content(
json{
{"error", "Component not found"},
{"component_id", component_id}
}.dump(2),
"application/json"
);
return;
}

// Construct full topic path
std::string full_topic_path = (component_namespace == "/")
? "/" + topic_name
: component_namespace + "/" + topic_name;

// Publish data using DataAccessManager
auto data_access_mgr = node_->get_data_access_manager();
json result = data_access_mgr->publish_to_topic(full_topic_path, msg_type, data);

// Add component info to result
result["component_id"] = component_id;
result["topic_name"] = topic_name;

res.set_content(result.dump(2), "application/json");
} catch (const std::exception& e) {
res.status = StatusCode::InternalServerError_500;
res.set_content(
json{
{"error", "Failed to publish to topic"},
{"details", e.what()},
{"component_id", component_id},
{"topic_name", topic_name}
}.dump(2),
"application/json"
);
RCLCPP_ERROR(
rclcpp::get_logger("rest_server"),
"Error in handle_component_topic_publish for component '%s', topic '%s': %s",
component_id.c_str(),
topic_name.c_str(),
e.what()
);
}
}

} // namespace ros2_medkit_gateway
Loading