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
33 changes: 33 additions & 0 deletions docs/api/rest.rst
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,39 @@ Apps
``GET /api/v1/apps/{app_id}``
Get capabilities for a single discovered app.

``GET /api/v1/apps/{app_id}/is-located-on``
Return the parent component that hosts this app.

The response follows the standard ``items`` wrapper and returns:

- ``0`` items when the app has no associated host component
- ``1`` item when the host component is resolved
- ``1`` item with ``x-medkit.missing=true`` when the app references a host
component that cannot currently be resolved

**Example Response:**

.. code-block:: json

{
"items": [
{
"id": "temp-sensor-hw",
"name": "Temperature Sensor",
"href": "/api/v1/components/temp-sensor-hw"
}
],
"x-medkit": {
"total_count": 1
},
"_links": {
"self": "/api/v1/apps/engine-temp-sensor/is-located-on",
"app": "/api/v1/apps/engine-temp-sensor"
}
}

Unknown apps return ``404 App not found`` with ``parameters.app_id``.

Functions
~~~~~~~~~

Expand Down
7 changes: 7 additions & 0 deletions docs/requirements/specs/discovery.rst
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ Discovery

Beacon hints shall follow an ACTIVE, STALE, EXPIRED lifecycle based on configurable TTL and expiry durations.

.. req:: GET /apps/{id}/is-located-on
:id: REQ_INTEROP_105
:status: verified
:tags: Discovery

The endpoint shall return the component that hosts the addressed application.

.. req:: Vendor endpoints expose beacon data
:id: REQ_DISCO_BEACON_04
:status: verified
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ class DiscoveryHandlers {
*/
void handle_app_depends_on(const httplib::Request & req, httplib::Response & res);

/**
* @brief Handle GET /apps/{app-id}/is-located-on - get parent component.
*/
void handle_app_is_located_on(const httplib::Request & req, httplib::Response & res);

// =========================================================================
// Function endpoints
// =========================================================================
Expand Down
77 changes: 77 additions & 0 deletions src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -951,6 +951,83 @@ void DiscoveryHandlers::handle_app_depends_on(const httplib::Request & req, http
}
}

void DiscoveryHandlers::handle_app_is_located_on(const httplib::Request & req, httplib::Response & res) {
try {
if (req.matches.size() < 2) {
HandlerContext::send_error(res, 400, ERR_INVALID_REQUEST, "Invalid request");
return;
}

std::string app_id = req.matches[1];

auto validation_result = ctx_.validate_entity_id(app_id);
if (!validation_result) {
HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Invalid app ID",
{{"details", validation_result.error()}, {"app_id", app_id}});
return;
}

const auto & cache = ctx_.node()->get_thread_safe_cache();
auto discovery = ctx_.node()->get_discovery_manager();
auto app_opt = cache.get_app(app_id);
if (!app_opt) {
app_opt = discovery->get_app(app_id);
}

if (!app_opt) {
HandlerContext::send_error(res, 404, ERR_ENTITY_NOT_FOUND, "App not found", {{"app_id", app_id}});
return;
}

json items = json::array();
const auto & app = *app_opt;

if (!app.component_id.empty()) {
auto component_opt = cache.get_component(app.component_id);
if (!component_opt) {
component_opt = discovery->get_component(app.component_id);
}
if (component_opt) {
json item;
item["id"] = component_opt->id;
item["name"] = component_opt->name.empty() ? component_opt->id : component_opt->name;
item["href"] = "/api/v1/components/" + component_opt->id;
items.push_back(item);
} else {
json item;
item["id"] = app.component_id;
item["name"] = app.component_id;
item["href"] = "/api/v1/components/" + app.component_id;

XMedkit ext;
ext.add("missing", true);
item["x-medkit"] = ext.build();
items.push_back(item);

RCLCPP_WARN(HandlerContext::logger(), "App '%s' references unknown component '%s'", app_id.c_str(),
app.component_id.c_str());
}
}

json response;
response["items"] = items;

XMedkit resp_ext;
resp_ext.add("total_count", items.size());
response["x-medkit"] = resp_ext.build();

json links;
links["self"] = "/api/v1/apps/" + app_id + "/is-located-on";
links["app"] = "/api/v1/apps/" + app_id;
response["_links"] = links;

HandlerContext::send_json(res, response);
} catch (const std::exception & e) {
HandlerContext::send_error(res, 500, ERR_INTERNAL_ERROR, "Internal server error", {{"details", e.what()}});
RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_app_is_located_on: %s", e.what());
}
}

// =============================================================================
// Function handlers
// =============================================================================
Expand Down
7 changes: 7 additions & 0 deletions src/ros2_medkit_gateway/src/http/rest_server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,13 @@ void RESTServer::setup_routes() {
}

if (et_type_str == "apps") {
reg.get(entity_path + "/is-located-on",
[this](auto & req, auto & res) {
discovery_handlers_->handle_app_is_located_on(req, res);
})
.tag("Discovery")
.summary("Get app host component");

reg.get(entity_path + "/depends-on",
[this](auto & req, auto & res) {
discovery_handlers_->handle_app_depends_on(req, res);
Expand Down
88 changes: 86 additions & 2 deletions src/ros2_medkit_gateway/test/test_discovery_handlers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ manifest_version: "1.0"
name: "Mapper"
is_located_on: "lidar_unit"
depends_on: ["planner", "ghost_app"]
- id: "standalone"
name: "Standalone"
description: "Standalone app without a hosting component"
functions:
- id: "navigation"
name: "Navigation"
Expand Down Expand Up @@ -229,7 +232,7 @@ class DiscoveryHandlersFixtureTest : public ::testing::Test {
auto apps = suite_node_->get_discovery_manager()->discover_apps();
auto functions = suite_node_->get_discovery_manager()->discover_functions();

ASSERT_EQ(apps.size(), 2u);
ASSERT_EQ(apps.size(), 3u);
apps[0].is_online = true;
apps[0].bound_fqn = "/vehicle/main_ecu/planner";
apps[1].bound_fqn = "/sensors/lidar_unit/mapper";
Expand Down Expand Up @@ -566,7 +569,7 @@ TEST_F(DiscoveryHandlersFixtureTest, ListAppsReturnsSeededMetadata) {
handlers_->handle_list_apps(req, res);

auto body = parse_json(res);
ASSERT_EQ(body["items"].size(), 2);
ASSERT_EQ(body["items"].size(), 3);
EXPECT_EQ(body["items"][0]["id"], "planner");
EXPECT_EQ(body["items"][0]["x-medkit"]["component_id"], "main_ecu");
EXPECT_EQ(body["items"][0]["x-medkit"]["is_online"], true);
Expand Down Expand Up @@ -601,6 +604,87 @@ TEST_F(DiscoveryHandlersFixtureTest, GetAppReturnsLinksAndCapabilities) {
EXPECT_EQ(body["x-medkit"]["is_online"], false);
}

// @verifies REQ_INTEROP_105
TEST_F(DiscoveryHandlersFixtureTest, AppIsLocatedOnReturnsParentComponent) {
auto req = make_request_with_match("/api/v1/apps/mapper/is-located-on", R"(/api/v1/apps/([^/]+)/is-located-on)");
httplib::Response res;

handlers_->handle_app_is_located_on(req, res);

auto body = parse_json(res);
ASSERT_EQ(body["items"].size(), 1);
EXPECT_EQ(body["items"][0]["id"], "lidar_unit");
EXPECT_EQ(body["items"][0]["name"], "Lidar Unit");
EXPECT_EQ(body["items"][0]["href"], "/api/v1/components/lidar_unit");
EXPECT_EQ(body["x-medkit"]["total_count"], 1);
EXPECT_EQ(body["_links"]["self"], "/api/v1/apps/mapper/is-located-on");
EXPECT_EQ(body["_links"]["app"], "/api/v1/apps/mapper");
}

// @verifies REQ_INTEROP_105
TEST_F(DiscoveryHandlersFixtureTest, AppIsLocatedOnReturnsEmptyWhenAppHasNoComponent) {
auto req = make_request_with_match("/api/v1/apps/standalone/is-located-on", R"(/api/v1/apps/([^/]+)/is-located-on)");
httplib::Response res;

handlers_->handle_app_is_located_on(req, res);

auto body = parse_json(res);
ASSERT_TRUE(body["items"].empty());
EXPECT_EQ(body["x-medkit"]["total_count"], 0);
EXPECT_EQ(body["_links"]["self"], "/api/v1/apps/standalone/is-located-on");
EXPECT_EQ(body["_links"]["app"], "/api/v1/apps/standalone");
}

// @verifies REQ_INTEROP_105
TEST_F(DiscoveryHandlersFixtureTest, AppIsLocatedOnReturnsMissingItemWhenHostComponentUnresolved) {
auto & cache = const_cast<ThreadSafeEntityCache &>(suite_node_->get_thread_safe_cache());
auto apps = cache.get_apps();
ASSERT_FALSE(apps.empty());

bool updated = false;
for (auto & app : apps) {
if (app.id == "mapper") {
app.component_id = "ghost_component";
updated = true;
break;
}
}
ASSERT_TRUE(updated);
cache.update_apps(apps);

auto req = make_request_with_match("/api/v1/apps/mapper/is-located-on", R"(/api/v1/apps/([^/]+)/is-located-on)");
httplib::Response res;

handlers_->handle_app_is_located_on(req, res);

auto body = parse_json(res);
ASSERT_EQ(body["items"].size(), 1);
EXPECT_EQ(body["items"][0]["id"], "ghost_component");
EXPECT_EQ(body["items"][0]["name"], "ghost_component");
EXPECT_EQ(body["items"][0]["href"], "/api/v1/components/ghost_component");
EXPECT_EQ(body["items"][0]["x-medkit"]["missing"], true);
}

// @verifies REQ_INTEROP_105
TEST_F(DiscoveryHandlersValidationTest, AppIsLocatedOnInvalidIdReturns400) {
auto req = make_request_with_match("/api/v1/apps/bad/id/is-located-on", R"(/api/v1/apps/(.+)/is-located-on)");
httplib::Response res;

handlers_.handle_app_is_located_on(req, res);

EXPECT_EQ(res.status, 400);
}

// @verifies REQ_INTEROP_105
TEST_F(DiscoveryHandlersFixtureTest, AppIsLocatedOnUnknownAppReturns404) {
auto req = make_request_with_match("/api/v1/apps/unknown/is-located-on", R"(/api/v1/apps/([^/]+)/is-located-on)");
httplib::Response res;

handlers_->handle_app_is_located_on(req, res);

EXPECT_EQ(res.status, 404);
}

// @verifies REQ_INTEROP_009
TEST_F(DiscoveryHandlersFixtureTest, AppDependsOnReturnsResolvedAndMissingDependencies) {
auto req = make_request_with_match("/api/v1/apps/mapper/depends-on", R"(/api/v1/apps/([^/]+)/depends-on)");
Expand Down
53 changes: 53 additions & 0 deletions src/ros2_medkit_gateway/test/test_gateway_node.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,41 @@ class TestGatewayNode : public ::testing::Test {
cache.update_functions({func});
}

void load_app_location_fixture() {
const std::string manifest = R"(
manifest_version: "1.0"
areas:
- id: "vehicle"
name: "Vehicle"
components:
- id: "main_ecu"
name: "Main ECU"
area: "vehicle"
namespace: "/vehicle"
apps:
- id: "planner"
name: "Planner"
is_located_on: "main_ecu"
- id: "standalone"
name: "Standalone"
)";

ros2_medkit_gateway::DiscoveryConfig config;
config.mode = ros2_medkit_gateway::DiscoveryMode::MANIFEST_ONLY;
config.manifest_path = write_temp_manifest(manifest);
config.manifest_strict_validation = false;

ASSERT_TRUE(node_->get_discovery_manager()->initialize(config));

auto areas = node_->get_discovery_manager()->discover_areas();
auto components = node_->get_discovery_manager()->discover_components();
auto apps = node_->get_discovery_manager()->discover_apps();
auto functions = node_->get_discovery_manager()->discover_functions();

auto & cache = const_cast<ros2_medkit_gateway::ThreadSafeEntityCache &>(node_->get_thread_safe_cache());
cache.update_all(areas, components, apps, functions);
}

std::shared_ptr<ros2_medkit_gateway::GatewayNode> node_;
std::string server_host_;
int server_port_;
Expand Down Expand Up @@ -801,6 +836,24 @@ TEST_F(TestGatewayNode, test_invalid_app_id_bad_request) {
EXPECT_EQ(res->status, 400);
}

// @verifies REQ_INTEROP_105
TEST_F(TestGatewayNode, test_app_is_located_on_endpoint) {
load_app_location_fixture();

auto client = create_client();
auto res = client.Get((std::string(API_BASE_PATH) + "/apps/planner/is-located-on").c_str());

ASSERT_TRUE(res);
EXPECT_EQ(res->status, 200);

auto json_response = nlohmann::json::parse(res->body);
ASSERT_TRUE(json_response.contains("items"));
ASSERT_EQ(json_response["items"].size(), 1);
EXPECT_EQ(json_response["items"][0]["id"], "main_ecu");
EXPECT_EQ(json_response["_links"]["self"], "/api/v1/apps/planner/is-located-on");
EXPECT_EQ(json_response["_links"]["app"], "/api/v1/apps/planner");
}

TEST_F(TestGatewayNode, test_invalid_function_id_bad_request) {
auto client = create_client();

Expand Down
Loading
Loading