diff --git a/docs/api/rest.rst b/docs/api/rest.rst index d69deea2..95b5ba85 100644 --- a/docs/api/rest.rst +++ b/docs/api/rest.rst @@ -452,14 +452,13 @@ Manage ROS 2 node parameters. { "items": [ { + "id": "publish_rate", "name": "publish_rate", - "value": 10.0, - "type": "double", - "description": "Publishing rate in Hz" + "type": "double" }, { + "id": "sensor_id", "name": "sensor_id", - "value": "sensor_001", "type": "string" } ], @@ -486,7 +485,7 @@ Manage ROS 2 node parameters. curl -X PUT http://localhost:8080/api/v1/components/temp_sensor/configurations/publish_rate \ -H "Content-Type: application/json" \ - -d '{"value": 20.0}' + -d '{"data": 20.0}' ``DELETE /api/v1/components/{id}/configurations/{param_name}`` Reset parameter to default value. diff --git a/docs/tutorials/demos/demo-sensor.rst b/docs/tutorials/demos/demo-sensor.rst index f071fb3c..b2cf6087 100644 --- a/docs/tutorials/demos/demo-sensor.rst +++ b/docs/tutorials/demos/demo-sensor.rst @@ -136,7 +136,7 @@ View and modify sensor parameters: # Change scan rate curl -X PUT http://localhost:8080/api/v1/apps/lidar-sim/configurations/scan_rate \ -H "Content-Type: application/json" \ - -d '{"value": 20.0}' + -d '{"data": 20.0}' **Key Parameters:** @@ -189,15 +189,15 @@ You can also inject faults by setting parameters directly: # Increase noise level curl -X PUT http://localhost:8080/api/v1/apps/lidar-sim/configurations/noise_stddev \ - -H "Content-Type: application/json" -d '{"value": 0.5}' + -H "Content-Type: application/json" -d '{"data": 0.5}' # Enable NaN injection curl -X PUT http://localhost:8080/api/v1/apps/imu-sim/configurations/inject_nan \ - -H "Content-Type: application/json" -d '{"value": true}' + -H "Content-Type: application/json" -d '{"data": true}' # Increase failure probability curl -X PUT http://localhost:8080/api/v1/apps/gps-sim/configurations/failure_probability \ - -H "Content-Type: application/json" -d '{"value": 0.3}' + -H "Content-Type: application/json" -d '{"data": 0.3}' Stopping the Demo ----------------- diff --git a/docs/tutorials/locking.rst b/docs/tutorials/locking.rst index 43b59398..acfb2639 100644 --- a/docs/tutorials/locking.rst +++ b/docs/tutorials/locking.rst @@ -82,7 +82,7 @@ response until the lock is released or expires. curl -X PUT http://localhost:8080/api/v1/components/motor_controller/configurations/max_speed \ -H "Content-Type: application/json" \ -H "X-Client-Id: $CLIENT_ID" \ - -d '{"value": 1500}' + -d '{"data": 1500}' **3. Extend the lock** diff --git a/src/ros2_medkit_gateway/src/http/rest_server.cpp b/src/ros2_medkit_gateway/src/http/rest_server.cpp index ca619796..55e887f9 100644 --- a/src/ros2_medkit_gateway/src/http/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/http/rest_server.cpp @@ -481,7 +481,7 @@ void RESTServer::setup_routes() { .summary(std::string("Get operation details for ") + et.singular) .description(std::string("Returns operation details including request/response schema for this ") + et.singular + ".") - .response(200, "Operation details", SB::ref("OperationItem")) + .response(200, "Operation details", SB::ref("OperationDetail")) .operation_id(std::string("get") + capitalize(et.singular) + "Operation"); // Execution endpoints @@ -546,7 +546,7 @@ void RESTServer::setup_routes() { .tag("Configuration") .summary(std::string("List configurations for ") + et.singular) .description(std::string("Lists all ROS 2 node parameters for this ") + et.singular + ".") - .response(200, "Configuration list", SB::ref("ConfigurationParamList")) + .response(200, "Configuration list", SB::ref("ConfigurationMetaDataList")) .operation_id(std::string("list") + capitalize(et.singular) + "Configurations"); reg.get(entity_path + "/configurations/{config_id}", @@ -556,7 +556,7 @@ void RESTServer::setup_routes() { .tag("Configuration") .summary(std::string("Get specific configuration for ") + et.singular) .description(std::string("Returns a specific ROS 2 node parameter for this ") + et.singular + ".") - .response(200, "Configuration parameter", SB::ref("ConfigurationParam")) + .response(200, "Configuration parameter", SB::ref("ConfigurationReadValue")) .operation_id(std::string("get") + capitalize(et.singular) + "Configuration"); reg.put(entity_path + "/configurations/{config_id}", @@ -566,8 +566,8 @@ void RESTServer::setup_routes() { .tag("Configuration") .summary(std::string("Set configuration for ") + et.singular) .description(std::string("Sets a ROS 2 node parameter value for this ") + et.singular + ".") - .request_body("Configuration value", SB::ref("ConfigurationParam")) - .response(200, "Updated configuration", SB::ref("ConfigurationParam")) + .request_body("Configuration value", SB::ref("ConfigurationWriteValue")) + .response(200, "Updated configuration", SB::ref("ConfigurationReadValue")) .operation_id(std::string("set") + capitalize(et.singular) + "Configuration"); reg.del(entity_path + "/configurations/{config_id}", @@ -588,6 +588,7 @@ void RESTServer::setup_routes() { .summary(std::string("Delete all configurations for ") + et.singular) .description(std::string("Resets all configuration parameters for this ") + et.singular + ".") .response(204, "All configurations deleted") + .response(207, "Partial success - some nodes failed", SB::ref("ConfigurationDeleteMultiStatus")) .operation_id(std::string("deleteAll") + capitalize(et.singular) + "Configurations"); // --- Faults --- @@ -772,7 +773,7 @@ void RESTServer::setup_routes() { .tag("Triggers") .summary(std::string("Create trigger for ") + et.singular) .description(std::string("Creates a new event trigger for this ") + et.singular + ".") - .request_body("Trigger configuration", SB::ref("Trigger")) + .request_body("Trigger configuration", SB::ref("TriggerCreateRequest")) .response(201, "Trigger created", SB::ref("Trigger")) .operation_id(std::string("create") + capitalize(et.singular) + "Trigger"); @@ -815,7 +816,7 @@ void RESTServer::setup_routes() { .tag("Triggers") .summary(std::string("Update trigger for ") + et.singular) .description(std::string("Updates a trigger configuration on this ") + et.singular + ".") - .request_body("Trigger update", SB::ref("Trigger")) + .request_body("Trigger update", SB::ref("TriggerUpdateRequest")) .response(200, "Updated trigger", SB::ref("Trigger")) .operation_id(std::string("update") + capitalize(et.singular) + "Trigger"); @@ -853,7 +854,7 @@ void RESTServer::setup_routes() { .tag("Subscriptions") .summary(std::string("Create cyclic subscription for ") + et.singular) .description(std::string("Creates a new cyclic data subscription for this ") + et.singular + ".") - .request_body("Subscription configuration", SB::ref("CyclicSubscription")) + .request_body("Subscription configuration", SB::ref("CyclicSubscriptionCreateRequest")) .response(201, "Subscription created", SB::ref("CyclicSubscription")) .operation_id(std::string("create") + capitalize(et.singular) + "Subscription"); @@ -964,7 +965,7 @@ void RESTServer::setup_routes() { .summary(std::string("Upload diagnostic script for ") + et.singular) .description(std::string("Uploads a diagnostic script for this ") + et.singular + ".") .request_body("Script file", SB::binary_schema(), "multipart/form-data") - .response(201, "Script uploaded", SB::ref("ScriptMetadata")) + .response(201, "Script uploaded", SB::ref("ScriptUploadResponse")) .operation_id(std::string("upload") + capitalize(et.singular) + "Script"); reg.get(entity_path + "/scripts", @@ -1286,7 +1287,7 @@ void RESTServer::setup_routes() { .summary("Prepare update for execution") .description("Prepares an update for execution (downloads, validates).") .request_body("Prepare parameters", SB::generic_object_schema()) - .response(200, "Update prepared", SB::ref("UpdateStatus")) + .response(202, "Update preparation started") .operation_id("prepareUpdate"); reg.put("/updates/{update_id}/execute", update_handlers_ ? HandlerFn([this](auto & req, auto & res) { @@ -1297,7 +1298,7 @@ void RESTServer::setup_routes() { .summary("Execute update") .description("Starts executing a prepared update.") .request_body("Execute parameters", SB::generic_object_schema()) - .response(200, "Update executing", SB::ref("UpdateStatus")) + .response(202, "Update execution started") .operation_id("executeUpdate"); reg.put("/updates/{update_id}/automated", update_handlers_ ? HandlerFn([this](auto & req, auto & res) { @@ -1308,7 +1309,7 @@ void RESTServer::setup_routes() { .summary("Run automated update") .description("Runs a fully automated update (prepare + execute).") .request_body("Automated parameters", SB::generic_object_schema()) - .response(200, "Automated update started", SB::ref("UpdateStatus")) + .response(202, "Automated update started") .operation_id("automateUpdate"); reg.get("/updates/{update_id}", update_handlers_ ? HandlerFn([this](auto & req, auto & res) { diff --git a/src/ros2_medkit_gateway/src/openapi/path_builder.cpp b/src/ros2_medkit_gateway/src/openapi/path_builder.cpp index 21fb252a..b81f9e93 100644 --- a/src/ros2_medkit_gateway/src/openapi/path_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/path_builder.cpp @@ -96,15 +96,9 @@ nlohmann::json PathBuilder::build_data_collection(const std::string & entity_pat get_op["description"] = "Returns all available data items (topics) for this entity."; get_op["parameters"] = build_query_params_for_collection(); - // Use the standard items wrapper with a generic data item schema - nlohmann::json data_item_schema = {{"type", "object"}, - {"properties", - {{"name", {{"type", "string"}}}, - {"type", {{"type", "string"}}}, - {"direction", {{"type", "string"}}}, - {"uri", {{"type", "string"}}}}}}; get_op["responses"]["200"]["description"] = "Successful response"; - get_op["responses"]["200"]["content"]["application/json"]["schema"] = SchemaBuilder::items_wrapper(data_item_schema); + get_op["responses"]["200"]["content"]["application/json"]["schema"] = + SchemaBuilder::items_wrapper(SchemaBuilder::data_item_schema()); auto errors = error_responses(); for (auto & [code, val] : errors.items()) { @@ -147,8 +141,7 @@ nlohmann::json PathBuilder::build_data_item(const std::string & /*entity_path*/, put_op["requestBody"]["required"] = true; put_op["requestBody"]["content"]["application/json"]["schema"] = schema_builder_.from_ros_msg(topic.type); put_op["responses"]["200"]["description"] = "Value written successfully"; - put_op["responses"]["200"]["content"]["application/json"]["schema"] = { - {"type", "object"}, {"properties", {{"status", {{"type", "string"}}}}}}; + put_op["responses"]["200"]["content"]["application/json"]["schema"] = SchemaBuilder::generic_object_schema(); auto put_errors = error_responses(); for (auto & [code, val] : put_errors.items()) { @@ -181,15 +174,9 @@ nlohmann::json PathBuilder::build_operations_collection(const std::string & enti get_op["description"] = "Returns all available operations (services and actions) for this entity."; get_op["parameters"] = build_query_params_for_collection(); - nlohmann::json op_item_schema = {{"type", "object"}, - {"properties", - {{"name", {{"type", "string"}}}, - {"type", {{"type", "string"}}}, - {"kind", {{"type", "string"}, {"enum", {"service", "action"}}}}, - {"path", {{"type", "string"}}}}}}; - get_op["responses"]["200"]["description"] = "Successful response"; - get_op["responses"]["200"]["content"]["application/json"]["schema"] = SchemaBuilder::items_wrapper(op_item_schema); + get_op["responses"]["200"]["content"]["application/json"]["schema"] = + SchemaBuilder::items_wrapper(SchemaBuilder::operation_item_schema()); auto errors = error_responses(); for (auto & [code, val] : errors.items()) { @@ -280,8 +267,7 @@ nlohmann::json PathBuilder::build_operation_item(const std::string & /*entity_pa post_op["requestBody"]["content"]["application/json"]["schema"] = schema_builder_.from_ros_msg(action.type + "_SendGoal_Request"); post_op["responses"]["202"]["description"] = "Action accepted"; - post_op["responses"]["202"]["content"]["application/json"]["schema"] = { - {"type", "object"}, {"properties", {{"id", {{"type", "string"}}}, {"status", {{"type", "string"}}}}}}; + post_op["responses"]["202"]["content"]["application/json"]["schema"] = SchemaBuilder::operation_execution_schema(); auto post_errors = error_responses(); for (auto & [code, val] : post_errors.items()) { @@ -309,7 +295,7 @@ nlohmann::json PathBuilder::build_configurations_collection(const std::string & get_op["parameters"] = build_query_params_for_collection(); get_op["responses"]["200"]["description"] = "Successful response"; get_op["responses"]["200"]["content"]["application/json"]["schema"] = - SchemaBuilder::items_wrapper(SchemaBuilder::configuration_param_schema()); + SchemaBuilder::items_wrapper(SchemaBuilder::configuration_metadata_schema()); auto errors = error_responses(); for (auto & [code, val] : errors.items()) { @@ -323,8 +309,10 @@ nlohmann::json PathBuilder::build_configurations_collection(const std::string & delete_op["tags"] = nlohmann::json::array({"Configuration"}); delete_op["summary"] = "Delete all configuration parameters"; delete_op["description"] = "Delete all configuration parameters for this entity, resetting them to defaults."; - delete_op["responses"]["200"]["description"] = "All parameters deleted"; - delete_op["responses"]["200"]["content"]["application/json"]["schema"] = SchemaBuilder::configuration_param_schema(); + delete_op["responses"]["204"]["description"] = "All parameters deleted"; + delete_op["responses"]["207"]["description"] = "Partial success - some nodes failed"; + delete_op["responses"]["207"]["content"]["application/json"]["schema"] = + SchemaBuilder::configuration_delete_multi_status_schema(); auto del_errors = error_responses(); for (auto & [code, val] : del_errors.items()) { @@ -426,13 +414,7 @@ nlohmann::json PathBuilder::build_bulk_data_collection(const std::string & entit get_op["parameters"] = build_query_params_for_collection(); get_op["responses"]["200"]["description"] = "Successful response"; get_op["responses"]["200"]["content"]["application/json"]["schema"] = - SchemaBuilder::items_wrapper({{"type", "object"}, - {"properties", - {{"id", {{"type", "string"}}}, - {"name", {{"type", "string"}}}, - {"status", {{"type", "string"}}}, - {"created_at", {{"type", "string"}}}, - {"size_bytes", {{"type", "integer"}}}}}}); + SchemaBuilder::items_wrapper(SchemaBuilder::bulk_data_descriptor_schema()); auto errors = error_responses(); for (auto & [code, val] : errors.items()) { @@ -458,12 +440,7 @@ nlohmann::json PathBuilder::build_cyclic_subscriptions_collection(const std::str get_op["parameters"] = build_query_params_for_collection(); get_op["responses"]["200"]["description"] = "Successful response"; get_op["responses"]["200"]["content"]["application/json"]["schema"] = - SchemaBuilder::items_wrapper({{"type", "object"}, - {"properties", - {{"id", {{"type", "string"}}}, - {"topic", {{"type", "string"}}}, - {"interval_ms", {{"type", "integer"}}}, - {"status", {{"type", "string"}}}}}}); + SchemaBuilder::items_wrapper(SchemaBuilder::cyclic_subscription_schema()); auto errors = error_responses(); for (auto & [code, val] : errors.items()) { @@ -478,18 +455,10 @@ nlohmann::json PathBuilder::build_cyclic_subscriptions_collection(const std::str post_op["summary"] = "Create cyclic subscription"; post_op["description"] = "Create a new cyclic subscription to stream data changes via SSE."; post_op["requestBody"]["required"] = true; - post_op["requestBody"]["content"]["application/json"]["schema"] = { - {"type", "object"}, - {"properties", {{"topic", {{"type", "string"}}}, {"interval_ms", {{"type", "integer"}, {"minimum", 1}}}}}, - {"required", {"topic"}}}; + post_op["requestBody"]["content"]["application/json"]["schema"] = + SchemaBuilder::cyclic_subscription_create_request_schema(); post_op["responses"]["201"]["description"] = "Subscription created"; - post_op["responses"]["201"]["content"]["application/json"]["schema"] = {{"type", "object"}, - {"properties", - {{"id", {{"type", "string"}}}, - {"topic", {{"type", "string"}}}, - {"interval_ms", {{"type", "integer"}}}, - {"status", {{"type", "string"}}}, - {"stream_uri", {{"type", "string"}}}}}}; + post_op["responses"]["201"]["content"]["application/json"]["schema"] = SchemaBuilder::cyclic_subscription_schema(); auto post_errors = error_responses(); for (auto & [code, val] : post_errors.items()) { diff --git a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp index 38b870cc..640faed0 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp @@ -140,13 +140,27 @@ nlohmann::json SchemaBuilder::items_wrapper(const nlohmann::json & item_schema) {"required", {"items"}}}; } -nlohmann::json SchemaBuilder::configuration_param_schema() { +nlohmann::json SchemaBuilder::configuration_metadata_schema() { return {{"type", "object"}, {"properties", - {{"name", {{"type", "string"}}}, - {"value", {{"description", "Configuration value (type varies by parameter)"}}}, - {"type", {{"type", "string"}}}}}, - {"required", {"name", "value"}}}; + {{"id", {{"type", "string"}, {"description", "Configuration parameter ID"}}}, + {"name", {{"type", "string"}, {"description", "Parameter name"}}}, + {"type", {{"type", "string"}, {"description", "Parameter type (e.g. 'parameter')"}}}}}, + {"required", {"id", "name", "type"}}}; +} + +nlohmann::json SchemaBuilder::configuration_read_value_schema() { + return {{"type", "object"}, + {"properties", + {{"id", {{"type", "string"}, {"description", "Configuration parameter ID"}}}, + {"data", {{"description", "Configuration value (type varies by parameter)"}}}}}, + {"required", {"id", "data"}}}; +} + +nlohmann::json SchemaBuilder::configuration_write_value_schema() { + return {{"type", "object"}, + {"properties", {{"data", {{"description", "Configuration value to set (type varies by parameter)"}}}}}, + {"required", {"data"}}}; } nlohmann::json SchemaBuilder::log_entry_schema() { @@ -169,10 +183,20 @@ nlohmann::json SchemaBuilder::log_entry_schema() { } nlohmann::json SchemaBuilder::health_schema() { - nlohmann::json discovery_schema = { - {"type", "object"}, - {"properties", {{"mode", {{"type", "string"}}}, {"strategy", {{"type", "string"}}}}}, - {"description", "Discovery subsystem status"}}; + nlohmann::json linking_schema = {{"type", "object"}, + {"properties", + {{"linked_count", {{"type", "integer"}}}, + {"orphan_count", {{"type", "integer"}}}, + {"binding_conflicts", {{"type", "array"}, {"items", {{"type", "string"}}}}}, + {"warnings", {{"type", "array"}, {"items", {{"type", "string"}}}}}}}}; + + nlohmann::json discovery_schema = {{"type", "object"}, + {"properties", + {{"mode", {{"type", "string"}}}, + {"strategy", {{"type", "string"}}}, + {"pipeline", {{"type", "object"}}}, + {"linking", linking_schema}}}, + {"description", "Discovery subsystem status"}}; return {{"type", "object"}, {"properties", @@ -217,13 +241,24 @@ nlohmann::json SchemaBuilder::root_overview_schema() { {"scripts", {{"type", "boolean"}}}, {"vendor_extensions", {{"type", "boolean"}}}}}}; + nlohmann::json auth_schema = {{"type", "object"}, + {"properties", + {{"enabled", {{"type", "boolean"}}}, + {"algorithm", {{"type", "string"}}}, + {"require_auth_for", {{"type", "string"}}}}}}; + + nlohmann::json tls_schema = { + {"type", "object"}, {"properties", {{"enabled", {{"type", "boolean"}}}, {"min_version", {{"type", "string"}}}}}}; + return {{"type", "object"}, {"properties", {{"name", {{"type", "string"}}}, {"version", {{"type", "string"}}}, {"api_base", {{"type", "string"}}}, {"endpoints", {{"type", "array"}, {"items", {{"type", "string"}}}}}, - {"capabilities", capabilities_schema}}}, + {"capabilities", capabilities_schema}, + {"auth", auth_schema}, + {"tls", tls_schema}}}, {"required", {"name", "version", "api_base", "endpoints", "capabilities"}}}; } @@ -250,12 +285,16 @@ nlohmann::json SchemaBuilder::operation_item_schema() { {"properties", {{"id", {{"type", "string"}}}, {"name", {{"type", "string"}}}, - {"proximity_proof_required", {{"type", "boolean"}}}, - {"asynchronous_execution", {{"type", "boolean"}}}, + {"proximity_proof_required", {{"type", "boolean"}, {"description", "Whether proximity proof is needed"}}}, + {"asynchronous_execution", {{"type", "boolean"}, {"description", "Whether operation runs asynchronously"}}}, {"x-medkit", {{"type", "object"}}}}}, {"required", {"id", "name"}}}; } +nlohmann::json SchemaBuilder::operation_detail_schema() { + return {{"type", "object"}, {"properties", {{"item", ref("OperationItem")}}}, {"required", {"item"}}}; +} + nlohmann::json SchemaBuilder::operation_execution_schema() { return {{"type", "object"}, {"properties", @@ -274,27 +313,28 @@ nlohmann::json SchemaBuilder::trigger_schema() { {"properties", {{"id", {{"type", "string"}}}, {"status", {{"type", "string"}, {"enum", {"active", "terminated"}}}}, - {"observed_resource", {{"type", "string"}}}, - {"event_source", {{"type", "string"}}}, - {"protocol", {{"type", "string"}}}, + {"observed_resource", {{"type", "string"}, {"description", "Resource URI being observed"}}}, + {"event_source", {{"type", "string"}, {"description", "Server-generated event source URI"}}}, + {"protocol", {{"type", "string"}, {"description", "Transport protocol"}}}, {"trigger_condition", condition_schema}, - {"multishot", {{"type", "boolean"}}}, - {"persistent", {{"type", "boolean"}}}, - {"lifetime", {{"type", "number"}}}, - {"path", {{"type", "string"}}}, + {"multishot", {{"type", "boolean"}, {"description", "Whether trigger fires multiple times"}}}, + {"persistent", {{"type", "boolean"}, {"description", "Whether trigger survives server restarts"}}}, + {"lifetime", {{"type", "number"}, {"description", "Trigger lifetime in seconds"}}}, + {"path", {{"type", "string"}, {"description", "Notification delivery path"}}}, {"log_settings", {{"type", "object"}}}}}, {"required", {"id", "status", "observed_resource", "event_source", "protocol", "trigger_condition"}}}; } nlohmann::json SchemaBuilder::cyclic_subscription_schema() { - return {{"type", "object"}, - {"properties", - {{"id", {{"type", "string"}}}, - {"observed_resource", {{"type", "string"}}}, - {"event_source", {{"type", "string"}}}, - {"protocol", {{"type", "string"}}}, - {"interval", {{"type", "string"}, {"enum", {"fast", "normal", "slow"}}}}}}, - {"required", {"id", "observed_resource", "event_source", "protocol", "interval"}}}; + return { + {"type", "object"}, + {"properties", + {{"id", {{"type", "string"}}}, + {"observed_resource", {{"type", "string"}, {"description", "Resource URI being observed"}}}, + {"event_source", {{"type", "string"}, {"description", "Server-generated event source URI"}}}, + {"protocol", {{"type", "string"}, {"description", "Transport protocol"}}}, + {"interval", {{"type", "string"}, {"enum", {"fast", "normal", "slow"}}, {"description", "Polling interval"}}}}}, + {"required", {"id", "observed_resource", "event_source", "protocol", "interval"}}}; } nlohmann::json SchemaBuilder::lock_schema() { @@ -333,21 +373,75 @@ nlohmann::json SchemaBuilder::script_execution_schema() { {"required", {"id", "status"}}}; } -nlohmann::json SchemaBuilder::bulk_data_category_schema() { +nlohmann::json SchemaBuilder::script_upload_response_schema() { return {{"type", "object"}, - {"properties", - {{"id", {{"type", "string"}}}, {"name", {{"type", "string"}}}, {"description", {{"type", "string"}}}}}, + {"properties", {{"id", {{"type", "string"}}}, {"name", {{"type", "string"}}}}}, {"required", {"id", "name"}}}; } +nlohmann::json SchemaBuilder::trigger_update_request_schema() { + return { + {"type", "object"}, + {"properties", {{"lifetime", {{"type", "integer"}, {"minimum", 1}, {"description", "New lifetime in seconds"}}}}}, + {"required", {"lifetime"}}}; +} + +nlohmann::json SchemaBuilder::trigger_create_request_schema() { + nlohmann::json condition_schema = { + {"type", "object"}, {"properties", {{"condition_type", {{"type", "string"}}}}}, {"required", {"condition_type"}}}; + + return {{"type", "object"}, + {"properties", + {{"resource", {{"type", "string"}, {"description", "Resource URI to observe"}}}, + {"trigger_condition", condition_schema}, + {"protocol", {{"type", "string"}, {"description", "Transport protocol (default: sse)"}}}, + {"multishot", {{"type", "boolean"}}}, + {"persistent", {{"type", "boolean"}}}, + {"lifetime", {{"type", "integer"}, {"minimum", 1}}}, + {"path", {{"type", "string"}}}, + {"log_settings", {{"type", "object"}}}}}, + {"required", {"resource", "trigger_condition"}}}; +} + +nlohmann::json SchemaBuilder::configuration_delete_multi_status_schema() { + nlohmann::json result_entry = {{"type", "object"}, + {"properties", + {{"node", {{"type", "string"}}}, + {"app_id", {{"type", "string"}}}, + {"success", {{"type", "boolean"}}}, + {"error", {{"type", "string"}}}}}}; + + return { + {"type", "object"}, + {"properties", {{"entity_id", {{"type", "string"}}}, {"results", {{"type", "array"}, {"items", result_entry}}}}}, + {"required", {"entity_id", "results"}}}; +} + +nlohmann::json SchemaBuilder::cyclic_subscription_create_request_schema() { + return {{"type", "object"}, + {"properties", + {{"resource", {{"type", "string"}, {"description", "Resource URI to subscribe to"}}}, + {"interval", {{"type", "string"}, {"enum", {"fast", "normal", "slow"}}}}, + {"duration", {{"type", "integer"}, {"minimum", 1}, {"description", "Subscription duration in seconds"}}}, + {"protocol", {{"type", "string"}, {"description", "Transport protocol (default: sse)"}}}}}, + {"required", {"resource", "interval", "duration"}}}; +} + +nlohmann::json SchemaBuilder::bulk_data_category_list_schema() { + return items_wrapper({{"type", "string"}}); +} + nlohmann::json SchemaBuilder::bulk_data_descriptor_schema() { return {{"type", "object"}, {"properties", {{"id", {{"type", "string"}}}, {"name", {{"type", "string"}}}, {"size", {{"type", "integer"}}}, - {"content_type", {{"type", "string"}}}, - {"created_at", {{"type", "string"}, {"format", "date-time"}}}}}, + {"mimetype", {{"type", "string"}, {"description", "MIME type of the file"}}}, + {"creation_date", + {{"type", "string"}, {"format", "date-time"}, {"description", "ISO 8601 creation timestamp"}}}, + {"description", {{"type", "string"}, {"description", "Human-readable description"}}}, + {"x-medkit", {{"type", "object"}}}}}, {"required", {"id", "name"}}}; } @@ -414,8 +508,11 @@ const std::map & SchemaBuilder::component_schemas() {"FaultDetail", fault_detail_schema()}, {"FaultList", items_wrapper_ref("FaultListItem")}, // Configuration - {"ConfigurationParam", configuration_param_schema()}, - {"ConfigurationParamList", items_wrapper_ref("ConfigurationParam")}, + {"ConfigurationMetaData", configuration_metadata_schema()}, + {"ConfigurationMetaDataList", items_wrapper_ref("ConfigurationMetaData")}, + {"ConfigurationReadValue", configuration_read_value_schema()}, + {"ConfigurationWriteValue", configuration_write_value_schema()}, + {"ConfigurationDeleteMultiStatus", configuration_delete_multi_status_schema()}, // Logs {"LogEntry", log_entry_schema()}, {"LogEntryList", items_wrapper_ref("LogEntry")}, @@ -430,24 +527,28 @@ const std::map & SchemaBuilder::component_schemas() // Operations {"OperationItem", operation_item_schema()}, {"OperationItemList", items_wrapper_ref("OperationItem")}, + {"OperationDetail", operation_detail_schema()}, {"OperationExecution", operation_execution_schema()}, {"OperationExecutionList", items_wrapper_ref("OperationExecution")}, // Triggers {"Trigger", trigger_schema()}, {"TriggerList", items_wrapper_ref("Trigger")}, + {"TriggerUpdateRequest", trigger_update_request_schema()}, + {"TriggerCreateRequest", trigger_create_request_schema()}, // Subscriptions {"CyclicSubscription", cyclic_subscription_schema()}, {"CyclicSubscriptionList", items_wrapper_ref("CyclicSubscription")}, + {"CyclicSubscriptionCreateRequest", cyclic_subscription_create_request_schema()}, // Locking {"Lock", lock_schema()}, {"LockList", items_wrapper_ref("Lock")}, // Scripts {"ScriptMetadata", script_metadata_schema()}, {"ScriptMetadataList", items_wrapper_ref("ScriptMetadata")}, + {"ScriptUploadResponse", script_upload_response_schema()}, {"ScriptExecution", script_execution_schema()}, // Bulk Data - {"BulkDataCategory", bulk_data_category_schema()}, - {"BulkDataCategoryList", items_wrapper_ref("BulkDataCategory")}, + {"BulkDataCategoryList", bulk_data_category_list_schema()}, {"BulkDataDescriptor", bulk_data_descriptor_schema()}, {"BulkDataDescriptorList", items_wrapper_ref("BulkDataDescriptor")}, // Updates diff --git a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp index e5eea91d..5adc3b50 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp @@ -68,8 +68,14 @@ class SchemaBuilder { /// Wrap an item schema in a SOVD collection response: {"items": [item_schema]} static nlohmann::json items_wrapper(const nlohmann::json & item_schema); - /// Configuration parameter schema - static nlohmann::json configuration_param_schema(); + /// Configuration metadata schema (list endpoint - SOVD ConfigurationMetaData) + static nlohmann::json configuration_metadata_schema(); + + /// Configuration read value schema (GET detail response - SOVD ReadValue) + static nlohmann::json configuration_read_value_schema(); + + /// Configuration write value schema (PUT request body - only data field) + static nlohmann::json configuration_write_value_schema(); /// Log entry schema static nlohmann::json log_entry_schema(); @@ -95,6 +101,9 @@ class SchemaBuilder { /// Operation item in collection list static nlohmann::json operation_item_schema(); + /// Operation detail schema (wraps OperationItem in {item: ...}) + static nlohmann::json operation_detail_schema(); + /// Operation execution status static nlohmann::json operation_execution_schema(); @@ -113,12 +122,27 @@ class SchemaBuilder { /// Script execution status schema static nlohmann::json script_execution_schema(); - /// Bulk-data category schema - static nlohmann::json bulk_data_category_schema(); + /// Bulk-data category list schema (items are bare strings) + static nlohmann::json bulk_data_category_list_schema(); /// Bulk-data descriptor schema static nlohmann::json bulk_data_descriptor_schema(); + /// Script upload response schema (minimal: id + name) + static nlohmann::json script_upload_response_schema(); + + /// Trigger update request schema (only mutable fields) + static nlohmann::json trigger_update_request_schema(); + + /// Trigger create request schema (client-supplied fields only) + static nlohmann::json trigger_create_request_schema(); + + /// Configuration delete-all multi-status response schema (207) + static nlohmann::json configuration_delete_multi_status_schema(); + + /// Cyclic subscription create request schema + static nlohmann::json cyclic_subscription_create_request_schema(); + /// Software update list schema (items: [string]) static nlohmann::json update_list_schema(); diff --git a/src/ros2_medkit_gateway/test/test_path_builder.cpp b/src/ros2_medkit_gateway/test/test_path_builder.cpp index c4e21d93..6bedb2bd 100644 --- a/src/ros2_medkit_gateway/test/test_path_builder.cpp +++ b/src/ros2_medkit_gateway/test/test_path_builder.cpp @@ -306,6 +306,13 @@ TEST_F(PathBuilderTest, ConfigurationsDeleteHasSummary) { EXPECT_EQ(result["delete"]["summary"], "Delete all configuration parameters"); } +TEST_F(PathBuilderTest, ConfigurationsDeleteReturns204And207) { + auto result = path_builder_.build_configurations_collection("apps/sensor"); + ASSERT_TRUE(result["delete"]["responses"].contains("204")); + ASSERT_TRUE(result["delete"]["responses"].contains("207")); + EXPECT_FALSE(result["delete"]["responses"].contains("200")); +} + // ============================================================================= // Faults collection tests // ============================================================================= @@ -393,7 +400,7 @@ TEST_F(PathBuilderTest, CyclicSubscriptionsPostHasRequestBody) { auto result = path_builder_.build_cyclic_subscriptions_collection("apps/sensor"); ASSERT_TRUE(result["post"].contains("requestBody")); auto req_schema = result["post"]["requestBody"]["content"]["application/json"]["schema"]; - EXPECT_TRUE(req_schema["properties"].contains("topic")); + EXPECT_TRUE(req_schema["properties"].contains("resource")); } TEST_F(PathBuilderTest, CyclicSubscriptionsPostReturns201) { diff --git a/src/ros2_medkit_gateway/test/test_schema_builder.cpp b/src/ros2_medkit_gateway/test/test_schema_builder.cpp index 7b4fe896..5f48ffda 100644 --- a/src/ros2_medkit_gateway/test/test_schema_builder.cpp +++ b/src/ros2_medkit_gateway/test/test_schema_builder.cpp @@ -14,6 +14,11 @@ #include +#include +#include +#include +#include + #include "../src/openapi/schema_builder.hpp" using ros2_medkit_gateway::openapi::SchemaBuilder; @@ -147,20 +152,181 @@ TEST(SchemaBuilderStaticTest, ItemsWrapper) { EXPECT_NE(std::find(required.begin(), required.end(), "items"), required.end()); } -TEST(SchemaBuilderStaticTest, ConfigurationParamSchema) { - auto schema = SchemaBuilder::configuration_param_schema(); +// @verifies REQ_INTEROP_002 +TEST(SchemaBuilderStaticTest, ConfigurationMetaDataSchema) { + auto schema = SchemaBuilder::configuration_metadata_schema(); EXPECT_EQ(schema["type"], "object"); ASSERT_TRUE(schema.contains("properties")); + EXPECT_TRUE(schema["properties"].contains("id")); EXPECT_TRUE(schema["properties"].contains("name")); - EXPECT_TRUE(schema["properties"].contains("value")); EXPECT_TRUE(schema["properties"].contains("type")); + EXPECT_FALSE(schema["properties"].contains("value")); + EXPECT_EQ(schema["properties"]["id"]["type"], "string"); EXPECT_EQ(schema["properties"]["name"]["type"], "string"); + EXPECT_EQ(schema["properties"]["type"]["type"], "string"); + + // Required: id, name, type (no value) + ASSERT_TRUE(schema.contains("required")); + auto required = schema["required"].get>(); + EXPECT_NE(std::find(required.begin(), required.end(), "id"), required.end()); + EXPECT_NE(std::find(required.begin(), required.end(), "name"), required.end()); + EXPECT_NE(std::find(required.begin(), required.end(), "type"), required.end()); + EXPECT_EQ(std::find(required.begin(), required.end(), "value"), required.end()); +} + +// @verifies REQ_INTEROP_002 +TEST(SchemaBuilderStaticTest, ConfigurationReadValueSchema) { + auto schema = SchemaBuilder::configuration_read_value_schema(); + EXPECT_EQ(schema["type"], "object"); + ASSERT_TRUE(schema.contains("properties")); + EXPECT_TRUE(schema["properties"].contains("id")); + EXPECT_TRUE(schema["properties"].contains("data")); + EXPECT_FALSE(schema["properties"].contains("name")); + EXPECT_FALSE(schema["properties"].contains("value")); + EXPECT_EQ(schema["properties"]["id"]["type"], "string"); + + // Required: id, data + ASSERT_TRUE(schema.contains("required")); + auto required = schema["required"].get>(); + EXPECT_NE(std::find(required.begin(), required.end(), "id"), required.end()); + EXPECT_NE(std::find(required.begin(), required.end(), "data"), required.end()); +} + +// @verifies REQ_INTEROP_002 +TEST(SchemaBuilderStaticTest, OperationDetailSchema) { + auto schema = SchemaBuilder::operation_detail_schema(); + EXPECT_EQ(schema["type"], "object"); + ASSERT_TRUE(schema.contains("properties")); + EXPECT_TRUE(schema["properties"].contains("item")); + // item references OperationItem via $ref + EXPECT_TRUE(schema["properties"]["item"].contains("$ref")); + + ASSERT_TRUE(schema.contains("required")); + auto required = schema["required"].get>(); + EXPECT_NE(std::find(required.begin(), required.end(), "item"), required.end()); +} + +// @verifies REQ_INTEROP_002 +TEST(SchemaBuilderStaticTest, ConfigurationWriteValueSchema) { + auto schema = SchemaBuilder::configuration_write_value_schema(); + EXPECT_EQ(schema["type"], "object"); + ASSERT_TRUE(schema.contains("properties")); + EXPECT_TRUE(schema["properties"].contains("data")); + EXPECT_FALSE(schema["properties"].contains("id")); + + ASSERT_TRUE(schema.contains("required")); + auto required = schema["required"].get>(); + EXPECT_NE(std::find(required.begin(), required.end(), "data"), required.end()); + EXPECT_EQ(std::find(required.begin(), required.end(), "id"), required.end()); +} + +// @verifies REQ_INTEROP_002 +TEST(SchemaBuilderStaticTest, ScriptUploadResponseSchema) { + auto schema = SchemaBuilder::script_upload_response_schema(); + EXPECT_EQ(schema["type"], "object"); + ASSERT_TRUE(schema.contains("properties")); + EXPECT_TRUE(schema["properties"].contains("id")); + EXPECT_TRUE(schema["properties"].contains("name")); + EXPECT_EQ(schema["properties"]["id"]["type"], "string"); + EXPECT_EQ(schema["properties"]["name"]["type"], "string"); + + ASSERT_TRUE(schema.contains("required")); + auto required = schema["required"].get>(); + EXPECT_NE(std::find(required.begin(), required.end(), "id"), required.end()); + EXPECT_NE(std::find(required.begin(), required.end(), "name"), required.end()); +} + +// @verifies REQ_INTEROP_002 +TEST(SchemaBuilderStaticTest, TriggerUpdateRequestSchema) { + auto schema = SchemaBuilder::trigger_update_request_schema(); + EXPECT_EQ(schema["type"], "object"); + ASSERT_TRUE(schema.contains("properties")); + EXPECT_TRUE(schema["properties"].contains("lifetime")); + EXPECT_EQ(schema["properties"]["lifetime"]["type"], "integer"); + + ASSERT_TRUE(schema.contains("required")); + auto required = schema["required"].get>(); + EXPECT_NE(std::find(required.begin(), required.end(), "lifetime"), required.end()); + + EXPECT_EQ(schema["properties"]["lifetime"]["minimum"], 1); +} + +// @verifies REQ_INTEROP_002 +TEST(SchemaBuilderStaticTest, BulkDataCategoryListSchema) { + auto schema = SchemaBuilder::bulk_data_category_list_schema(); + EXPECT_EQ(schema["type"], "object"); + ASSERT_TRUE(schema.contains("properties")); + EXPECT_TRUE(schema["properties"].contains("items")); + EXPECT_EQ(schema["properties"]["items"]["type"], "array"); + EXPECT_EQ(schema["properties"]["items"]["items"]["type"], "string"); +} + +// @verifies REQ_INTEROP_002 +TEST(SchemaBuilderStaticTest, BulkDataDescriptorSchema) { + auto schema = SchemaBuilder::bulk_data_descriptor_schema(); + EXPECT_EQ(schema["type"], "object"); + ASSERT_TRUE(schema.contains("properties")); + EXPECT_TRUE(schema["properties"].contains("id")); + EXPECT_TRUE(schema["properties"].contains("name")); + EXPECT_TRUE(schema["properties"].contains("mimetype")); + EXPECT_TRUE(schema["properties"].contains("creation_date")); + EXPECT_TRUE(schema["properties"].contains("description")); + EXPECT_TRUE(schema["properties"].contains("x-medkit")); + EXPECT_FALSE(schema["properties"].contains("content_type")); + EXPECT_FALSE(schema["properties"].contains("created_at")); - // Required ASSERT_TRUE(schema.contains("required")); auto required = schema["required"].get>(); + EXPECT_NE(std::find(required.begin(), required.end(), "id"), required.end()); EXPECT_NE(std::find(required.begin(), required.end(), "name"), required.end()); - EXPECT_NE(std::find(required.begin(), required.end(), "value"), required.end()); +} + +// @verifies REQ_INTEROP_002 +TEST(SchemaBuilderStaticTest, CyclicSubscriptionCreateRequestSchema) { + auto schema = SchemaBuilder::cyclic_subscription_create_request_schema(); + EXPECT_EQ(schema["type"], "object"); + ASSERT_TRUE(schema.contains("properties")); + EXPECT_TRUE(schema["properties"].contains("resource")); + EXPECT_TRUE(schema["properties"].contains("interval")); + EXPECT_TRUE(schema["properties"].contains("duration")); + EXPECT_TRUE(schema["properties"].contains("protocol")); + EXPECT_FALSE(schema["properties"].contains("id")); + EXPECT_FALSE(schema["properties"].contains("event_source")); + + ASSERT_TRUE(schema.contains("required")); + auto required = schema["required"].get>(); + EXPECT_NE(std::find(required.begin(), required.end(), "resource"), required.end()); + EXPECT_NE(std::find(required.begin(), required.end(), "interval"), required.end()); + EXPECT_NE(std::find(required.begin(), required.end(), "duration"), required.end()); + EXPECT_EQ(std::find(required.begin(), required.end(), "id"), required.end()); + + // Verify interval enum constraint + EXPECT_EQ(schema["properties"]["interval"]["type"], "string"); + ASSERT_TRUE(schema["properties"]["interval"].contains("enum")); + auto enum_vals = schema["properties"]["interval"]["enum"].get>(); + EXPECT_EQ(enum_vals.size(), 3u); + + // Verify duration type and minimum + EXPECT_EQ(schema["properties"]["duration"]["type"], "integer"); + EXPECT_EQ(schema["properties"]["duration"]["minimum"], 1); +} + +// @verifies REQ_INTEROP_002 +TEST(SchemaBuilderStaticTest, TriggerCreateRequestSchema) { + auto schema = SchemaBuilder::trigger_create_request_schema(); + EXPECT_EQ(schema["type"], "object"); + ASSERT_TRUE(schema.contains("properties")); + EXPECT_TRUE(schema["properties"].contains("resource")); + EXPECT_TRUE(schema["properties"].contains("trigger_condition")); + EXPECT_FALSE(schema["properties"].contains("id")); + EXPECT_FALSE(schema["properties"].contains("status")); + EXPECT_FALSE(schema["properties"].contains("event_source")); + + ASSERT_TRUE(schema.contains("required")); + auto required = schema["required"].get>(); + EXPECT_NE(std::find(required.begin(), required.end(), "resource"), required.end()); + EXPECT_NE(std::find(required.begin(), required.end(), "trigger_condition"), required.end()); + EXPECT_EQ(std::find(required.begin(), required.end(), "id"), required.end()); } TEST(SchemaBuilderStaticTest, LogEntrySchema) { @@ -289,3 +455,98 @@ TEST(SchemaBuilderRuntimeTest, FromRosSrvResponseUnknown) { EXPECT_TRUE(schema.contains("x-medkit-schema-unavailable")); EXPECT_TRUE(schema["x-medkit-schema-unavailable"].get()); } + +// ============================================================================= +// Schema registry consistency tests +// Validates that all $ref references resolve and schemas are internally consistent. +// This catches mismatches between schema definitions and route registrations. +// ============================================================================= + +namespace { + +// Recursively collect all $ref targets from a JSON schema +void collect_refs(const nlohmann::json & schema, std::set & refs) { + if (schema.is_object()) { + if (schema.contains("$ref")) { + auto ref_str = schema["$ref"].get(); + // Extract schema name from "#/components/schemas/SchemaName" + std::regex ref_regex(R"(^#/components/schemas/(.+)$)"); + std::smatch match; + if (std::regex_match(ref_str, match, ref_regex)) { + refs.insert(match[1].str()); + } + } + for (auto & [key, val] : schema.items()) { + collect_refs(val, refs); + } + } else if (schema.is_array()) { + for (const auto & item : schema) { + collect_refs(item, refs); + } + } +} + +} // namespace + +// @verifies REQ_INTEROP_002 +TEST(SchemaConsistencyTest, AllRefsResolveToRegisteredSchemas) { + const auto & schemas = SchemaBuilder::component_schemas(); + + // Collect all $ref targets across all schemas + std::set all_refs; + for (const auto & [name, schema] : schemas) { + collect_refs(schema, all_refs); + } + + // Verify every $ref target exists in component_schemas + for (const auto & ref_name : all_refs) { + EXPECT_TRUE(schemas.count(ref_name) > 0) + << "Schema $ref '#/components/schemas/" << ref_name << "' does not resolve to any registered schema"; + } +} + +// @verifies REQ_INTEROP_002 +TEST(SchemaConsistencyTest, ListSchemasReferenceExistingItemSchemas) { + const auto & schemas = SchemaBuilder::component_schemas(); + + // Every schema named *List should reference an existing item schema via $ref + for (const auto & [name, schema] : schemas) { + if (name.size() > 4 && name.substr(name.size() - 4) == "List") { + SCOPED_TRACE("Checking list schema: " + name); + ASSERT_TRUE(schema.contains("properties")) << name << " should have properties"; + ASSERT_TRUE(schema["properties"].contains("items")) << name << " should have 'items' property"; + + auto items_prop = schema["properties"]["items"]; + ASSERT_TRUE(items_prop.contains("items")) << name << " items property should define array items"; + + auto item_schema = items_prop["items"]; + if (item_schema.contains("$ref")) { + auto ref_str = item_schema["$ref"].get(); + std::regex ref_regex(R"(^#/components/schemas/(.+)$)"); + std::smatch match; + ASSERT_TRUE(std::regex_match(ref_str, match, ref_regex)) << name << " has malformed $ref: " << ref_str; + EXPECT_TRUE(schemas.count(match[1].str()) > 0) << name << " references non-existent schema: " << match[1].str(); + } else { + // Inline items (e.g., BulkDataCategoryList with string items) must have a type + EXPECT_TRUE(item_schema.contains("type")) << name << " has inline items without a type field"; + } + } + } +} + +// @verifies REQ_INTEROP_002 +TEST(SchemaConsistencyTest, RequiredFieldsExistInProperties) { + const auto & schemas = SchemaBuilder::component_schemas(); + + for (const auto & [name, schema] : schemas) { + if (!schema.contains("required") || !schema.contains("properties")) { + continue; + } + SCOPED_TRACE("Checking schema: " + name); + auto required = schema["required"].get>(); + for (const auto & field : required) { + EXPECT_TRUE(schema["properties"].contains(field)) + << "Schema '" << name << "' has required field '" << field << "' not present in properties"; + } + } +} diff --git a/src/ros2_medkit_integration_tests/test/features/test_health.test.py b/src/ros2_medkit_integration_tests/test/features/test_health.test.py index 008c81a6..913e141c 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_health.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_health.test.py @@ -184,9 +184,12 @@ def test_docs_spec_completeness(self): for code, resp in op.get('responses', {}).items(): if not code.startswith('2'): continue - # 204 No Content doesn't need a schema + # 204 No Content never has a body if code == '204': has_schema = True + # 202 Accepted without body is OK (async operations) + if code == '202' and 'content' not in resp: + has_schema = True if '$ref' in resp: has_schema = True elif 'content' in resp: