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
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,14 @@ class ResourceChangeNotifier {
size_t max_queue_size_{kDefaultMaxQueueSize};
size_t overflow_drop_count_{0}; ///< Accumulated drops since last overflow log

// Optional error logger - must be declared before worker_thread_ so it is
// initialized before the worker thread starts (C++ initializes members in
// declaration order, not initializer-list order).
ErrorLoggerFn error_logger_;

// Worker thread lifecycle
std::atomic<bool> shutdown_flag_{false};
std::thread worker_thread_;

// Optional error logger (set once before concurrent use)
ErrorLoggerFn error_logger_;
};

} // namespace ros2_medkit_gateway
16 changes: 16 additions & 0 deletions src/ros2_medkit_gateway/src/http/handlers/lock_handlers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,28 @@ bool LockHandlers::check_locking_enabled(httplib::Response & res) {
}

std::optional<std::string> LockHandlers::require_client_id(const httplib::Request & req, httplib::Response & res) {
static constexpr size_t kMaxClientIdLen = 256;

auto client_id = req.get_header_value("X-Client-Id");
if (client_id.empty()) {
HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Missing required X-Client-Id header",
json{{"details", "X-Client-Id header is required for lock operations"}});
return std::nullopt;
}
if (client_id.size() > kMaxClientIdLen) {
HandlerContext::send_error(
res, 400, ERR_INVALID_PARAMETER, "X-Client-Id exceeds maximum length",
json{{"details", "X-Client-Id must be at most 256 characters"}, {"max_length", kMaxClientIdLen}});
return std::nullopt;
}
// Reject control characters (defense-in-depth)
for (char c : client_id) {
if (static_cast<unsigned char>(c) < 0x20) {
HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "X-Client-Id contains invalid characters",
json{{"details", "X-Client-Id must not contain control characters"}});
return std::nullopt;
}
}
return client_id;
}

Expand Down
22 changes: 16 additions & 6 deletions src/ros2_medkit_gateway/src/http/rest_server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ void RESTServer::setup_routes() {
.tag("Data")
.summary(std::string("Write data item for ") + et.singular)
.description(std::string("Publishes a value to a ROS 2 topic on this ") + et.singular + ".")
.request_body("Data value to write", SB::generic_object_schema())
.request_body("Data value to write", SB::ref("DataWriteRequest"))
.response(200, "Written value", SB::generic_object_schema())
.operation_id(std::string("put") + capitalize(et.singular) + "DataItem");

Expand Down Expand Up @@ -524,7 +524,7 @@ void RESTServer::setup_routes() {
.tag("Operations")
.summary(std::string("Update execution for ") + et.singular)
.description("Sends a control command to a running execution.")
.request_body("Execution control", SB::generic_object_schema())
.request_body("Execution control", SB::ref("ExecutionUpdateRequest"))
.response(200, "Updated execution", SB::ref("OperationExecution"))
.operation_id(std::string("update") + capitalize(et.singular) + "Execution");

Expand Down Expand Up @@ -902,14 +902,18 @@ void RESTServer::setup_routes() {

// --- Locking (components and apps only, per SOVD spec) ---
if (et_type_str == "components" || et_type_str == "apps") {
// X-Client-Id schema with length constraints matching handler validation
static const nlohmann::json client_id_schema = {{"type", "string"}, {"minLength", 1}, {"maxLength", 256}};

reg.post(entity_path + "/locks",
[this](auto & req, auto & res) {
lock_handlers_->handle_acquire_lock(req, res);
})
.tag("Locking")
.summary(std::string("Acquire lock on ") + et.singular)
.description(std::string("Acquires an exclusive lock on this ") + et.singular + ".")
.request_body("Lock parameters", SB::ref("Lock"))
.request_body("Lock parameters", SB::ref("AcquireLockRequest"))
.header_param("X-Client-Id", "Unique client identifier for lock ownership", true, client_id_schema)
.response(201, "Lock acquired", SB::ref("Lock"))
.operation_id(std::string("acquire") + capitalize(et.singular) + "Lock");

Expand All @@ -920,6 +924,8 @@ void RESTServer::setup_routes() {
.tag("Locking")
.summary(std::string("List locks on ") + et.singular)
.description(std::string("Lists all active locks on this ") + et.singular + ".")
.header_param("X-Client-Id", "When provided, the 'owned' field indicates whether this client owns the lock",
false, client_id_schema)
.response(200, "Lock list", SB::ref("LockList"))
.operation_id(std::string("list") + capitalize(et.singular) + "Locks");

Expand All @@ -930,6 +936,8 @@ void RESTServer::setup_routes() {
.tag("Locking")
.summary(std::string("Get lock details for ") + et.singular)
.description(std::string("Returns details of a specific lock on this ") + et.singular + ".")
.header_param("X-Client-Id", "When provided, the 'owned' field indicates whether this client owns the lock",
false, client_id_schema)
.response(200, "Lock details", SB::ref("Lock"))
.operation_id(std::string("get") + capitalize(et.singular) + "Lock");

Expand All @@ -940,8 +948,9 @@ void RESTServer::setup_routes() {
.tag("Locking")
.summary(std::string("Extend lock on ") + et.singular)
.description(std::string("Extends the expiration of a lock on this ") + et.singular + ".")
.request_body("Lock extension", SB::ref("Lock"))
.response(200, "Lock extended", SB::ref("Lock"))
.request_body("Lock extension", SB::ref("ExtendLockRequest"))
.header_param("X-Client-Id", "Unique client identifier for lock ownership", true, client_id_schema)
.response(204, "Lock extended")
.operation_id(std::string("extend") + capitalize(et.singular) + "Lock");

reg.del(entity_path + "/locks/{lock_id}",
Expand All @@ -951,6 +960,7 @@ void RESTServer::setup_routes() {
.tag("Locking")
.summary(std::string("Release lock on ") + et.singular)
.description(std::string("Releases a lock on this ") + et.singular + ".")
.header_param("X-Client-Id", "Unique client identifier for lock ownership", true, client_id_schema)
.response(204, "Lock released")
.operation_id(std::string("release") + capitalize(et.singular) + "Lock");
}
Expand Down Expand Up @@ -1026,7 +1036,7 @@ void RESTServer::setup_routes() {
.tag("Scripts")
.summary(std::string("Terminate script execution for ") + et.singular)
.description("Sends a control command (e.g., terminate) to a running script execution.")
.request_body("Execution control", SB::generic_object_schema())
.request_body("Execution control", SB::ref("ScriptControlRequest"))
.response(200, "Execution updated", SB::ref("ScriptExecution"))
.operation_id(std::string("control") + capitalize(et.singular) + "ScriptExecution");

Expand Down
23 changes: 3 additions & 20 deletions src/ros2_medkit_gateway/src/openapi/capability_generator.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -631,33 +631,16 @@ void CapabilityGenerator::add_log_configuration_path(nlohmann::json & paths, con
config_get["summary"] = "Get log configuration for " + entity_path;
config_get["description"] = "Returns the current log level configuration.";
config_get["responses"]["200"]["description"] = "Current log configuration";
config_get["responses"]["200"]["content"]["application/json"]["schema"] = {
{"type", "object"},
{"properties",
{{"severity_filter", {{"type", "string"}, {"description", "Minimum log severity level"}}},
{"max_entries", {{"type", "integer"}, {"description", "Maximum number of log entries to retain"}}},
{"entity_id", {{"type", "string"}}}}},
{"required", {"severity_filter"}}};
config_get["responses"]["200"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("LogConfiguration");
config_path_item["get"] = std::move(config_get);

nlohmann::json config_put;
config_put["tags"] = nlohmann::json::array({"Logs"});
config_put["summary"] = "Update log configuration for " + entity_path;
config_put["description"] = "Update the log level configuration.";
config_put["requestBody"]["required"] = true;
config_put["requestBody"]["content"]["application/json"]["schema"] = {
{"type", "object"},
{"properties",
{{"severity_filter", {{"type", "string"}, {"description", "Minimum log severity level"}}},
{"max_entries", {{"type", "integer"}, {"description", "Maximum number of log entries to retain"}}}}}};
config_put["responses"]["200"]["description"] = "Log configuration updated";
config_put["responses"]["200"]["content"]["application/json"]["schema"] = {
{"type", "object"},
{"properties",
{{"severity_filter", {{"type", "string"}}},
{"max_entries", {{"type", "integer"}}},
{"entity_id", {{"type", "string"}}}}},
{"required", {"severity_filter"}}};
config_put["requestBody"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("LogConfiguration");
config_put["responses"]["204"]["description"] = "Log configuration updated";
config_path_item["put"] = std::move(config_put);

paths[logs_path + "/configuration"] = std::move(config_path_item);
Expand Down
21 changes: 19 additions & 2 deletions src/ros2_medkit_gateway/src/openapi/route_registry.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,18 @@ RouteEntry & RouteEntry::query_param(const std::string & name, const std::string
return *this;
}

RouteEntry & RouteEntry::header_param(const std::string & name, const std::string & desc, bool required,
const nlohmann::json & schema) {
nlohmann::json param;
param["name"] = name;
param["in"] = "header";
param["required"] = required;
param["description"] = desc;
param["schema"] = schema;
parameters_.push_back(std::move(param));
return *this;
}

RouteEntry & RouteEntry::deprecated() {
deprecated_ = true;
return *this;
Expand Down Expand Up @@ -137,6 +149,11 @@ std::string RouteRegistry::to_regex_path(const std::string & openapi_path, const
//
// The "end of path" check ensures only the LAST param on data/config paths gets (.+).

// Root path "/" -> just optional slash anchor (prefix already has the base path)
if (openapi_path == "/") {
return "/?$";
}

std::string result;
size_t i = 0;
while (i < openapi_path.size()) {
Expand All @@ -163,8 +180,8 @@ std::string RouteRegistry::to_regex_path(const std::string & openapi_path, const
}
}

// Append $ anchor to ensure exact match
result += "$";
// Accept optional trailing slash, then anchor to ensure exact match
result += "/?$";
return result;
}

Expand Down
2 changes: 2 additions & 0 deletions src/ros2_medkit_gateway/src/openapi/route_registry.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ class RouteEntry {
const std::string & content_type = "application/json");
RouteEntry & path_param(const std::string & name, const std::string & desc);
RouteEntry & query_param(const std::string & name, const std::string & desc, const std::string & type = "string");
RouteEntry & header_param(const std::string & name, const std::string & desc, bool required = true,
const nlohmann::json & schema = {{"type", "string"}});
RouteEntry & deprecated();
RouteEntry & operation_id(const std::string & id);

Expand Down
Loading
Loading