From b6dfc81d3bdb52db423e4ae3bbcf7de22a7066e8 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 17 May 2026 21:33:36 +0200 Subject: [PATCH 01/51] feat(gateway): add DTO contract core (Field, dto_fields, traits) --- src/ros2_medkit_gateway/CMakeLists.txt | 4 + .../ros2_medkit_gateway/dto/contract.hpp | 133 ++++++++++++++++++ .../test/test_dto_contract.cpp | 66 +++++++++ 3 files changed, 203 insertions(+) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/contract.hpp create mode 100644 src/ros2_medkit_gateway/test/test_dto_contract.cpp diff --git a/src/ros2_medkit_gateway/CMakeLists.txt b/src/ros2_medkit_gateway/CMakeLists.txt index 171a5c09..dff2960a 100644 --- a/src/ros2_medkit_gateway/CMakeLists.txt +++ b/src/ros2_medkit_gateway/CMakeLists.txt @@ -635,6 +635,10 @@ if(BUILD_TESTING) ament_add_gtest(test_x_medkit test/test_x_medkit.cpp) target_link_libraries(test_x_medkit gateway_ros2) + # DTO contract core (pure C++17, no ROS node) + ament_add_gtest(test_dto_contract test/test_dto_contract.cpp) + target_link_libraries(test_dto_contract gateway_core) + # Add rate limiter tests ament_add_gtest(test_rate_limiter test/test_rate_limiter.cpp) target_link_libraries(test_rate_limiter gateway_ros2) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/contract.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/contract.hpp new file mode 100644 index 00000000..6c6a63cb --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/contract.hpp @@ -0,0 +1,133 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace ros2_medkit_gateway { +namespace dto { + +/// Whether a DTO field is mandatory in the OpenAPI schema / request body. +enum class Presence { kRequired, kOptional }; + +// --- type traits ----------------------------------------------------------- + +template +struct is_optional : std::false_type {}; +template +struct is_optional> : std::true_type {}; +template +inline constexpr bool is_optional_v = is_optional::value; + +template +struct is_vector : std::false_type {}; +template +struct is_vector> : std::true_type {}; +template +inline constexpr bool is_vector_v = is_vector::value; + +template +struct is_variant : std::false_type {}; +template +struct is_variant> : std::true_type {}; +template +inline constexpr bool is_variant_v = is_variant::value; + +/// Optional members default to kOptional, everything else to kRequired. +template +constexpr Presence default_presence() { + return is_optional_v ? Presence::kOptional : Presence::kRequired; +} + +// --- Field ----------------------------------------------------------------- + +/// Binds a JSON key to a struct member plus OpenAPI metadata. +/// NEVER brace-initialize Field directly: aggregate CTAD is C++20-only. +/// Always construct via the field() / field_enum() factories below. +template +struct Field { + std::string_view key; + Member Class::*ptr; + Presence presence; + std::string_view description; + const std::string_view * enum_values; // (ptr,count) into an inline constexpr array + std::size_t enum_count; +}; + +template +constexpr Field field(std::string_view key, M C::*ptr, std::string_view desc = std::string_view{}) { + return Field{key, ptr, default_presence(), desc, nullptr, 0}; +} + +template +constexpr Field field(std::string_view key, M C::*ptr, Presence p, std::string_view desc = std::string_view{}) { + return Field{key, ptr, p, desc, nullptr, 0}; +} + +/// Enum-constrained field: `values` must be an inline constexpr std::string_view array. +template +constexpr Field field_enum(std::string_view key, M C::*ptr, const std::string_view (&values)[N], + std::string_view desc = std::string_view{}) { + return Field{key, ptr, default_presence(), desc, values, N}; +} + +// --- dto_fields / dto_name / is_dto ----------------------------------------- + +namespace detail { +/// Per-type sentinel: the value of the primary (unspecialized) dto_fields. +template +struct not_a_dto {}; +} // namespace detail + +/// Primary template: a sentinel. Specialize per DTO with std::make_tuple(field(...), ...). +/// IMPORTANT: every dto_fields / dto_name specialization MUST be co-located +/// in the header that declares X. A TU that instantiates a visitor before seeing +/// the specialization silently binds this sentinel (latent ODR-adjacent bug). +/// IMPORTANT: a DTO must not transitively contain itself BY VALUE (infinite +/// template recursion) - use std::optional / std::vector / nlohmann::json for any +/// recursive shape. +template +inline constexpr auto dto_fields = detail::not_a_dto{}; + +/// True iff dto_fields was specialized (detected by sentinel type identity - +/// the primary is well-formed for any T, so std::void_t cannot probe it). +template +inline constexpr bool is_dto_v = !std::is_same_v)>, detail::not_a_dto>; + +/// Schema name in components/schemas. Specialize per DTO with a string literal. +template +inline constexpr std::string_view dto_name = std::string_view{}; + +// --- field fold ------------------------------------------------------------- + +/// Invoke `v` once per field of T, in declaration order. +template +void for_each_field(V && v) { + static_assert(is_dto_v, "for_each_field requires a DTO; specialize dto_fields"); + std::apply( + [&](auto &&... f) { + (v(f), ...); + }, + dto_fields); +} + +} // namespace dto +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/test/test_dto_contract.cpp b/src/ros2_medkit_gateway/test/test_dto_contract.cpp new file mode 100644 index 00000000..1974def6 --- /dev/null +++ b/src/ros2_medkit_gateway/test/test_dto_contract.cpp @@ -0,0 +1,66 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include + +#include +#include +#include + +#include "ros2_medkit_gateway/dto/contract.hpp" + +namespace dto = ros2_medkit_gateway::dto; + +namespace { +struct Sample { + std::string id; + std::optional note; +}; +inline constexpr std::string_view kSampleColors[] = {"red", "green"}; +} // namespace + +template <> +inline constexpr auto dto::dto_fields = + std::make_tuple(dto::field("id", &Sample::id), dto::field("note", &Sample::note)); + +template <> +inline constexpr std::string_view dto::dto_name = "Sample"; + +TEST(DtoContract, FieldFactoryDerivesPresenceFromOptional) { + constexpr auto fields = dto::dto_fields; + EXPECT_EQ(std::get<0>(fields).presence, dto::Presence::kRequired); + EXPECT_EQ(std::get<1>(fields).presence, dto::Presence::kOptional); + EXPECT_EQ(std::get<0>(fields).key, "id"); +} + +TEST(DtoContract, IsDtoDetectsSpecialization) { + EXPECT_TRUE(dto::is_dto_v); + EXPECT_FALSE(dto::is_dto_v); + EXPECT_FALSE(dto::is_dto_v); +} + +TEST(DtoContract, ForEachFieldVisitsEveryField) { + int count = 0; + dto::for_each_field([&](const auto & /*f*/) { + ++count; + }); + EXPECT_EQ(count, 2); +} + +TEST(DtoContract, FieldEnumPopulatesVocabularyAndDtoName) { + constexpr auto f = dto::field_enum("color", &Sample::id, kSampleColors); + EXPECT_EQ(f.enum_count, 2u); + EXPECT_EQ(f.enum_values[0], "red"); + EXPECT_EQ(dto::dto_name, "Sample"); +} From aadffc5af6d826789592c12d965565c457c1e605 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 17 May 2026 21:41:47 +0200 Subject: [PATCH 02/51] feat(gateway): add SchemaWriter visitor for DTO->OpenAPI schema --- .../ros2_medkit_gateway/dto/schema_writer.hpp | 99 +++++++++++++++++++ .../test/test_dto_contract.cpp | 16 +++ 2 files changed, 115 insertions(+) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/schema_writer.hpp diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/schema_writer.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/schema_writer.hpp new file mode 100644 index 00000000..97bc40ab --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/schema_writer.hpp @@ -0,0 +1,99 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "ros2_medkit_gateway/dto/contract.hpp" + +namespace ros2_medkit_gateway { +namespace dto { + +template +nlohmann::json schema_of(); // forward declaration + +namespace detail { +template +nlohmann::json variant_schema(std::index_sequence) { + return nlohmann::json{{"oneOf", nlohmann::json::array({schema_of>()...})}}; +} +} // namespace detail + +/// Map a C++ value type to its OpenAPI JSON Schema fragment. +template +nlohmann::json schema_of() { + if constexpr (is_dto_v) { + return nlohmann::json{{"$ref", "#/components/schemas/" + std::string(dto_name)}}; + } else if constexpr (is_optional_v) { + return schema_of(); + } else if constexpr (is_vector_v) { + return nlohmann::json{{"type", "array"}, {"items", schema_of()}}; + } else if constexpr (is_variant_v) { + return detail::variant_schema(std::make_index_sequence>{}); + } else if constexpr (std::is_same_v) { + return nlohmann::json::object(); // {} = any JSON + } else if constexpr (std::is_same_v) { + return nlohmann::json{{"type", "string"}}; + } else if constexpr (std::is_same_v) { + return nlohmann::json{{"type", "boolean"}}; + } else if constexpr (std::is_integral_v) { + return nlohmann::json{{"type", "integer"}}; + } else if constexpr (std::is_floating_point_v) { + return nlohmann::json{{"type", "number"}}; + } else { + static_assert(sizeof(U) == 0, "schema_of: unsupported field type"); + return {}; + } +} + +/// Generates the components/schemas object entry for a DTO type T. +template +struct SchemaWriter { + static nlohmann::json schema() { + nlohmann::json props = nlohmann::json::object(); + nlohmann::json required = nlohmann::json::array(); + for_each_field([&](const auto & f) { + using MemberT = std::decay_t().*(f.ptr))>; + nlohmann::json prop = schema_of(); + if (!f.description.empty()) { + prop["description"] = std::string(f.description); + } + if (f.enum_count > 0) { + nlohmann::json values = nlohmann::json::array(); + for (std::size_t i = 0; i < f.enum_count; ++i) { + values.push_back(std::string(f.enum_values[i])); + } + prop["enum"] = values; + } + props[std::string(f.key)] = prop; + if (f.presence == Presence::kRequired) { + required.push_back(std::string(f.key)); + } + }); + nlohmann::json schema = {{"type", "object"}, {"properties", props}}; + if (!required.empty()) { + schema["required"] = required; + } + return schema; + } +}; + +} // namespace dto +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/test/test_dto_contract.cpp b/src/ros2_medkit_gateway/test/test_dto_contract.cpp index 1974def6..1be2337a 100644 --- a/src/ros2_medkit_gateway/test/test_dto_contract.cpp +++ b/src/ros2_medkit_gateway/test/test_dto_contract.cpp @@ -19,6 +19,7 @@ #include #include "ros2_medkit_gateway/dto/contract.hpp" +#include "ros2_medkit_gateway/dto/schema_writer.hpp" namespace dto = ros2_medkit_gateway::dto; @@ -64,3 +65,18 @@ TEST(DtoContract, FieldEnumPopulatesVocabularyAndDtoName) { EXPECT_EQ(f.enum_values[0], "red"); EXPECT_EQ(dto::dto_name, "Sample"); } + +TEST(SchemaWriter, BuildsObjectSchemaWithProperties) { + const auto schema = dto::SchemaWriter::schema(); + EXPECT_EQ(schema.at("type"), "object"); + EXPECT_TRUE(schema.at("properties").contains("id")); + EXPECT_TRUE(schema.at("properties").contains("note")); + EXPECT_EQ(schema.at("properties").at("id").at("type"), "string"); +} + +TEST(SchemaWriter, RequiredListExcludesOptionalFields) { + const auto schema = dto::SchemaWriter::schema(); + const auto & req = schema.at("required"); + EXPECT_NE(std::find(req.begin(), req.end(), "id"), req.end()); + EXPECT_EQ(std::find(req.begin(), req.end(), "note"), req.end()); +} From b6ba51e01550f61298add556ba5f1b33e752ad0e Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 17 May 2026 21:45:53 +0200 Subject: [PATCH 03/51] feat(gateway): add JsonWriter visitor for DTO->wire JSON --- .../ros2_medkit_gateway/dto/json_writer.hpp | 74 +++++++++++++++++++ .../test/test_dto_contract.cpp | 14 ++++ 2 files changed, 88 insertions(+) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/json_writer.hpp diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/json_writer.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/json_writer.hpp new file mode 100644 index 00000000..20522f93 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/json_writer.hpp @@ -0,0 +1,74 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include +#include + +#include "ros2_medkit_gateway/dto/contract.hpp" + +namespace ros2_medkit_gateway { +namespace dto { + +template +struct JsonWriter; // forward declaration for nested-DTO recursion + +/// Encode a single field value to JSON. +template +nlohmann::json encode_value(const U & v) { + if constexpr (is_dto_v) { + return JsonWriter::write(v); + } else if constexpr (is_vector_v) { + nlohmann::json arr = nlohmann::json::array(); + for (const auto & e : v) { + arr.push_back(encode_value(e)); + } + return arr; + } else if constexpr (is_variant_v) { + return std::visit( + [](const auto & alt) { + return encode_value(alt); + }, + v); + } else { + // string / bool / integral / floating / nlohmann::json passthrough + return nlohmann::json(v); + } +} + +/// Serializes a DTO instance to its wire JSON object. +template +struct JsonWriter { + static nlohmann::json write(const T & obj) { + nlohmann::json out = nlohmann::json::object(); + for_each_field([&](const auto & f) { + const auto & val = obj.*(f.ptr); + using MemberT = std::decay_t; + if constexpr (is_optional_v) { + if (val.has_value()) { + out[std::string(f.key)] = encode_value(*val); + } + } else { + out[std::string(f.key)] = encode_value(val); + } + }); + return out; + } +}; + +} // namespace dto +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/test/test_dto_contract.cpp b/src/ros2_medkit_gateway/test/test_dto_contract.cpp index 1be2337a..c1265741 100644 --- a/src/ros2_medkit_gateway/test/test_dto_contract.cpp +++ b/src/ros2_medkit_gateway/test/test_dto_contract.cpp @@ -19,6 +19,7 @@ #include #include "ros2_medkit_gateway/dto/contract.hpp" +#include "ros2_medkit_gateway/dto/json_writer.hpp" #include "ros2_medkit_gateway/dto/schema_writer.hpp" namespace dto = ros2_medkit_gateway::dto; @@ -80,3 +81,16 @@ TEST(SchemaWriter, RequiredListExcludesOptionalFields) { EXPECT_NE(std::find(req.begin(), req.end(), "id"), req.end()); EXPECT_EQ(std::find(req.begin(), req.end(), "note"), req.end()); } + +TEST(JsonWriter, WritesRequiredFieldsAndSkipsEmptyOptional) { + Sample s{"area_1", std::nullopt}; + const auto j = dto::JsonWriter::write(s); + EXPECT_EQ(j.at("id"), "area_1"); + EXPECT_FALSE(j.contains("note")); +} + +TEST(JsonWriter, WritesPresentOptional) { + Sample s{"area_1", std::string{"hello"}}; + const auto j = dto::JsonWriter::write(s); + EXPECT_EQ(j.at("note"), "hello"); +} From 8a8c6bf23d1ee0643bd84fd8a431a65a8d30afaa Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 17 May 2026 21:49:51 +0200 Subject: [PATCH 04/51] feat(gateway): add JsonReader visitor with lenient validation --- .../ros2_medkit_gateway/dto/json_reader.hpp | 149 ++++++++++++++++++ .../test/test_dto_contract.cpp | 32 ++++ 2 files changed, 181 insertions(+) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/json_reader.hpp diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/json_reader.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/json_reader.hpp new file mode 100644 index 00000000..50341715 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/json_reader.hpp @@ -0,0 +1,149 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include +#include +#include + +#include "ros2_medkit_gateway/dto/contract.hpp" + +namespace ros2_medkit_gateway { +namespace dto { + +/// A single request-body validation failure. +struct FieldError { + std::string field; + std::string message; +}; + +template +struct JsonReader; // forward declaration + +/// Decode one JSON value into `out`. Appends a FieldError on failure. +template +void decode_value(const nlohmann::json & j, U & out, const std::string & path, std::vector & errs) { + if constexpr (is_dto_v) { + auto nested = JsonReader::read(j); + if (nested.has_value()) { + out = std::move(nested.value()); + } else { + for (auto & e : nested.error()) { + errs.push_back({path + "." + e.field, e.message}); + } + } + } else if constexpr (is_vector_v) { + if (!j.is_array()) { + errs.push_back({path, "expected an array"}); + return; + } + out.clear(); + std::size_t idx = 0; + for (const auto & elem : j) { + typename U::value_type item{}; + decode_value(elem, item, path + "[" + std::to_string(idx++) + "]", errs); + out.push_back(std::move(item)); + } + } else if constexpr (std::is_same_v) { + out = j; + } else if constexpr (std::is_same_v) { + if (j.is_string()) { + out = j.get(); + } else { + errs.push_back({path, "expected a string"}); + } + } else if constexpr (std::is_same_v) { + if (j.is_boolean()) { + out = j.get(); + } else { + errs.push_back({path, "expected a boolean"}); + } + } else if constexpr (std::is_integral_v) { + if (j.is_number_integer() || j.is_number_unsigned()) { + out = j.get(); + } else { + errs.push_back({path, "expected an integer"}); + } + } else if constexpr (std::is_floating_point_v) { + if (j.is_number()) { + out = j.get(); + } else { + errs.push_back({path, "expected a number"}); + } + } else { + static_assert(sizeof(U) == 0, "decode_value: unsupported field type"); + } +} + +/// Check an already-decoded string member against the field's enum vocabulary. +template +void check_enum(const FieldT & f, const std::string & value, const std::string & key, std::vector & errs) { + if (f.enum_count == 0) { + return; + } + for (std::size_t i = 0; i < f.enum_count; ++i) { + if (value == f.enum_values[i]) { + return; + } + } + errs.push_back({key, "value not in allowed set"}); +} + +/// Parses + validates a JSON object into a DTO. Collects every error. +template +struct JsonReader { + static tl::expected> read(const nlohmann::json & j) { + std::vector errs; + T obj{}; + if (!j.is_object()) { + errs.push_back({"", "expected a JSON object"}); + return tl::make_unexpected(errs); + } + for_each_field([&](const auto & f) { + const std::string key(f.key); + auto & member = obj.*(f.ptr); + using MemberT = std::decay_t; + const auto it = j.find(key); + if (it == j.end() || it->is_null()) { + if (f.presence == Presence::kRequired) { + errs.push_back({key, "missing required field"}); + } + return; // unknown/extra fields are never looked for (lenient) + } + if constexpr (is_optional_v) { + typename MemberT::value_type val{}; + decode_value(*it, val, key, errs); + if constexpr (std::is_same_v) { + check_enum(f, val, key, errs); + } + member = std::move(val); + } else { + decode_value(*it, member, key, errs); + if constexpr (std::is_same_v) { + check_enum(f, member, key, errs); + } + } + }); + if (!errs.empty()) { + return tl::make_unexpected(errs); + } + return obj; + } +}; + +} // namespace dto +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/test/test_dto_contract.cpp b/src/ros2_medkit_gateway/test/test_dto_contract.cpp index c1265741..2683c495 100644 --- a/src/ros2_medkit_gateway/test/test_dto_contract.cpp +++ b/src/ros2_medkit_gateway/test/test_dto_contract.cpp @@ -19,6 +19,7 @@ #include #include "ros2_medkit_gateway/dto/contract.hpp" +#include "ros2_medkit_gateway/dto/json_reader.hpp" #include "ros2_medkit_gateway/dto/json_writer.hpp" #include "ros2_medkit_gateway/dto/schema_writer.hpp" @@ -94,3 +95,34 @@ TEST(JsonWriter, WritesPresentOptional) { const auto j = dto::JsonWriter::write(s); EXPECT_EQ(j.at("note"), "hello"); } + +TEST(JsonReader, DecodesValidObject) { + const auto j = nlohmann::json{{"id", "x"}, {"note", "n"}}; + const auto result = dto::JsonReader::read(j); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->id, "x"); + ASSERT_TRUE(result->note.has_value()); + EXPECT_EQ(*result->note, "n"); +} + +TEST(JsonReader, ReportsMissingRequiredField) { + const auto j = nlohmann::json{{"note", "n"}}; + const auto result = dto::JsonReader::read(j); + ASSERT_FALSE(result.has_value()); + ASSERT_EQ(result.error().size(), 1u); + EXPECT_EQ(result.error()[0].field, "id"); +} + +TEST(JsonReader, ReportsWrongType) { + const auto j = nlohmann::json{{"id", 123}}; + const auto result = dto::JsonReader::read(j); + ASSERT_FALSE(result.has_value()); + EXPECT_EQ(result.error()[0].field, "id"); +} + +TEST(JsonReader, IgnoresUnknownFields) { + const auto j = nlohmann::json{{"id", "x"}, {"bogus", 1}}; + const auto result = dto::JsonReader::read(j); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->id, "x"); +} From fe9f46b1801faccbad191e6f7aa438a33d82e317 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 17 May 2026 21:56:49 +0200 Subject: [PATCH 05/51] feat(gateway): add DTO sample synthesizer and round-trip self-test --- .../ros2_medkit_gateway/dto/sample.hpp | 89 +++++++++++++++++++ .../test/test_dto_contract.cpp | 42 +++++++++ 2 files changed, 131 insertions(+) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/sample.hpp diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/sample.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/sample.hpp new file mode 100644 index 00000000..1c5ea1ae --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/sample.hpp @@ -0,0 +1,89 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include + +#include "ros2_medkit_gateway/dto/contract.hpp" + +namespace ros2_medkit_gateway { +namespace dto { + +/// Canonical value for a non-DTO field type. DTO-typed members never reach +/// here - dto_sample::make handles them directly. +template +U sample_value() { + if constexpr (is_optional_v) { + return typename U::value_type{}; // present, default-filled + } else if constexpr (is_vector_v) { + return U{}; // empty vector is a valid array + } else if constexpr (std::is_same_v) { + return std::string{"sample"}; + } else if constexpr (std::is_same_v) { + return true; + } else if constexpr (std::is_arithmetic_v) { + return U{1}; + } else { + return U{}; // nlohmann::json and any other type + } +} + +/// Build a canonical sample instance of a DTO by filling each field. +/// Specialize dto_sample to override for DTOs the generic path cannot cover +/// (e.g. variant members - the variant's first alternative must be +/// default-constructible for the generic path to work). +template +struct dto_sample { + static T make() { + T obj{}; + for_each_field([&](const auto & f) { + using MemberT = std::decay_t; + if constexpr (is_dto_v) { + obj.*(f.ptr) = dto_sample::make(); + } else if constexpr (is_optional_v) { + if constexpr (is_dto_v) { + obj.*(f.ptr) = dto_sample::make(); + } else { + obj.*(f.ptr) = sample_value(); + } + } else { + obj.*(f.ptr) = sample_value(); + } + // enum fields (string or optional): use the first allowed value. + // NESTED if constexpr - a single `&&` would substitute + // `typename MemberT::value_type` for non-optional scalar members and + // hard-fail to compile. + if (f.enum_count > 0) { + if constexpr (std::is_same_v) { + obj.*(f.ptr) = std::string(f.enum_values[0]); + } else if constexpr (is_optional_v) { + if constexpr (std::is_same_v) { + obj.*(f.ptr) = std::string(f.enum_values[0]); + } + } + } + }); + return obj; + } +}; + +template +T make_sample() { + return dto_sample::make(); +} + +} // namespace dto +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/test/test_dto_contract.cpp b/src/ros2_medkit_gateway/test/test_dto_contract.cpp index 2683c495..df8b12cf 100644 --- a/src/ros2_medkit_gateway/test/test_dto_contract.cpp +++ b/src/ros2_medkit_gateway/test/test_dto_contract.cpp @@ -21,6 +21,7 @@ #include "ros2_medkit_gateway/dto/contract.hpp" #include "ros2_medkit_gateway/dto/json_reader.hpp" #include "ros2_medkit_gateway/dto/json_writer.hpp" +#include "ros2_medkit_gateway/dto/sample.hpp" #include "ros2_medkit_gateway/dto/schema_writer.hpp" namespace dto = ros2_medkit_gateway::dto; @@ -31,6 +32,12 @@ struct Sample { std::optional note; }; inline constexpr std::string_view kSampleColors[] = {"red", "green"}; + +struct ScalarSample { + std::string id; + bool active; + int count; +}; } // namespace template <> @@ -40,6 +47,14 @@ inline constexpr auto dto::dto_fields = template <> inline constexpr std::string_view dto::dto_name = "Sample"; +template <> +inline constexpr auto dto::dto_fields = + std::make_tuple(dto::field("id", &ScalarSample::id), dto::field("active", &ScalarSample::active), + dto::field("count", &ScalarSample::count)); + +template <> +inline constexpr std::string_view dto::dto_name = "ScalarSample"; + TEST(DtoContract, FieldFactoryDerivesPresenceFromOptional) { constexpr auto fields = dto::dto_fields; EXPECT_EQ(std::get<0>(fields).presence, dto::Presence::kRequired); @@ -126,3 +141,30 @@ TEST(JsonReader, IgnoresUnknownFields) { ASSERT_TRUE(result.has_value()); EXPECT_EQ(result->id, "x"); } + +TEST(DtoSample, RoundTripsThroughWriterAndReader) { + const Sample s = dto::make_sample(); + const auto j = dto::JsonWriter::write(s); + const auto back = dto::JsonReader::read(j); + ASSERT_TRUE(back.has_value()); + EXPECT_EQ(back->id, s.id); +} + +TEST(DtoSample, SampleContainsEveryRequiredSchemaKey) { + const Sample s = dto::make_sample(); + const auto j = dto::JsonWriter::write(s); + const auto schema = dto::SchemaWriter::schema(); + for (const auto & req : schema.at("required")) { + EXPECT_TRUE(j.contains(req.get())) << req; + } +} + +TEST(DtoSample, SynthesizesScalarMembers) { + const ScalarSample s = dto::make_sample(); + const auto j = dto::JsonWriter::write(s); + const auto back = dto::JsonReader::read(j); + ASSERT_TRUE(back.has_value()); + EXPECT_EQ(back->id, s.id); + EXPECT_EQ(back->active, s.active); + EXPECT_EQ(back->count, s.count); +} From 4022609241c1483a166c5a051f3356a091fd22b3 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 17 May 2026 22:04:20 +0200 Subject: [PATCH 06/51] feat(gateway): add DTO registry and component-schema collection --- .../ros2_medkit_gateway/dto/registry.hpp | 52 +++++++++++++++++++ .../test/test_dto_contract.cpp | 6 +++ 2 files changed, 58 insertions(+) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp new file mode 100644 index 00000000..917c10d2 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp @@ -0,0 +1,52 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include +#include + +#include "ros2_medkit_gateway/dto/contract.hpp" +#include "ros2_medkit_gateway/dto/schema_writer.hpp" + +namespace ros2_medkit_gateway { +namespace dto { + +/// The single compile-time list of every named DTO. Each domain header +/// (Phase 2/3) appends its types here. Order is irrelevant. +using AllDtos = std::tuple< + // entity + x-medkit + error DTOs appended in Task 10, then one block + // per domain in Phase 3. + >; + +namespace detail { +template +nlohmann::json collect_impl(std::index_sequence) { + nlohmann::json schemas = nlohmann::json::object(); + ((schemas[std::string(dto_name>)] = + SchemaWriter>::schema()), + ...); + return schemas; +} +} // namespace detail + +/// Build the components/schemas object from every DTO in AllDtos. +inline nlohmann::json collect_component_schemas() { + return detail::collect_impl(std::make_index_sequence>{}); +} + +} // namespace dto +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/test/test_dto_contract.cpp b/src/ros2_medkit_gateway/test/test_dto_contract.cpp index df8b12cf..fb896320 100644 --- a/src/ros2_medkit_gateway/test/test_dto_contract.cpp +++ b/src/ros2_medkit_gateway/test/test_dto_contract.cpp @@ -21,6 +21,7 @@ #include "ros2_medkit_gateway/dto/contract.hpp" #include "ros2_medkit_gateway/dto/json_reader.hpp" #include "ros2_medkit_gateway/dto/json_writer.hpp" +#include "ros2_medkit_gateway/dto/registry.hpp" #include "ros2_medkit_gateway/dto/sample.hpp" #include "ros2_medkit_gateway/dto/schema_writer.hpp" @@ -168,3 +169,8 @@ TEST(DtoSample, SynthesizesScalarMembers) { EXPECT_EQ(back->active, s.active); EXPECT_EQ(back->count, s.count); } + +TEST(DtoRegistry, CollectsNamedSchemas) { + const auto schemas = dto::collect_component_schemas(); + EXPECT_TRUE(schemas.is_object()); +} From 280d06224e6f3cbca56ad5f8496a93195de1db31 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 08:24:33 +0200 Subject: [PATCH 07/51] fix(gateway): clang-tidy cleanup of the DTO contract headers Name the unnamed std::index_sequence tag parameters in variant_schema() and collect_impl() to satisfy readability-named-parameter. These are the only two clang-tidy findings in the new dto/ headers. --- .../include/ros2_medkit_gateway/dto/registry.hpp | 2 +- .../include/ros2_medkit_gateway/dto/schema_writer.hpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp index 917c10d2..67016f7b 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp @@ -34,7 +34,7 @@ using AllDtos = std::tuple< namespace detail { template -nlohmann::json collect_impl(std::index_sequence) { +nlohmann::json collect_impl(std::index_sequence /*seq*/) { nlohmann::json schemas = nlohmann::json::object(); ((schemas[std::string(dto_name>)] = SchemaWriter>::schema()), diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/schema_writer.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/schema_writer.hpp index 97bc40ab..07188fb4 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/schema_writer.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/schema_writer.hpp @@ -31,7 +31,7 @@ nlohmann::json schema_of(); // forward declaration namespace detail { template -nlohmann::json variant_schema(std::index_sequence) { +nlohmann::json variant_schema(std::index_sequence /*seq*/) { return nlohmann::json{{"oneOf", nlohmann::json::array({schema_of>()...})}}; } } // namespace detail From 794eaabb9e1b23624722d286baf806712d8dcdf4 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 08:46:59 +0200 Subject: [PATCH 08/51] feat(gateway): add DTO enum vocabularies and GenericError --- .../include/ros2_medkit_gateway/dto/enums.hpp | 60 +++++++++++++++++++ .../ros2_medkit_gateway/dto/errors.hpp | 45 ++++++++++++++ .../test/test_dto_contract.cpp | 37 ++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/enums.hpp create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/errors.hpp diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/enums.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/enums.hpp new file mode 100644 index 00000000..f88c8486 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/enums.hpp @@ -0,0 +1,60 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include + +namespace ros2_medkit_gateway { +namespace dto { + +/// Entity types (used in entity detail responses). +inline constexpr std::string_view kEntityTypeValues[] = {"area", "component", "app", "function"}; + +/// Fault severity label (fault_list_item_schema / fault_detail_schema). +inline constexpr std::string_view kFaultSeverityLabelValues[] = {"INFO", "WARN", "ERROR", "CRITICAL"}; + +/// Fault aggregated status (fault_detail_schema - status.aggregatedStatus). +inline constexpr std::string_view kFaultAggregatedStatusValues[] = {"active", "passive", "cleared"}; + +/// Log aggregation level (log_entry_list_schema - x-medkit.aggregation_level). +inline constexpr std::string_view kLogAggregationLevelValues[] = {"function", "area", "component"}; + +/// Operation/execution status (operation_execution_schema). +inline constexpr std::string_view kOperationExecutionStatusValues[] = {"pending", "running", "completed", "failed"}; + +/// Trigger status (trigger_schema). +inline constexpr std::string_view kTriggerStatusValues[] = {"active", "terminated"}; + +/// Cyclic subscription interval (cyclic_subscription_schema). +inline constexpr std::string_view kCyclicSubscriptionIntervalValues[] = {"fast", "normal", "slow"}; + +/// Update internal lifecycle phase (update_status_schema - x-medkit.phase). +inline constexpr std::string_view kUpdatePhaseValues[] = {"none", "preparing", "prepared", "executing", + "executed", "failed", "deleting"}; + +/// Update status (update_status_schema). +inline constexpr std::string_view kUpdateStatusValues[] = {"pending", "inProgress", "completed", "failed"}; + +/// Log severity filter (log_configuration_schema). +inline constexpr std::string_view kLogSeverityFilterValues[] = {"debug", "info", "warning", "error", "fatal"}; + +/// Execution control capability (execution_update_request_schema). +inline constexpr std::string_view kExecutionCapabilityValues[] = {"stop", "execute", "freeze", "reset"}; + +/// Script control action (script_control_request_schema). +inline constexpr std::string_view kScriptControlActionValues[] = {"stop", "forced_termination"}; + +} // namespace dto +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/errors.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/errors.hpp new file mode 100644 index 00000000..6508759b --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/errors.hpp @@ -0,0 +1,45 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include +#include +#include + +#include "ros2_medkit_gateway/dto/contract.hpp" + +namespace ros2_medkit_gateway { +namespace dto { + +// GenericError mirrors SchemaBuilder::generic_error(): +// required: error_code, message +// optional: parameters (free-form JSON object - schema says {"type":"object"}) +struct GenericError { + std::string error_code; + std::string message; + std::optional parameters; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("error_code", &GenericError::error_code), field("message", &GenericError::message), + field("parameters", &GenericError::parameters)); +template <> +inline constexpr std::string_view dto_name = "GenericError"; + +} // namespace dto +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/test/test_dto_contract.cpp b/src/ros2_medkit_gateway/test/test_dto_contract.cpp index fb896320..162acc35 100644 --- a/src/ros2_medkit_gateway/test/test_dto_contract.cpp +++ b/src/ros2_medkit_gateway/test/test_dto_contract.cpp @@ -19,6 +19,8 @@ #include #include "ros2_medkit_gateway/dto/contract.hpp" +#include "ros2_medkit_gateway/dto/enums.hpp" +#include "ros2_medkit_gateway/dto/errors.hpp" #include "ros2_medkit_gateway/dto/json_reader.hpp" #include "ros2_medkit_gateway/dto/json_writer.hpp" #include "ros2_medkit_gateway/dto/registry.hpp" @@ -174,3 +176,38 @@ TEST(DtoRegistry, CollectsNamedSchemas) { const auto schemas = dto::collect_component_schemas(); EXPECT_TRUE(schemas.is_object()); } + +TEST(DtoErrors, GenericErrorIsDtoWithCorrectFields) { + EXPECT_TRUE(dto::is_dto_v); + EXPECT_EQ(dto::dto_name, "GenericError"); + + const auto schema = dto::SchemaWriter::schema(); + EXPECT_EQ(schema.at("type"), "object"); + EXPECT_TRUE(schema.at("properties").contains("error_code")); + EXPECT_TRUE(schema.at("properties").contains("message")); + EXPECT_TRUE(schema.at("properties").contains("parameters")); + + // error_code and message are required; parameters is optional + const auto & req = schema.at("required"); + EXPECT_NE(std::find(req.begin(), req.end(), "error_code"), req.end()); + EXPECT_NE(std::find(req.begin(), req.end(), "message"), req.end()); + EXPECT_EQ(std::find(req.begin(), req.end(), "parameters"), req.end()); +} + +TEST(DtoErrors, GenericErrorRoundTrips) { + dto::GenericError e{"x-medkit-entity-not-found", "Entity not found", std::nullopt}; + const auto j = dto::JsonWriter::write(e); + EXPECT_EQ(j.at("error_code"), "x-medkit-entity-not-found"); + EXPECT_EQ(j.at("message"), "Entity not found"); + EXPECT_FALSE(j.contains("parameters")); +} + +TEST(DtoEnums, EntityTypeVocabularyHasFourValues) { + EXPECT_EQ(std::size(dto::kEntityTypeValues), 4u); + EXPECT_EQ(dto::kEntityTypeValues[0], "area"); + EXPECT_EQ(dto::kEntityTypeValues[3], "function"); +} + +TEST(DtoEnums, CyclicSubscriptionIntervalHasThreeValues) { + EXPECT_EQ(std::size(dto::kCyclicSubscriptionIntervalValues), 3u); +} From 6d6998c35699fdb0932a0c0d667ed0a7621dcc30 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 08:53:21 +0200 Subject: [PATCH 09/51] feat(gateway): add typed x-medkit DTOs with nested ros2 sub-object --- .../ros2_medkit_gateway/dto/x_medkit.hpp | 195 ++++++++++++++++++ .../test/test_dto_contract.cpp | 141 +++++++++++++ 2 files changed, 336 insertions(+) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/x_medkit.hpp diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/x_medkit.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/x_medkit.hpp new file mode 100644 index 00000000..2a4f02d1 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/x_medkit.hpp @@ -0,0 +1,195 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "ros2_medkit_gateway/dto/contract.hpp" + +namespace ros2_medkit_gateway { +namespace dto { + +// --------------------------------------------------------------------------- +// XMedkitRos2 - the nested "ros2" sub-object inside every x-medkit payload. +// +// All members are optional: each entity type only populates the subset that +// applies to it. Wire key for the namespace member is "namespace" even +// though the C++ member is named `ns` (reserved keyword avoidance). +// --------------------------------------------------------------------------- +struct XMedkitRos2 { + std::optional node; + std::optional ns; // wire key: "namespace" + std::optional type; + std::optional topic; + std::optional service; + std::optional action; + std::optional kind; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("node", &XMedkitRos2::node), field("namespace", &XMedkitRos2::ns), + field("type", &XMedkitRos2::type), field("topic", &XMedkitRos2::topic), + field("service", &XMedkitRos2::service), field("action", &XMedkitRos2::action), + field("kind", &XMedkitRos2::kind)); + +template <> +inline constexpr std::string_view dto_name = "XMedkitRos2"; + +// --------------------------------------------------------------------------- +// XMedkitArea - x-medkit payload for Area entities. +// +// Populated by handle_get_area / handle_list_areas: +// ros2.namespace <- area.namespace_path +// parent_area_id <- area.parent_area_id (detail only, via ext.add()) +// contributors <- area.contributors (detail only) +// --------------------------------------------------------------------------- +struct XMedkitArea { + std::optional ros2; + std::optional parent_area_id; + std::optional> contributors; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("ros2", &XMedkitArea::ros2), field("parent_area_id", &XMedkitArea::parent_area_id), + field("contributors", &XMedkitArea::contributors)); + +template <> +inline constexpr std::string_view dto_name = "XMedkitArea"; + +// --------------------------------------------------------------------------- +// XMedkitComponent - x-medkit payload for Component entities. +// +// Populated by handle_get_component / handle_list_components: +// source <- comp.source +// ros2.node <- comp.fqn +// ros2.namespace <- comp.namespace_path +// type <- comp.type (via ext.add()) +// parent_component_id <- comp.parent_component_id (via ext.add("parentComponentId",...)) +// depends_on <- comp.depends_on (via ext.add(), array of strings) +// area <- comp.area (via ext.add()) +// variant <- comp.variant (via ext.add()) +// description <- comp.description (via ext.add()) +// contributors <- comp.contributors +// capabilities <- capabilities JSON array (via ext.add()) +// +// Note: "parentComponentId" uses camelCase on the wire per discovery_handlers.cpp. +// "dependsOn" uses camelCase on the wire per discovery_handlers.cpp. +// --------------------------------------------------------------------------- +struct XMedkitComponent { + std::optional ros2; + std::optional source; + std::optional type; + std::optional parent_component_id; // wire key: "parentComponentId" + std::optional> depends_on; // wire key: "dependsOn" + std::optional area; + std::optional variant; + std::optional description; + std::optional> contributors; + std::optional capabilities; // free-form JSON array +}; + +template <> +inline constexpr auto dto_fields = std::make_tuple( + field("ros2", &XMedkitComponent::ros2), field("source", &XMedkitComponent::source), + field("type", &XMedkitComponent::type), field("parentComponentId", &XMedkitComponent::parent_component_id), + field("dependsOn", &XMedkitComponent::depends_on), field("area", &XMedkitComponent::area), + field("variant", &XMedkitComponent::variant), field("description", &XMedkitComponent::description), + field("contributors", &XMedkitComponent::contributors), field("capabilities", &XMedkitComponent::capabilities)); + +template <> +inline constexpr std::string_view dto_name = "XMedkitComponent"; + +// --------------------------------------------------------------------------- +// XMedkitApp - x-medkit payload for App entities. +// +// Populated by handle_get_app / handle_list_apps: +// source <- app.source +// is_online <- app.is_online +// ros2.node <- app.bound_fqn +// component_id <- app.component_id +// contributors <- app.contributors (detail only) +// --------------------------------------------------------------------------- +struct XMedkitApp { + std::optional ros2; + std::optional source; + std::optional is_online; + std::optional component_id; + std::optional> contributors; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("ros2", &XMedkitApp::ros2), field("source", &XMedkitApp::source), + field("is_online", &XMedkitApp::is_online), field("component_id", &XMedkitApp::component_id), + field("contributors", &XMedkitApp::contributors)); + +template <> +inline constexpr std::string_view dto_name = "XMedkitApp"; + +// --------------------------------------------------------------------------- +// XMedkitFunction - x-medkit payload for Function entities. +// +// Populated by handle_get_function / handle_list_functions: +// source <- func.source +// hosts <- func.hosts (array of app IDs, via ext.add()) +// description <- func.description (via ext.add()) +// contributors <- func.contributors (detail only) +// --------------------------------------------------------------------------- +struct XMedkitFunction { + std::optional ros2; + std::optional source; + std::optional> hosts; + std::optional description; + std::optional> contributors; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("ros2", &XMedkitFunction::ros2), field("source", &XMedkitFunction::source), + field("hosts", &XMedkitFunction::hosts), field("description", &XMedkitFunction::description), + field("contributors", &XMedkitFunction::contributors)); + +template <> +inline constexpr std::string_view dto_name = "XMedkitFunction"; + +// --------------------------------------------------------------------------- +// XMedkitCollection - x-medkit payload on list (collection) responses. +// +// Emitted by every handle_list_* and sub-collection handler via resp_ext.add(): +// total_count <- items.size() +// contributors <- (optional aggregation provenance, included for completeness) +// --------------------------------------------------------------------------- +struct XMedkitCollection { + std::optional total_count; + std::optional> contributors; +}; + +template <> +inline constexpr auto dto_fields = std::make_tuple( + field("total_count", &XMedkitCollection::total_count), field("contributors", &XMedkitCollection::contributors)); + +template <> +inline constexpr std::string_view dto_name = "XMedkitCollection"; + +} // namespace dto +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/test/test_dto_contract.cpp b/src/ros2_medkit_gateway/test/test_dto_contract.cpp index 162acc35..81c9171f 100644 --- a/src/ros2_medkit_gateway/test/test_dto_contract.cpp +++ b/src/ros2_medkit_gateway/test/test_dto_contract.cpp @@ -26,6 +26,7 @@ #include "ros2_medkit_gateway/dto/registry.hpp" #include "ros2_medkit_gateway/dto/sample.hpp" #include "ros2_medkit_gateway/dto/schema_writer.hpp" +#include "ros2_medkit_gateway/dto/x_medkit.hpp" namespace dto = ros2_medkit_gateway::dto; @@ -211,3 +212,143 @@ TEST(DtoEnums, EntityTypeVocabularyHasFourValues) { TEST(DtoEnums, CyclicSubscriptionIntervalHasThreeValues) { EXPECT_EQ(std::size(dto::kCyclicSubscriptionIntervalValues), 3u); } + +// ============================================================================= +// XMedkit DTOs +// ============================================================================= + +TEST(XMedkitDtos, XMedkitRos2IsDto) { + EXPECT_TRUE(dto::is_dto_v); + EXPECT_EQ(dto::dto_name, "XMedkitRos2"); +} + +TEST(XMedkitDtos, XMedkitRos2NamespaceKeyIsMappedCorrectly) { + // The C++ member is `ns` but the wire key must be "namespace". + dto::XMedkitRos2 r2; + r2.ns = "/sensors"; + const auto j = dto::JsonWriter::write(r2); + EXPECT_TRUE(j.contains("namespace")); + EXPECT_EQ(j.at("namespace"), "/sensors"); + EXPECT_FALSE(j.contains("ns")); +} + +TEST(XMedkitDtos, XMedkitAreaNestedRos2IsSerializedCorrectly) { + dto::XMedkitArea area; + dto::XMedkitRos2 r2; + r2.ns = "/perception"; + area.ros2 = r2; + area.contributors = std::vector{"local", "peer:robot2"}; + + const auto j = dto::JsonWriter::write(area); + + // Nested "ros2" object must be present with the "namespace" sub-key. + ASSERT_TRUE(j.contains("ros2")); + EXPECT_TRUE(j.at("ros2").contains("namespace")); + EXPECT_EQ(j.at("ros2").at("namespace"), "/perception"); + + // optional parent_area_id is absent when not set + EXPECT_FALSE(j.contains("parent_area_id")); + + // contributors are serialized + ASSERT_TRUE(j.contains("contributors")); + EXPECT_EQ(j.at("contributors").size(), 2u); +} + +TEST(XMedkitDtos, XMedkitAreaSchemaHasRos2RefProperty) { + const auto schema = dto::SchemaWriter::schema(); + ASSERT_EQ(schema.at("type"), "object"); + ASSERT_TRUE(schema.at("properties").contains("ros2")); + // Nested DTO renders as a $ref in schema. + const auto & ros2_prop = schema.at("properties").at("ros2"); + EXPECT_TRUE(ros2_prop.contains("$ref")); + EXPECT_EQ(ros2_prop.at("$ref"), "#/components/schemas/XMedkitRos2"); +} + +TEST(XMedkitDtos, XMedkitComponentIsDto) { + EXPECT_TRUE(dto::is_dto_v); + EXPECT_EQ(dto::dto_name, "XMedkitComponent"); +} + +TEST(XMedkitDtos, XMedkitComponentCamelCaseWireKeys) { + dto::XMedkitComponent comp; + comp.parent_component_id = "parent_comp"; + comp.depends_on = std::vector{"dep_a", "dep_b"}; + + const auto j = dto::JsonWriter::write(comp); + + EXPECT_TRUE(j.contains("parentComponentId")); + EXPECT_EQ(j.at("parentComponentId"), "parent_comp"); + EXPECT_TRUE(j.contains("dependsOn")); + EXPECT_EQ(j.at("dependsOn").size(), 2u); +} + +TEST(XMedkitDtos, XMedkitAppIsDto) { + EXPECT_TRUE(dto::is_dto_v); + EXPECT_EQ(dto::dto_name, "XMedkitApp"); +} + +TEST(XMedkitDtos, XMedkitAppRoundTrip) { + dto::XMedkitApp app; + dto::XMedkitRos2 r2; + r2.node = "/sensors/camera"; + app.ros2 = r2; + app.source = "runtime"; + app.is_online = true; + app.component_id = "sensors_comp"; + + const auto j = dto::JsonWriter::write(app); + EXPECT_EQ(j.at("source"), "runtime"); + EXPECT_EQ(j.at("is_online"), true); + EXPECT_EQ(j.at("component_id"), "sensors_comp"); + ASSERT_TRUE(j.contains("ros2")); + EXPECT_EQ(j.at("ros2").at("node"), "/sensors/camera"); +} + +TEST(XMedkitDtos, XMedkitFunctionIsDto) { + EXPECT_TRUE(dto::is_dto_v); + EXPECT_EQ(dto::dto_name, "XMedkitFunction"); +} + +TEST(XMedkitDtos, XMedkitFunctionHostsSerializedAsArray) { + dto::XMedkitFunction func; + func.source = "manifest"; + func.hosts = std::vector{"app_a", "app_b"}; + func.description = "Navigation function"; + + const auto j = dto::JsonWriter::write(func); + EXPECT_EQ(j.at("source"), "manifest"); + ASSERT_TRUE(j.contains("hosts")); + EXPECT_EQ(j.at("hosts").size(), 2u); + EXPECT_EQ(j.at("description"), "Navigation function"); +} + +TEST(XMedkitDtos, XMedkitCollectionIsDto) { + EXPECT_TRUE(dto::is_dto_v); + EXPECT_EQ(dto::dto_name, "XMedkitCollection"); +} + +TEST(XMedkitDtos, XMedkitCollectionTotalCount) { + dto::XMedkitCollection col; + col.total_count = 42u; + + const auto j = dto::JsonWriter::write(col); + ASSERT_TRUE(j.contains("total_count")); + EXPECT_EQ(j.at("total_count"), 42u); + EXPECT_FALSE(j.contains("contributors")); +} + +TEST(XMedkitDtos, AllXMedkitSchemasAreObjects) { + const auto area_schema = dto::SchemaWriter::schema(); + const auto comp_schema = dto::SchemaWriter::schema(); + const auto app_schema = dto::SchemaWriter::schema(); + const auto func_schema = dto::SchemaWriter::schema(); + const auto coll_schema = dto::SchemaWriter::schema(); + const auto ros2_schema = dto::SchemaWriter::schema(); + + EXPECT_EQ(area_schema.at("type"), "object"); + EXPECT_EQ(comp_schema.at("type"), "object"); + EXPECT_EQ(app_schema.at("type"), "object"); + EXPECT_EQ(func_schema.at("type"), "object"); + EXPECT_EQ(coll_schema.at("type"), "object"); + EXPECT_EQ(ros2_schema.at("type"), "object"); +} From a37da14fed1604b85aef5cff8e5bebb9d91aa67a Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 09:00:28 +0200 Subject: [PATCH 10/51] feat(gateway): add entity DTOs and register them in AllDtos Adds entities.hpp with 8 entity DTOs (list-item + detail pairs for Area, Component, App, Function), the Collection wrapper with per-instantiation dto_name specializations, and populates AllDtos in registry.hpp with all entity, x-medkit, error, and collection DTOs. Extends test_dto_contract.cpp with EveryRegisteredDtoRoundTrips, which exercises every registered DTO through SchemaWriter, JsonWriter, and JsonReader. --- .../ros2_medkit_gateway/dto/entities.hpp | 399 ++++++++++++++++++ .../ros2_medkit_gateway/dto/registry.hpp | 11 +- .../test/test_dto_contract.cpp | 25 ++ 3 files changed, 431 insertions(+), 4 deletions(-) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/entities.hpp diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/entities.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/entities.hpp new file mode 100644 index 00000000..24fc9924 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/entities.hpp @@ -0,0 +1,399 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "ros2_medkit_gateway/dto/contract.hpp" +#include "ros2_medkit_gateway/dto/enums.hpp" +#include "ros2_medkit_gateway/dto/x_medkit.hpp" + +namespace ros2_medkit_gateway { +namespace dto { + +// ============================================================================= +// Area DTOs +// ============================================================================= + +// ----------------------------------------------------------------------------- +// AreaListItem - shape emitted per item in handle_list_areas "items" array. +// +// Wire keys: id, name, href, type, description?, tags?, x-medkit +// ----------------------------------------------------------------------------- +struct AreaListItem { + std::string id; + std::string name; + std::string href; + std::string type; // entity type discriminator (area|component|app|function) + std::optional description; + std::optional> tags; + std::optional x_medkit; // wire key: "x-medkit" +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("id", &AreaListItem::id), field("name", &AreaListItem::name), + field("href", &AreaListItem::href), field_enum("type", &AreaListItem::type, kEntityTypeValues), + field("description", &AreaListItem::description), field("tags", &AreaListItem::tags), + field("x-medkit", &AreaListItem::x_medkit)); + +template <> +inline constexpr std::string_view dto_name = "AreaListItem"; + +// ----------------------------------------------------------------------------- +// AreaDetail - shape emitted by handle_get_area. +// +// Wire keys: +// id, name, description?, tags?, +// subareas, components, contains, data, operations, configurations, faults, +// logs, bulk-data, triggers, +// capabilities (free-form JSON array of {name, href} objects), +// _links (free-form JSON object), +// x-medkit +// ----------------------------------------------------------------------------- +struct AreaDetail { + std::string id; + std::string name; + std::string type; // entity type discriminator (area|component|app|function) + std::optional description; + std::optional> tags; + // Resource collection URI fields (always present in detail response) + std::string subareas; + std::string components; + std::string contains; + std::string data; + std::string operations; + std::string configurations; + std::string faults; + std::string logs; + std::string bulk_data; // wire key: "bulk-data" + std::string triggers; + // Free-form fields + std::optional capabilities; // array of {name, href} + std::optional links; // wire key: "_links" + std::optional x_medkit; // wire key: "x-medkit" +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("id", &AreaDetail::id), field("name", &AreaDetail::name), + field_enum("type", &AreaDetail::type, kEntityTypeValues), + field("description", &AreaDetail::description), field("tags", &AreaDetail::tags), + field("subareas", &AreaDetail::subareas), field("components", &AreaDetail::components), + field("contains", &AreaDetail::contains), field("data", &AreaDetail::data), + field("operations", &AreaDetail::operations), field("configurations", &AreaDetail::configurations), + field("faults", &AreaDetail::faults), field("logs", &AreaDetail::logs), + field("bulk-data", &AreaDetail::bulk_data), field("triggers", &AreaDetail::triggers), + field("capabilities", &AreaDetail::capabilities), field("_links", &AreaDetail::links), + field("x-medkit", &AreaDetail::x_medkit)); + +template <> +inline constexpr std::string_view dto_name = "AreaDetail"; + +// ============================================================================= +// Component DTOs +// ============================================================================= + +// ----------------------------------------------------------------------------- +// ComponentListItem - shape emitted per item in handle_list_components "items". +// +// Wire keys: id, name, href, description?, tags?, x-medkit +// ----------------------------------------------------------------------------- +struct ComponentListItem { + std::string id; + std::string name; + std::string href; + std::string type; // entity type discriminator (area|component|app|function) + std::optional description; + std::optional> tags; + std::optional x_medkit; // wire key: "x-medkit" +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("id", &ComponentListItem::id), field("name", &ComponentListItem::name), + field("href", &ComponentListItem::href), + field_enum("type", &ComponentListItem::type, kEntityTypeValues), + field("description", &ComponentListItem::description), field("tags", &ComponentListItem::tags), + field("x-medkit", &ComponentListItem::x_medkit)); + +template <> +inline constexpr std::string_view dto_name = "ComponentListItem"; + +// ----------------------------------------------------------------------------- +// ComponentDetail - shape emitted by handle_get_component. +// +// Wire keys: +// id, name, description?, tags?, +// data, operations, configurations, faults, subcomponents, hosts, logs, +// bulk-data, cyclic-subscriptions, triggers, +// scripts? (conditional on script backend), depends-on? (conditional), +// belongs-to? (conditional on area), is-located-on? (not present here - app only), +// capabilities (free-form JSON array), +// _links (free-form JSON object), +// x-medkit +// ----------------------------------------------------------------------------- +struct ComponentDetail { + std::string id; + std::string name; + std::string type; // entity type discriminator (area|component|app|function) + std::optional description; + std::optional> tags; + // Always-present resource collection URIs + std::string data; + std::string operations; + std::string configurations; + std::string faults; + std::string subcomponents; + std::string hosts; + std::string logs; + std::string bulk_data; // wire key: "bulk-data" + std::string cyclic_subscriptions; // wire key: "cyclic-subscriptions" + std::string triggers; + // Conditional URI fields + std::optional scripts; // present only with script backend + std::optional depends_on; // wire key: "depends-on" + std::optional belongs_to; // wire key: "belongs-to" + // Free-form fields + std::optional capabilities; + std::optional links; // wire key: "_links" + std::optional x_medkit; // wire key: "x-medkit" +}; + +template <> +inline constexpr auto dto_fields = std::make_tuple( + field("id", &ComponentDetail::id), field("name", &ComponentDetail::name), + field_enum("type", &ComponentDetail::type, kEntityTypeValues), field("description", &ComponentDetail::description), + field("tags", &ComponentDetail::tags), field("data", &ComponentDetail::data), + field("operations", &ComponentDetail::operations), field("configurations", &ComponentDetail::configurations), + field("faults", &ComponentDetail::faults), field("subcomponents", &ComponentDetail::subcomponents), + field("hosts", &ComponentDetail::hosts), field("logs", &ComponentDetail::logs), + field("bulk-data", &ComponentDetail::bulk_data), + field("cyclic-subscriptions", &ComponentDetail::cyclic_subscriptions), + field("triggers", &ComponentDetail::triggers), field("scripts", &ComponentDetail::scripts), + field("depends-on", &ComponentDetail::depends_on), field("belongs-to", &ComponentDetail::belongs_to), + field("capabilities", &ComponentDetail::capabilities), field("_links", &ComponentDetail::links), + field("x-medkit", &ComponentDetail::x_medkit)); + +template <> +inline constexpr std::string_view dto_name = "ComponentDetail"; + +// ============================================================================= +// App DTOs +// ============================================================================= + +// ----------------------------------------------------------------------------- +// AppListItem - shape emitted per item in handle_list_apps "items" array. +// +// Wire keys: id, name, href, description?, tags?, x-medkit +// ----------------------------------------------------------------------------- +struct AppListItem { + std::string id; + std::string name; + std::string href; + std::string type; // entity type discriminator (area|component|app|function) + std::optional description; + std::optional> tags; + std::optional x_medkit; // wire key: "x-medkit" +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("id", &AppListItem::id), field("name", &AppListItem::name), field("href", &AppListItem::href), + field_enum("type", &AppListItem::type, kEntityTypeValues), + field("description", &AppListItem::description), field("tags", &AppListItem::tags), + field("x-medkit", &AppListItem::x_medkit)); + +template <> +inline constexpr std::string_view dto_name = "AppListItem"; + +// ----------------------------------------------------------------------------- +// AppDetail - shape emitted by handle_get_app. +// +// Wire keys: +// id, name, description?, translation_id?, tags?, +// data, operations, configurations, faults, logs, bulk-data, +// cyclic-subscriptions, triggers, +// scripts? (conditional on script backend), +// is-located-on? (conditional on component_id), +// belongs-to? (conditional on component_id), +// depends-on? (conditional on depends_on list), +// capabilities (free-form JSON array), +// _links (free-form JSON object), +// x-medkit +// ----------------------------------------------------------------------------- +struct AppDetail { + std::string id; + std::string name; + std::string type; // entity type discriminator (area|component|app|function) + std::optional description; + std::optional translation_id; + std::optional> tags; + // Always-present resource collection URIs + std::string data; + std::string operations; + std::string configurations; + std::string faults; + std::string logs; + std::string bulk_data; // wire key: "bulk-data" + std::string cyclic_subscriptions; // wire key: "cyclic-subscriptions" + std::string triggers; + // Conditional URI fields + std::optional scripts; // present only with script backend + std::optional is_located_on; // wire key: "is-located-on" + std::optional belongs_to; // wire key: "belongs-to" + std::optional depends_on; // wire key: "depends-on" + // Free-form fields + std::optional capabilities; + std::optional links; // wire key: "_links" + std::optional x_medkit; // wire key: "x-medkit" +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("id", &AppDetail::id), field("name", &AppDetail::name), + field_enum("type", &AppDetail::type, kEntityTypeValues), + field("description", &AppDetail::description), field("translation_id", &AppDetail::translation_id), + field("tags", &AppDetail::tags), field("data", &AppDetail::data), + field("operations", &AppDetail::operations), field("configurations", &AppDetail::configurations), + field("faults", &AppDetail::faults), field("logs", &AppDetail::logs), + field("bulk-data", &AppDetail::bulk_data), + field("cyclic-subscriptions", &AppDetail::cyclic_subscriptions), + field("triggers", &AppDetail::triggers), field("scripts", &AppDetail::scripts), + field("is-located-on", &AppDetail::is_located_on), field("belongs-to", &AppDetail::belongs_to), + field("depends-on", &AppDetail::depends_on), field("capabilities", &AppDetail::capabilities), + field("_links", &AppDetail::links), field("x-medkit", &AppDetail::x_medkit)); + +template <> +inline constexpr std::string_view dto_name = "AppDetail"; + +// ============================================================================= +// Function DTOs +// ============================================================================= + +// ----------------------------------------------------------------------------- +// FunctionListItem - shape emitted per item in handle_list_functions "items". +// +// Wire keys: id, name, href, description?, tags?, x-medkit +// ----------------------------------------------------------------------------- +struct FunctionListItem { + std::string id; + std::string name; + std::string href; + std::string type; // entity type discriminator (area|component|app|function) + std::optional description; + std::optional> tags; + std::optional x_medkit; // wire key: "x-medkit" +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("id", &FunctionListItem::id), field("name", &FunctionListItem::name), + field("href", &FunctionListItem::href), + field_enum("type", &FunctionListItem::type, kEntityTypeValues), + field("description", &FunctionListItem::description), field("tags", &FunctionListItem::tags), + field("x-medkit", &FunctionListItem::x_medkit)); + +template <> +inline constexpr std::string_view dto_name = "FunctionListItem"; + +// ----------------------------------------------------------------------------- +// FunctionDetail - shape emitted by handle_get_function. +// +// Wire keys: +// id, name, description?, translation_id?, tags?, +// hosts, data, operations, configurations, faults, logs, bulk-data, +// x-medkit-graph, cyclic-subscriptions, triggers, +// capabilities (free-form JSON array), +// _links (free-form JSON object), +// x-medkit +// ----------------------------------------------------------------------------- +struct FunctionDetail { + std::string id; + std::string name; + std::string type; // entity type discriminator (area|component|app|function) + std::optional description; + std::optional translation_id; + std::optional> tags; + // Always-present resource collection URIs + std::string hosts; + std::string data; + std::string operations; + std::string configurations; + std::string faults; + std::string logs; + std::string bulk_data; // wire key: "bulk-data" + std::string x_medkit_graph; // wire key: "x-medkit-graph" + std::string cyclic_subscriptions; // wire key: "cyclic-subscriptions" + std::string triggers; + // Free-form fields + std::optional capabilities; + std::optional links; // wire key: "_links" + std::optional x_medkit; // wire key: "x-medkit" +}; + +template <> +inline constexpr auto dto_fields = std::make_tuple( + field("id", &FunctionDetail::id), field("name", &FunctionDetail::name), + field_enum("type", &FunctionDetail::type, kEntityTypeValues), field("description", &FunctionDetail::description), + field("translation_id", &FunctionDetail::translation_id), field("tags", &FunctionDetail::tags), + field("hosts", &FunctionDetail::hosts), field("data", &FunctionDetail::data), + field("operations", &FunctionDetail::operations), field("configurations", &FunctionDetail::configurations), + field("faults", &FunctionDetail::faults), field("logs", &FunctionDetail::logs), + field("bulk-data", &FunctionDetail::bulk_data), field("x-medkit-graph", &FunctionDetail::x_medkit_graph), + field("cyclic-subscriptions", &FunctionDetail::cyclic_subscriptions), field("triggers", &FunctionDetail::triggers), + field("capabilities", &FunctionDetail::capabilities), field("_links", &FunctionDetail::links), + field("x-medkit", &FunctionDetail::x_medkit)); + +template <> +inline constexpr std::string_view dto_name = "FunctionDetail"; + +// ============================================================================= +// Collection wrapper +// ============================================================================= + +/// Generic collection wrapper used for every entity list response. +/// The "items" array element type T is one of the *ListItem DTOs above. +template +struct Collection { + std::vector items; + std::optional x_medkit; // wire key: "x-medkit" +}; + +template +inline constexpr auto dto_fields> = + std::make_tuple(field("items", &Collection::items), field("x-medkit", &Collection::x_medkit)); + +// dto_name per concrete instantiation (no runtime string concatenation): +template <> +inline constexpr std::string_view dto_name> = "AreaList"; + +template <> +inline constexpr std::string_view dto_name> = "ComponentList"; + +template <> +inline constexpr std::string_view dto_name> = "AppList"; + +template <> +inline constexpr std::string_view dto_name> = "FunctionList"; + +} // namespace dto +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp index 67016f7b..f2e58c80 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp @@ -20,17 +20,20 @@ #include #include "ros2_medkit_gateway/dto/contract.hpp" +#include "ros2_medkit_gateway/dto/entities.hpp" +#include "ros2_medkit_gateway/dto/errors.hpp" #include "ros2_medkit_gateway/dto/schema_writer.hpp" +#include "ros2_medkit_gateway/dto/x_medkit.hpp" namespace ros2_medkit_gateway { namespace dto { /// The single compile-time list of every named DTO. Each domain header /// (Phase 2/3) appends its types here. Order is irrelevant. -using AllDtos = std::tuple< - // entity + x-medkit + error DTOs appended in Task 10, then one block - // per domain in Phase 3. - >; +using AllDtos = std::tuple, + Collection, Collection, Collection>; namespace detail { template diff --git a/src/ros2_medkit_gateway/test/test_dto_contract.cpp b/src/ros2_medkit_gateway/test/test_dto_contract.cpp index 81c9171f..45b4466c 100644 --- a/src/ros2_medkit_gateway/test/test_dto_contract.cpp +++ b/src/ros2_medkit_gateway/test/test_dto_contract.cpp @@ -19,6 +19,7 @@ #include #include "ros2_medkit_gateway/dto/contract.hpp" +#include "ros2_medkit_gateway/dto/entities.hpp" #include "ros2_medkit_gateway/dto/enums.hpp" #include "ros2_medkit_gateway/dto/errors.hpp" #include "ros2_medkit_gateway/dto/json_reader.hpp" @@ -352,3 +353,27 @@ TEST(XMedkitDtos, AllXMedkitSchemasAreObjects) { EXPECT_EQ(coll_schema.at("type"), "object"); EXPECT_EQ(ros2_schema.at("type"), "object"); } + +// ============================================================================= +// All-DTO registry round-trip test +// ============================================================================= + +template +void check_one() { + using D = std::tuple_element_t; + EXPECT_FALSE(dto::dto_name.empty()) << "DTO at index " << I; + const auto schema = dto::SchemaWriter::schema(); + EXPECT_EQ(schema.at("type"), "object") << dto::dto_name; + const D s = dto::make_sample(); + const auto j = dto::JsonWriter::write(s); + EXPECT_TRUE(dto::JsonReader::read(j).has_value()) << dto::dto_name; +} + +template +void check_all(std::index_sequence /*seq*/) { + (check_one(), ...); +} + +TEST(DtoRegistry, EveryRegisteredDtoRoundTrips) { + check_all(std::make_index_sequence>{}); +} From fe1bb1a70166a1447a80ddd887676e1bcb891324 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 09:26:05 +0200 Subject: [PATCH 11/51] refactor(gateway): merge DTO schemas into component_schemas() Include ros2_medkit_gateway/dto/registry.hpp and convert the static-const map in SchemaBuilder::component_schemas() to an IIFE so the DTO-generated schemas can be merged in at initialisation time. The DTO version wins on name collisions (currently only GenericError); hand-written factory calls are preserved intact so existing $ref targets remain valid until the per-domain migration tasks remove them. Store dto::collect_component_schemas() in a named local variable before iterating - calling .items() directly on the returned temporary creates a dangling reference inside the nlohmann iteration_proxy, which would cause invalid_iterator.214 at runtime. --- .../src/openapi/schema_builder.cpp | 142 ++++++++++-------- 1 file changed, 77 insertions(+), 65 deletions(-) diff --git a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp index 242c7403..58edf847 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp @@ -14,6 +14,8 @@ #include "schema_builder.hpp" +#include "ros2_medkit_gateway/dto/registry.hpp" + namespace ros2_medkit_gateway { namespace openapi { @@ -666,71 +668,81 @@ nlohmann::json SchemaBuilder::items_wrapper_ref(const std::string & schema_name) } const std::map & SchemaBuilder::component_schemas() { - static const std::map schemas = { - // Core types - {"GenericError", generic_error()}, - {"EntityDetail", entity_detail_schema()}, - {"EntityList", items_wrapper_ref("EntityDetail")}, - // Faults - {"FaultListItem", fault_list_item_schema()}, - {"FaultDetail", fault_detail_schema()}, - {"FaultList", items_wrapper_ref("FaultListItem")}, - // Configuration - {"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", log_entry_list_schema()}, - {"LogConfiguration", log_configuration_schema()}, - // Server - {"HealthStatus", health_schema()}, - {"VersionInfo", version_info_schema()}, - {"RootOverview", root_overview_schema()}, - // Data - {"DataItem", data_item_schema()}, - {"DataItemList", items_wrapper_ref("DataItem")}, - {"DataWriteRequest", data_write_request_schema()}, - // Operations - {"OperationItem", operation_item_schema()}, - {"OperationItemList", items_wrapper_ref("OperationItem")}, - {"OperationDetail", operation_detail_schema()}, - {"OperationExecution", operation_execution_schema()}, - {"OperationExecutionList", items_wrapper_ref("OperationExecution")}, - {"ExecutionUpdateRequest", execution_update_request_schema()}, - // 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")}, - {"AcquireLockRequest", acquire_lock_request_schema()}, - {"ExtendLockRequest", extend_lock_request_schema()}, - // Scripts - {"ScriptMetadata", script_metadata_schema()}, - {"ScriptMetadataList", items_wrapper_ref("ScriptMetadata")}, - {"ScriptUploadResponse", script_upload_response_schema()}, - {"ScriptExecution", script_execution_schema()}, - {"ScriptControlRequest", script_control_request_schema()}, - // Bulk Data - {"BulkDataCategoryList", bulk_data_category_list_schema()}, - {"BulkDataDescriptor", bulk_data_descriptor_schema()}, - {"BulkDataDescriptorList", items_wrapper_ref("BulkDataDescriptor")}, - // Updates - {"UpdateList", update_list_schema()}, - {"UpdateStatus", update_status_schema()}, - // Auth - {"AuthTokenResponse", auth_token_response_schema()}, - {"AuthCredentials", auth_credentials_schema()}, - }; + static const std::map schemas = []() { + std::map m = { + // Core types + {"GenericError", generic_error()}, + {"EntityDetail", entity_detail_schema()}, + {"EntityList", items_wrapper_ref("EntityDetail")}, + // Faults + {"FaultListItem", fault_list_item_schema()}, + {"FaultDetail", fault_detail_schema()}, + {"FaultList", items_wrapper_ref("FaultListItem")}, + // Configuration + {"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", log_entry_list_schema()}, + {"LogConfiguration", log_configuration_schema()}, + // Server + {"HealthStatus", health_schema()}, + {"VersionInfo", version_info_schema()}, + {"RootOverview", root_overview_schema()}, + // Data + {"DataItem", data_item_schema()}, + {"DataItemList", items_wrapper_ref("DataItem")}, + {"DataWriteRequest", data_write_request_schema()}, + // Operations + {"OperationItem", operation_item_schema()}, + {"OperationItemList", items_wrapper_ref("OperationItem")}, + {"OperationDetail", operation_detail_schema()}, + {"OperationExecution", operation_execution_schema()}, + {"OperationExecutionList", items_wrapper_ref("OperationExecution")}, + {"ExecutionUpdateRequest", execution_update_request_schema()}, + // 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")}, + {"AcquireLockRequest", acquire_lock_request_schema()}, + {"ExtendLockRequest", extend_lock_request_schema()}, + // Scripts + {"ScriptMetadata", script_metadata_schema()}, + {"ScriptMetadataList", items_wrapper_ref("ScriptMetadata")}, + {"ScriptUploadResponse", script_upload_response_schema()}, + {"ScriptExecution", script_execution_schema()}, + {"ScriptControlRequest", script_control_request_schema()}, + // Bulk Data + {"BulkDataCategoryList", bulk_data_category_list_schema()}, + {"BulkDataDescriptor", bulk_data_descriptor_schema()}, + {"BulkDataDescriptorList", items_wrapper_ref("BulkDataDescriptor")}, + // Updates + {"UpdateList", update_list_schema()}, + {"UpdateStatus", update_status_schema()}, + // Auth + {"AuthTokenResponse", auth_token_response_schema()}, + {"AuthCredentials", auth_credentials_schema()}, + }; + // DTO-contract schemas, merged on top of the hand-written factories. The DTO + // version wins on a name collision (currently only "GenericError"). Each + // domain migration task removes its now-redundant factory call later. + auto dto_schemas = dto::collect_component_schemas(); + for (auto & [name, schema] : dto_schemas.items()) { + m[name] = schema; + } + return m; + }(); return schemas; } From 1cca8b47857d511312b5278133d7dd3e249c9779 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 09:31:01 +0200 Subject: [PATCH 12/51] feat(gateway): add HandlerContext send_dto/parse_body --- .../http/handlers/handler_context.hpp | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handler_context.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handler_context.hpp index ab019d86..2f8d51e8 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handler_context.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handler_context.hpp @@ -35,6 +35,8 @@ #include "ros2_medkit_gateway/core/models/entity_capabilities.hpp" #include "ros2_medkit_gateway/core/models/entity_types.hpp" #include "ros2_medkit_gateway/core/models/thread_safe_entity_cache.hpp" +#include "ros2_medkit_gateway/dto/json_reader.hpp" +#include "ros2_medkit_gateway/dto/json_writer.hpp" namespace ros2_medkit_gateway { @@ -339,6 +341,38 @@ class HandlerContext { static std::vector resolve_app_host_fqns(const ThreadSafeEntityCache & cache, const std::vector & app_ids); + /// Serialize a DTO to a 200 JSON response. + template + static void send_dto(httplib::Response & res, const T & dto_obj) { + send_json(res, dto::JsonWriter::write(dto_obj)); + } + + /// Parse + validate a request body into a DTO. On failure sends a 400 + /// GenericError and returns nullopt. + template + std::optional parse_body(const httplib::Request & req, httplib::Response & res) { + nlohmann::json body; + try { + body = nlohmann::json::parse(req.body); + } catch (const nlohmann::json::parse_error &) { + send_error(res, 400, ERR_INVALID_REQUEST, "Request body is not valid JSON"); + return std::nullopt; + } + auto parsed = dto::JsonReader::read(body); + if (parsed.has_value()) { + return parsed.value(); + } + std::string detail; + for (const auto & e : parsed.error()) { + if (!detail.empty()) { + detail += "; "; + } + detail += e.field + ": " + e.message; + } + send_error(res, 400, ERR_INVALID_REQUEST, detail); + return std::nullopt; + } + private: GatewayNode * node_; CorsConfig cors_config_; From 992a1eb69128991af8ae83ac48bddecb87486aee Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 09:35:37 +0200 Subject: [PATCH 13/51] feat(gateway): add typed response/request_body route overloads Template overloads on RouteEntry delegate to the existing raw 3-arg overloads, generating a $ref to the DTO's components/schemas name via dto::dto_name. Non-template calls are unaffected (non-templates win overload resolution). --- .../src/openapi/route_registry.hpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/ros2_medkit_gateway/src/openapi/route_registry.hpp b/src/ros2_medkit_gateway/src/openapi/route_registry.hpp index 790439a0..ed28bb80 100644 --- a/src/ros2_medkit_gateway/src/openapi/route_registry.hpp +++ b/src/ros2_medkit_gateway/src/openapi/route_registry.hpp @@ -23,6 +23,8 @@ #include #include +#include "ros2_medkit_gateway/dto/contract.hpp" + namespace ros2_medkit_gateway { namespace openapi { @@ -38,6 +40,20 @@ class RouteEntry { RouteEntry & response(int status_code, const std::string & desc, const nlohmann::json & schema); RouteEntry & request_body(const std::string & desc, const nlohmann::json & schema, const std::string & content_type = "application/json"); + + /// Typed response: the schema is a $ref to the DTO's component schema. + template + RouteEntry & response(int status_code, const std::string & desc) { + return response(status_code, desc, + nlohmann::json{{"$ref", "#/components/schemas/" + std::string(dto::dto_name)}}); + } + + /// Typed request body: the schema is a $ref to the DTO's component schema. + template + RouteEntry & request_body(const std::string & desc) { + return request_body(desc, nlohmann::json{{"$ref", "#/components/schemas/" + std::string(dto::dto_name)}}); + } + 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, From 2ed66dda96c8556fa1145c71e3a4fe0a9d52d56d Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 09:41:50 +0200 Subject: [PATCH 14/51] refactor(gateway): migrate handle_list_areas to DTO contract Replace hand-built nlohmann::json + XMedkit fluent builder with typed dto::Collection and dto::XMedkitArea structs. Adds "type": "area" field per item (deliberate per issue #338 - entities must carry their type). Wire behavior otherwise preserved field-for-field. --- .../src/http/handlers/discovery_handlers.cpp | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp index 4541456c..e3fad913 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp @@ -22,6 +22,7 @@ #include "ros2_medkit_gateway/core/http/http_utils.hpp" #include "ros2_medkit_gateway/core/http/x_medkit.hpp" #include "ros2_medkit_gateway/core/plugins/plugin_manager.hpp" +#include "ros2_medkit_gateway/dto/entities.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" using json = nlohmann::json; @@ -98,7 +99,7 @@ void DiscoveryHandlers::handle_list_areas(const httplib::Request & req, httplib: const auto & cache = ctx_.node()->get_thread_safe_cache(); const auto areas = cache.get_areas(); - json items = json::array(); + dto::Collection response; for (const auto & area : areas) { // Subareas (with parent_area_id) are only visible via // GET /areas/{id}/subareas, not in the top-level list. @@ -106,33 +107,35 @@ void DiscoveryHandlers::handle_list_areas(const httplib::Request & req, httplib: continue; } - json area_item; - area_item["id"] = area.id; - area_item["name"] = area.name.empty() ? area.id : area.name; - area_item["href"] = "/api/v1/areas/" + area.id; + dto::AreaListItem item; + item.id = area.id; + item.name = area.name.empty() ? area.id : area.name; + item.href = "/api/v1/areas/" + area.id; + item.type = "area"; if (!area.description.empty()) { - area_item["description"] = area.description; + item.description = area.description; } if (!area.tags.empty()) { - area_item["tags"] = area.tags; + item.tags = area.tags; } - XMedkit ext; - ext.ros2_namespace(area.namespace_path); - area_item["x-medkit"] = ext.build(); + if (!area.namespace_path.empty()) { + dto::XMedkitRos2 ros2; + ros2.ns = area.namespace_path; + dto::XMedkitArea ext; + ext.ros2 = ros2; + item.x_medkit = ext; + } - items.push_back(area_item); + response.items.push_back(std::move(item)); } - json response; - response["items"] = items; - - XMedkit resp_ext; - resp_ext.add("total_count", items.size()); - response["x-medkit"] = resp_ext.build(); + dto::XMedkitCollection col_ext; + col_ext.total_count = response.items.size(); + response.x_medkit = col_ext; - HandlerContext::send_json(res, response); + HandlerContext::send_dto(res, response); } catch (const std::exception & e) { HandlerContext::send_error(res, 500, ERR_INTERNAL_ERROR, "Internal server error"); RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_list_areas: %s", e.what()); From f45ae97efc8d3c4eb6cc82b0fdb5a4eb0a8e4fac Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 09:57:28 +0200 Subject: [PATCH 15/51] refactor(gateway): migrate all discovery handlers to DTO contract Migrate handle_get_area, handle_list_components, handle_get_component, handle_list_apps, handle_get_app, handle_list_functions, and handle_get_function to typed dto:: structs with send_dto(). Entity list items now carry a required "type" discriminator field per issue #338 intent. All 2169 gateway unit tests pass. --- .../ros2_medkit_gateway/dto/entities.hpp | 6 +- .../ros2_medkit_gateway/dto/x_medkit.hpp | 28 +- .../src/http/handlers/discovery_handlers.cpp | 814 ++++++++++-------- 3 files changed, 463 insertions(+), 385 deletions(-) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/entities.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/entities.hpp index 24fc9924..44e5c7a4 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/entities.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/entities.hpp @@ -372,15 +372,19 @@ inline constexpr std::string_view dto_name = "FunctionDetail"; /// Generic collection wrapper used for every entity list response. /// The "items" array element type T is one of the *ListItem DTOs above. +/// The optional "links" member carries the free-form "_links" object emitted +/// by sub-collection handlers (subareas, contains, hosts, depends-on, etc.). template struct Collection { std::vector items; std::optional x_medkit; // wire key: "x-medkit" + std::optional links; // wire key: "_links" }; template inline constexpr auto dto_fields> = - std::make_tuple(field("items", &Collection::items), field("x-medkit", &Collection::x_medkit)); + std::make_tuple(field("items", &Collection::items), field("x-medkit", &Collection::x_medkit), + field("_links", &Collection::links)); // dto_name per concrete instantiation (no runtime string concatenation): template <> diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/x_medkit.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/x_medkit.hpp index 2a4f02d1..88995690 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/x_medkit.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/x_medkit.hpp @@ -58,20 +58,27 @@ inline constexpr std::string_view dto_name = "XMedkitRos2"; // XMedkitArea - x-medkit payload for Area entities. // // Populated by handle_get_area / handle_list_areas: -// ros2.namespace <- area.namespace_path -// parent_area_id <- area.parent_area_id (detail only, via ext.add()) -// contributors <- area.contributors (detail only) +// ros2.namespace <- area.namespace_path +// parent_area_id <- area.parent_area_id (detail only, via ext.add()) +// contributors <- area.contributors (detail only) +// +// Also used in sub-collection responses (handle_app_belongs_to): +// missing <- true when area reference cannot be resolved +// unresolved_component <- component_id that could not be resolved (belongs-to only) // --------------------------------------------------------------------------- struct XMedkitArea { std::optional ros2; std::optional parent_area_id; std::optional> contributors; + std::optional missing; // broken reference sentinel + std::optional unresolved_component; // belongs-to: unresolvable parent component id }; template <> inline constexpr auto dto_fields = std::make_tuple(field("ros2", &XMedkitArea::ros2), field("parent_area_id", &XMedkitArea::parent_area_id), - field("contributors", &XMedkitArea::contributors)); + field("contributors", &XMedkitArea::contributors), field("missing", &XMedkitArea::missing), + field("unresolved_component", &XMedkitArea::unresolved_component)); template <> inline constexpr std::string_view dto_name = "XMedkitArea"; @@ -92,6 +99,9 @@ inline constexpr std::string_view dto_name = "XMedkitArea"; // contributors <- comp.contributors // capabilities <- capabilities JSON array (via ext.add()) // +// Also used in sub-collection responses (depends-on, subcomponents, hosts, contains, etc.): +// missing <- true when component reference cannot be resolved +// // Note: "parentComponentId" uses camelCase on the wire per discovery_handlers.cpp. // "dependsOn" uses camelCase on the wire per discovery_handlers.cpp. // --------------------------------------------------------------------------- @@ -106,6 +116,7 @@ struct XMedkitComponent { std::optional description; std::optional> contributors; std::optional capabilities; // free-form JSON array + std::optional missing; // broken reference sentinel }; template <> @@ -114,7 +125,8 @@ inline constexpr auto dto_fields = std::make_tuple( field("type", &XMedkitComponent::type), field("parentComponentId", &XMedkitComponent::parent_component_id), field("dependsOn", &XMedkitComponent::depends_on), field("area", &XMedkitComponent::area), field("variant", &XMedkitComponent::variant), field("description", &XMedkitComponent::description), - field("contributors", &XMedkitComponent::contributors), field("capabilities", &XMedkitComponent::capabilities)); + field("contributors", &XMedkitComponent::contributors), field("capabilities", &XMedkitComponent::capabilities), + field("missing", &XMedkitComponent::missing)); template <> inline constexpr std::string_view dto_name = "XMedkitComponent"; @@ -128,6 +140,9 @@ inline constexpr std::string_view dto_name = "XMedkitComponent // ros2.node <- app.bound_fqn // component_id <- app.component_id // contributors <- app.contributors (detail only) +// +// Also used in sub-collection responses (depends-on, hosts, function-hosts): +// missing <- true when app reference cannot be resolved // --------------------------------------------------------------------------- struct XMedkitApp { std::optional ros2; @@ -135,13 +150,14 @@ struct XMedkitApp { std::optional is_online; std::optional component_id; std::optional> contributors; + std::optional missing; // broken reference sentinel }; template <> inline constexpr auto dto_fields = std::make_tuple(field("ros2", &XMedkitApp::ros2), field("source", &XMedkitApp::source), field("is_online", &XMedkitApp::is_online), field("component_id", &XMedkitApp::component_id), - field("contributors", &XMedkitApp::contributors)); + field("contributors", &XMedkitApp::contributors), field("missing", &XMedkitApp::missing)); template <> inline constexpr std::string_view dto_name = "XMedkitApp"; diff --git a/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp index e3fad913..fcfb5c32 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp @@ -17,10 +17,10 @@ #include #include +#include "ros2_medkit_gateway/core/discovery/models/common.hpp" #include "ros2_medkit_gateway/core/http/error_codes.hpp" #include "ros2_medkit_gateway/core/http/handlers/capability_builder.hpp" #include "ros2_medkit_gateway/core/http/http_utils.hpp" -#include "ros2_medkit_gateway/core/http/x_medkit.hpp" #include "ros2_medkit_gateway/core/plugins/plugin_manager.hpp" #include "ros2_medkit_gateway/dto/entities.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" @@ -172,51 +172,59 @@ void DiscoveryHandlers::handle_get_area(const httplib::Request & req, httplib::R const auto & area = *area_opt; - json response; - response["id"] = area.id; - response["name"] = area.name.empty() ? area.id : area.name; + dto::AreaDetail detail; + detail.id = area.id; + detail.name = area.name.empty() ? area.id : area.name; + detail.type = "area"; if (!area.description.empty()) { - response["description"] = area.description; + detail.description = area.description; } if (!area.tags.empty()) { - response["tags"] = area.tags; + detail.tags = area.tags; } std::string base_uri = "/api/v1/areas/" + area.id; - response["subareas"] = base_uri + "/subareas"; - response["components"] = base_uri + "/components"; - response["contains"] = base_uri + "/contains"; - response["data"] = base_uri + "/data"; - response["operations"] = base_uri + "/operations"; - response["configurations"] = base_uri + "/configurations"; - response["faults"] = base_uri + "/faults"; - response["logs"] = base_uri + "/logs"; - response["bulk-data"] = base_uri + "/bulk-data"; - response["triggers"] = base_uri + "/triggers"; + detail.subareas = base_uri + "/subareas"; + detail.components = base_uri + "/components"; + detail.contains = base_uri + "/contains"; + detail.data = base_uri + "/data"; + detail.operations = base_uri + "/operations"; + detail.configurations = base_uri + "/configurations"; + detail.faults = base_uri + "/faults"; + detail.logs = base_uri + "/logs"; + detail.bulk_data = base_uri + "/bulk-data"; + detail.triggers = base_uri + "/triggers"; using Cap = CapabilityBuilder::Capability; std::vector caps = {Cap::SUBAREAS, Cap::CONTAINS, Cap::DATA, Cap::OPERATIONS, Cap::CONFIGURATIONS, Cap::FAULTS, Cap::LOGS, Cap::BULK_DATA, Cap::TRIGGERS}; - response["capabilities"] = CapabilityBuilder::build_capabilities("areas", area.id, caps); - append_plugin_capabilities(response["capabilities"], "areas", area.id, SovdEntityType::AREA, ctx_.node()); + auto area_caps = CapabilityBuilder::build_capabilities("areas", area.id, caps); + append_plugin_capabilities(area_caps, "areas", area.id, SovdEntityType::AREA, ctx_.node()); + detail.capabilities = area_caps; LinksBuilder links; links.self("/api/v1/areas/" + area.id).collection("/api/v1/areas"); if (!area.parent_area_id.empty()) { links.parent("/api/v1/areas/" + area.parent_area_id); } - response["_links"] = links.build(); + detail.links = links.build(); - XMedkit ext; - ext.ros2_namespace(area.namespace_path); + dto::XMedkitArea x_medkit_area; + if (!area.namespace_path.empty()) { + dto::XMedkitRos2 ros2; + ros2.ns = area.namespace_path; + x_medkit_area.ros2 = ros2; + } if (!area.parent_area_id.empty()) { - ext.add("parent_area_id", area.parent_area_id); + x_medkit_area.parent_area_id = area.parent_area_id; + } + if (!area.contributors.empty()) { + x_medkit_area.contributors = sorted_contributors(area.contributors); } - ext.contributors(area.contributors); - response["x-medkit"] = ext.build(); + detail.x_medkit = x_medkit_area; - HandlerContext::send_json(res, response); + HandlerContext::send_dto(res, detail); } 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_get_area: %s", e.what()); @@ -247,37 +255,39 @@ void DiscoveryHandlers::handle_area_components(const httplib::Request & req, htt } const auto components = cache.get_components(); - json items = json::array(); + dto::Collection response; for (const auto & component : components) { if (component.area == area_id) { - json comp_item; - comp_item["id"] = component.id; - comp_item["name"] = component.name.empty() ? component.id : component.name; - comp_item["href"] = "/api/v1/components/" + component.id; + dto::ComponentListItem item; + item.id = component.id; + item.name = component.name.empty() ? component.id : component.name; + item.href = "/api/v1/components/" + component.id; + item.type = "component"; if (!component.description.empty()) { - comp_item["description"] = component.description; + item.description = component.description; } - XMedkit ext; - ext.source(component.source); + dto::XMedkitComponent x_medkit_comp; + if (!component.source.empty()) { + x_medkit_comp.source = component.source; + } if (!component.namespace_path.empty()) { - ext.ros2_namespace(component.namespace_path); + dto::XMedkitRos2 ros2; + ros2.ns = component.namespace_path; + x_medkit_comp.ros2 = ros2; } - comp_item["x-medkit"] = ext.build(); + item.x_medkit = x_medkit_comp; - items.push_back(comp_item); + response.items.push_back(std::move(item)); } } - json response; - response["items"] = items; - - XMedkit resp_ext; - resp_ext.add("total_count", items.size()); - response["x-medkit"] = resp_ext.build(); + dto::XMedkitCollection col_ext; + col_ext.total_count = response.items.size(); + response.x_medkit = col_ext; - HandlerContext::send_json(res, response); + HandlerContext::send_dto(res, response); } catch (const std::exception & e) { HandlerContext::send_error(res, 500, ERR_INTERNAL_ERROR, "Internal server error"); RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_area_components: %s", e.what()); @@ -316,7 +326,7 @@ void DiscoveryHandlers::handle_get_subareas(const httplib::Request & req, httpli // Use cache relationship index for subarea IDs, then look up each auto subarea_ids = cache.get_subareas(area_id); - json items = json::array(); + dto::Collection response; for (const auto & sub_id : subarea_ids) { auto subarea_opt = cache.get_area(sub_id); if (!subarea_opt) { @@ -324,31 +334,33 @@ void DiscoveryHandlers::handle_get_subareas(const httplib::Request & req, httpli } const auto & subarea = *subarea_opt; - json item; - item["id"] = subarea.id; - item["name"] = subarea.name.empty() ? subarea.id : subarea.name; - item["href"] = "/api/v1/areas/" + subarea.id; + dto::AreaListItem item; + item.id = subarea.id; + item.name = subarea.name.empty() ? subarea.id : subarea.name; + item.href = "/api/v1/areas/" + subarea.id; + item.type = "area"; - XMedkit ext; - ext.ros2_namespace(subarea.namespace_path); - item["x-medkit"] = ext.build(); + if (!subarea.namespace_path.empty()) { + dto::XMedkitRos2 ros2; + ros2.ns = subarea.namespace_path; + dto::XMedkitArea x_medkit_area; + x_medkit_area.ros2 = ros2; + item.x_medkit = x_medkit_area; + } - items.push_back(item); + response.items.push_back(std::move(item)); } - json response; - response["items"] = items; - - XMedkit resp_ext; - resp_ext.add("total_count", items.size()); - response["x-medkit"] = resp_ext.build(); + dto::XMedkitCollection col_ext; + col_ext.total_count = response.items.size(); + response.x_medkit = col_ext; json links; links["self"] = "/api/v1/areas/" + area_id + "/subareas"; links["parent"] = "/api/v1/areas/" + area_id; - response["_links"] = links; + response.links = links; - HandlerContext::send_json(res, response); + HandlerContext::send_dto(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_get_subareas: %s", e.what()); @@ -405,7 +417,7 @@ void DiscoveryHandlers::handle_get_contains(const httplib::Request & req, httpli area_queue.insert(area_queue.end(), sub_ids.begin(), sub_ids.end()); } - json items = json::array(); + dto::Collection response; for (const auto & comp_id : all_comp_ids) { auto comp_opt = cache.get_component(comp_id); if (!comp_opt) { @@ -413,34 +425,36 @@ void DiscoveryHandlers::handle_get_contains(const httplib::Request & req, httpli } const auto & comp = *comp_opt; - json item; - item["id"] = comp.id; - item["name"] = comp.name.empty() ? comp.id : comp.name; - item["href"] = "/api/v1/components/" + comp.id; + dto::ComponentListItem item; + item.id = comp.id; + item.name = comp.name.empty() ? comp.id : comp.name; + item.href = "/api/v1/components/" + comp.id; + item.type = "component"; - XMedkit ext; - ext.source(comp.source); + dto::XMedkitComponent x_medkit_comp; + if (!comp.source.empty()) { + x_medkit_comp.source = comp.source; + } if (!comp.namespace_path.empty()) { - ext.ros2_namespace(comp.namespace_path); + dto::XMedkitRos2 ros2; + ros2.ns = comp.namespace_path; + x_medkit_comp.ros2 = ros2; } - item["x-medkit"] = ext.build(); + item.x_medkit = x_medkit_comp; - items.push_back(item); + response.items.push_back(std::move(item)); } - json response; - response["items"] = items; - - XMedkit resp_ext; - resp_ext.add("total_count", items.size()); - response["x-medkit"] = resp_ext.build(); + dto::XMedkitCollection col_ext; + col_ext.total_count = response.items.size(); + response.x_medkit = col_ext; json links; links["self"] = "/api/v1/areas/" + area_id + "/contains"; links["area"] = "/api/v1/areas/" + area_id; - response["_links"] = links; + response.links = links; - HandlerContext::send_json(res, response); + HandlerContext::send_dto(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_get_contains: %s", e.what()); @@ -458,7 +472,7 @@ void DiscoveryHandlers::handle_list_components(const httplib::Request & req, htt const auto & cache = ctx_.node()->get_thread_safe_cache(); const auto components = cache.get_components(); - json items = json::array(); + dto::Collection response; for (const auto & component : components) { // Subcomponents (with parent_component_id) are only visible via // GET /components/{id}/subcomponents, not in the top-level list. @@ -466,39 +480,45 @@ void DiscoveryHandlers::handle_list_components(const httplib::Request & req, htt continue; } - json item; - item["id"] = component.id; - item["name"] = component.name.empty() ? component.id : component.name; - item["href"] = "/api/v1/components/" + component.id; + dto::ComponentListItem item; + item.id = component.id; + item.name = component.name.empty() ? component.id : component.name; + item.href = "/api/v1/components/" + component.id; + item.type = "component"; if (!component.description.empty()) { - item["description"] = component.description; + item.description = component.description; } if (!component.tags.empty()) { - item["tags"] = component.tags; + item.tags = component.tags; } - XMedkit ext; - ext.source(component.source); - if (!component.fqn.empty()) { - ext.ros2_node(component.fqn); + dto::XMedkitComponent x_medkit_comp; + if (!component.source.empty()) { + x_medkit_comp.source = component.source; } - if (!component.namespace_path.empty()) { - ext.ros2_namespace(component.namespace_path); + if (!component.fqn.empty()) { + dto::XMedkitRos2 ros2; + ros2.node = component.fqn; + if (!component.namespace_path.empty()) { + ros2.ns = component.namespace_path; + } + x_medkit_comp.ros2 = ros2; + } else if (!component.namespace_path.empty()) { + dto::XMedkitRos2 ros2; + ros2.ns = component.namespace_path; + x_medkit_comp.ros2 = ros2; } - item["x-medkit"] = ext.build(); + item.x_medkit = x_medkit_comp; - items.push_back(item); + response.items.push_back(std::move(item)); } - json response; - response["items"] = items; - - XMedkit resp_ext; - resp_ext.add("total_count", items.size()); - response["x-medkit"] = resp_ext.build(); + dto::XMedkitCollection col_ext; + col_ext.total_count = response.items.size(); + response.x_medkit = col_ext; - HandlerContext::send_json(res, response); + HandlerContext::send_dto(res, response); } catch (const std::exception & e) { HandlerContext::send_error(res, 500, ERR_INTERNAL_ERROR, "Internal server error"); RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_list_components: %s", e.what()); @@ -536,39 +556,40 @@ void DiscoveryHandlers::handle_get_component(const httplib::Request & req, httpl const auto & comp = *comp_opt; - json response; - response["id"] = comp.id; - response["name"] = comp.name.empty() ? comp.id : comp.name; + dto::ComponentDetail detail; + detail.id = comp.id; + detail.name = comp.name.empty() ? comp.id : comp.name; + detail.type = "component"; if (!comp.description.empty()) { - response["description"] = comp.description; + detail.description = comp.description; } if (!comp.tags.empty()) { - response["tags"] = comp.tags; + detail.tags = comp.tags; } std::string base = "/api/v1/components/" + comp.id; - response["data"] = base + "/data"; - response["operations"] = base + "/operations"; - response["configurations"] = base + "/configurations"; - response["faults"] = base + "/faults"; - response["subcomponents"] = base + "/subcomponents"; - response["hosts"] = base + "/hosts"; - response["logs"] = base + "/logs"; - response["bulk-data"] = base + "/bulk-data"; - response["cyclic-subscriptions"] = base + "/cyclic-subscriptions"; - response["triggers"] = base + "/triggers"; + detail.data = base + "/data"; + detail.operations = base + "/operations"; + detail.configurations = base + "/configurations"; + detail.faults = base + "/faults"; + detail.subcomponents = base + "/subcomponents"; + detail.hosts = base + "/hosts"; + detail.logs = base + "/logs"; + detail.bulk_data = base + "/bulk-data"; + detail.cyclic_subscriptions = base + "/cyclic-subscriptions"; + detail.triggers = base + "/triggers"; if (ctx_.node()->get_script_manager() && ctx_.node()->get_script_manager()->has_backend()) { - response["scripts"] = base + "/scripts"; + detail.scripts = base + "/scripts"; } if (!comp.depends_on.empty()) { - response["depends-on"] = base + "/depends-on"; + detail.depends_on = base + "/depends-on"; } if (!comp.area.empty()) { - response["belongs-to"] = "/api/v1/areas/" + comp.area; + detail.belongs_to = "/api/v1/areas/" + comp.area; } LinksBuilder links; @@ -579,35 +600,45 @@ void DiscoveryHandlers::handle_get_component(const httplib::Request & req, httpl if (!comp.parent_component_id.empty()) { links.parent("/api/v1/components/" + comp.parent_component_id); } - response["_links"] = links.build(); + detail.links = links.build(); - XMedkit ext; - ext.source(comp.source); - if (!comp.fqn.empty()) { - ext.ros2_node(comp.fqn); + dto::XMedkitComponent x_medkit_comp; + if (!comp.source.empty()) { + x_medkit_comp.source = comp.source; } - if (!comp.namespace_path.empty()) { - ext.ros2_namespace(comp.namespace_path); + if (!comp.fqn.empty()) { + dto::XMedkitRos2 ros2; + ros2.node = comp.fqn; + if (!comp.namespace_path.empty()) { + ros2.ns = comp.namespace_path; + } + x_medkit_comp.ros2 = ros2; + } else if (!comp.namespace_path.empty()) { + dto::XMedkitRos2 ros2; + ros2.ns = comp.namespace_path; + x_medkit_comp.ros2 = ros2; } if (!comp.type.empty()) { - ext.add("type", comp.type); + x_medkit_comp.type = comp.type; } if (!comp.parent_component_id.empty()) { - ext.add("parentComponentId", comp.parent_component_id); + x_medkit_comp.parent_component_id = comp.parent_component_id; } if (!comp.depends_on.empty()) { - ext.add("dependsOn", nlohmann::json(comp.depends_on)); + x_medkit_comp.depends_on = comp.depends_on; } if (!comp.area.empty()) { - ext.add("area", comp.area); + x_medkit_comp.area = comp.area; } if (!comp.variant.empty()) { - ext.add("variant", comp.variant); + x_medkit_comp.variant = comp.variant; } if (!comp.description.empty()) { - ext.add("description", comp.description); + x_medkit_comp.description = comp.description; + } + if (!comp.contributors.empty()) { + x_medkit_comp.contributors = sorted_contributors(comp.contributors); } - ext.contributors(comp.contributors); using Cap = CapabilityBuilder::Capability; std::vector caps = { @@ -626,11 +657,11 @@ void DiscoveryHandlers::handle_get_component(const httplib::Request & req, httpl append_plugin_capabilities(comp_caps, "components", comp.id, SovdEntityType::COMPONENT, ctx_.node()); // Capabilities at root level (SOVD standard) and in x-medkit (vendor extension for tools // that only read x-medkit). Apps don't duplicate because they have no vendor extensions block. - response["capabilities"] = comp_caps; - ext.add("capabilities", comp_caps); - response["x-medkit"] = ext.build(); + detail.capabilities = comp_caps; + x_medkit_comp.capabilities = comp_caps; + detail.x_medkit = x_medkit_comp; - HandlerContext::send_json(res, response); + HandlerContext::send_dto(res, detail); } 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_get_component: %s", e.what()); @@ -670,40 +701,42 @@ void DiscoveryHandlers::handle_get_subcomponents(const httplib::Request & req, h // Cache has no get_subcomponents(), so filter from all components const auto all_components = cache.get_components(); - json items = json::array(); + dto::Collection response; for (const auto & sub : all_components) { if (sub.parent_component_id != component_id) { continue; } - json item; - item["id"] = sub.id; - item["name"] = sub.name.empty() ? sub.id : sub.name; - item["href"] = "/api/v1/components/" + sub.id; + dto::ComponentListItem item; + item.id = sub.id; + item.name = sub.name.empty() ? sub.id : sub.name; + item.href = "/api/v1/components/" + sub.id; + item.type = "component"; - XMedkit ext; - ext.source(sub.source); + dto::XMedkitComponent x_medkit_comp; + if (!sub.source.empty()) { + x_medkit_comp.source = sub.source; + } if (!sub.namespace_path.empty()) { - ext.ros2_namespace(sub.namespace_path); + dto::XMedkitRos2 ros2; + ros2.ns = sub.namespace_path; + x_medkit_comp.ros2 = ros2; } - item["x-medkit"] = ext.build(); + item.x_medkit = x_medkit_comp; - items.push_back(item); + response.items.push_back(std::move(item)); } - json response; - response["items"] = items; - - XMedkit resp_ext; - resp_ext.add("total_count", items.size()); - response["x-medkit"] = resp_ext.build(); + dto::XMedkitCollection col_ext; + col_ext.total_count = response.items.size(); + response.x_medkit = col_ext; json links; links["self"] = "/api/v1/components/" + component_id + "/subcomponents"; links["parent"] = "/api/v1/components/" + component_id; - response["_links"] = links; + response.links = links; - HandlerContext::send_json(res, response); + HandlerContext::send_dto(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_get_subcomponents: %s", e.what()); @@ -744,7 +777,7 @@ void DiscoveryHandlers::handle_get_hosts(const httplib::Request & req, httplib:: // Use cache relationship index for app IDs, then look up each auto app_ids = cache.get_apps_for_component(component_id); - json items = json::array(); + dto::Collection response; for (const auto & aid : app_ids) { auto app_opt = cache.get_app(aid); if (!app_opt) { @@ -752,34 +785,37 @@ void DiscoveryHandlers::handle_get_hosts(const httplib::Request & req, httplib:: } const auto & app = *app_opt; - json item; - item["id"] = app.id; - item["name"] = app.name.empty() ? app.id : app.name; - item["href"] = "/api/v1/apps/" + app.id; + dto::AppListItem item; + item.id = app.id; + item.name = app.name.empty() ? app.id : app.name; + item.href = "/api/v1/apps/" + app.id; + item.type = "app"; - XMedkit ext; - ext.is_online(app.is_online).source(app.source); + dto::XMedkitApp x_medkit_app; + x_medkit_app.is_online = app.is_online; + if (!app.source.empty()) { + x_medkit_app.source = app.source; + } if (app.bound_fqn) { - ext.ros2_node(*app.bound_fqn); + dto::XMedkitRos2 ros2; + ros2.node = *app.bound_fqn; + x_medkit_app.ros2 = ros2; } - item["x-medkit"] = ext.build(); + item.x_medkit = x_medkit_app; - items.push_back(item); + response.items.push_back(std::move(item)); } - json response; - response["items"] = items; - - XMedkit resp_ext; - resp_ext.add("total_count", items.size()); - response["x-medkit"] = resp_ext.build(); + dto::XMedkitCollection col_ext; + col_ext.total_count = response.items.size(); + response.x_medkit = col_ext; json links; links["self"] = "/api/v1/components/" + component_id + "/hosts"; links["component"] = "/api/v1/components/" + component_id; - response["_links"] = links; + response.links = links; - HandlerContext::send_json(res, response); + HandlerContext::send_dto(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_get_hosts: %s", e.what()); @@ -818,44 +854,44 @@ void DiscoveryHandlers::handle_component_depends_on(const httplib::Request & req const auto & comp = *comp_opt; - json items = json::array(); + dto::Collection response; for (const auto & dep_id : comp.depends_on) { - json item; - item["id"] = dep_id; - item["href"] = "/api/v1/components/" + dep_id; + dto::ComponentListItem item; + item.id = dep_id; + item.href = "/api/v1/components/" + dep_id; + item.type = "component"; auto dep_opt = cache.get_component(dep_id); if (dep_opt) { - item["name"] = dep_opt->name.empty() ? dep_id : dep_opt->name; + item.name = dep_opt->name.empty() ? dep_id : dep_opt->name; - XMedkit ext; - ext.source(dep_opt->source); - item["x-medkit"] = ext.build(); + dto::XMedkitComponent x_medkit_comp; + if (!dep_opt->source.empty()) { + x_medkit_comp.source = dep_opt->source; + } + item.x_medkit = x_medkit_comp; } else { - item["name"] = dep_id; - XMedkit ext; - ext.add("missing", true); - item["x-medkit"] = ext.build(); + item.name = dep_id; + dto::XMedkitComponent x_medkit_comp; + x_medkit_comp.missing = true; + item.x_medkit = x_medkit_comp; RCLCPP_WARN(HandlerContext::logger(), "Component '%s' declares dependency on unknown component '%s'", component_id.c_str(), dep_id.c_str()); } - items.push_back(item); + response.items.push_back(std::move(item)); } - json response; - response["items"] = items; - - XMedkit resp_ext; - resp_ext.add("total_count", items.size()); - response["x-medkit"] = resp_ext.build(); + dto::XMedkitCollection col_ext; + col_ext.total_count = response.items.size(); + response.x_medkit = col_ext; json links; links["self"] = "/api/v1/components/" + component_id + "/depends-on"; links["component"] = "/api/v1/components/" + component_id; - response["_links"] = links; + response.links = links; - HandlerContext::send_json(res, response); + HandlerContext::send_dto(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_component_depends_on: %s", e.what()); @@ -874,41 +910,44 @@ void DiscoveryHandlers::handle_list_apps(const httplib::Request & req, httplib:: const auto & cache = ctx_.node()->get_thread_safe_cache(); auto apps = cache.get_apps(); - json items = json::array(); + dto::Collection response; for (const auto & app : apps) { - json app_item; - app_item["id"] = app.id; - app_item["name"] = app.name.empty() ? app.id : app.name; - app_item["href"] = "/api/v1/apps/" + app.id; + dto::AppListItem item; + item.id = app.id; + item.name = app.name.empty() ? app.id : app.name; + item.href = "/api/v1/apps/" + app.id; + item.type = "app"; if (!app.description.empty()) { - app_item["description"] = app.description; + item.description = app.description; } if (!app.tags.empty()) { - app_item["tags"] = app.tags; + item.tags = app.tags; } - XMedkit ext; - ext.source(app.source).is_online(app.is_online); + dto::XMedkitApp x_medkit_app; + if (!app.source.empty()) { + x_medkit_app.source = app.source; + } + x_medkit_app.is_online = app.is_online; if (!app.component_id.empty()) { - ext.component_id(app.component_id); + x_medkit_app.component_id = app.component_id; } if (app.bound_fqn) { - ext.ros2_node(*app.bound_fqn); + dto::XMedkitRos2 ros2; + ros2.node = *app.bound_fqn; + x_medkit_app.ros2 = ros2; } - app_item["x-medkit"] = ext.build(); + item.x_medkit = x_medkit_app; - items.push_back(app_item); + response.items.push_back(std::move(item)); } - json response; - response["items"] = items; - - XMedkit resp_ext; - resp_ext.add("total_count", items.size()); - response["x-medkit"] = resp_ext.build(); + dto::XMedkitCollection col_ext; + col_ext.total_count = response.items.size(); + response.x_medkit = col_ext; - HandlerContext::send_json(res, response); + HandlerContext::send_dto(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_list_apps: %s", e.what()); @@ -945,41 +984,42 @@ void DiscoveryHandlers::handle_get_app(const httplib::Request & req, httplib::Re const auto & app = *app_opt; - json response; - response["id"] = app.id; - response["name"] = app.name; + dto::AppDetail detail; + detail.id = app.id; + detail.name = app.name; + detail.type = "app"; if (!app.description.empty()) { - response["description"] = app.description; + detail.description = app.description; } if (!app.translation_id.empty()) { - response["translation_id"] = app.translation_id; + detail.translation_id = app.translation_id; } if (!app.tags.empty()) { - response["tags"] = app.tags; + detail.tags = app.tags; } std::string base_uri = "/api/v1/apps/" + app.id; - response["data"] = base_uri + "/data"; - response["operations"] = base_uri + "/operations"; - response["configurations"] = base_uri + "/configurations"; - response["faults"] = base_uri + "/faults"; - response["logs"] = base_uri + "/logs"; - response["bulk-data"] = base_uri + "/bulk-data"; - response["cyclic-subscriptions"] = base_uri + "/cyclic-subscriptions"; - response["triggers"] = base_uri + "/triggers"; + detail.data = base_uri + "/data"; + detail.operations = base_uri + "/operations"; + detail.configurations = base_uri + "/configurations"; + detail.faults = base_uri + "/faults"; + detail.logs = base_uri + "/logs"; + detail.bulk_data = base_uri + "/bulk-data"; + detail.cyclic_subscriptions = base_uri + "/cyclic-subscriptions"; + detail.triggers = base_uri + "/triggers"; if (ctx_.node()->get_script_manager() && ctx_.node()->get_script_manager()->has_backend()) { - response["scripts"] = base_uri + "/scripts"; + detail.scripts = base_uri + "/scripts"; } if (!app.component_id.empty()) { - response["is-located-on"] = "/api/v1/components/" + app.component_id; - response["belongs-to"] = base_uri + "/belongs-to"; + detail.is_located_on = "/api/v1/components/" + app.component_id; + detail.belongs_to = base_uri + "/belongs-to"; } if (!app.depends_on.empty()) { - response["depends-on"] = base_uri + "/depends-on"; + detail.depends_on = base_uri + "/depends-on"; } using Cap = CapabilityBuilder::Capability; @@ -1001,8 +1041,9 @@ void DiscoveryHandlers::handle_get_app(const httplib::Request & req, httplib::Re if (ctx_.node() && ctx_.node()->get_lock_manager()) { caps.push_back(Cap::LOCKS); } - response["capabilities"] = CapabilityBuilder::build_capabilities("apps", app.id, caps); - append_plugin_capabilities(response["capabilities"], "apps", app.id, SovdEntityType::APP, ctx_.node()); + auto app_caps = CapabilityBuilder::build_capabilities("apps", app.id, caps); + append_plugin_capabilities(app_caps, "apps", app.id, SovdEntityType::APP, ctx_.node()); + detail.capabilities = app_caps; LinksBuilder links; links.self("/api/v1/apps/" + app.id).collection("/api/v1/apps"); @@ -1010,28 +1051,36 @@ void DiscoveryHandlers::handle_get_app(const httplib::Request & req, httplib::Re links.add("is-located-on", "/api/v1/components/" + app.component_id); links.add("belongs-to", base_uri + "/belongs-to"); } - response["_links"] = links.build(); + auto links_json = links.build(); if (!app.depends_on.empty()) { json depends_links = json::array(); for (const auto & dep_id : app.depends_on) { depends_links.push_back("/api/v1/apps/" + dep_id); } - response["_links"]["depends-on"] = depends_links; + links_json["depends-on"] = depends_links; } + detail.links = links_json; - XMedkit ext; - ext.source(app.source).is_online(app.is_online); + dto::XMedkitApp x_medkit_app; + if (!app.source.empty()) { + x_medkit_app.source = app.source; + } + x_medkit_app.is_online = app.is_online; if (app.bound_fqn) { - ext.ros2_node(*app.bound_fqn); + dto::XMedkitRos2 ros2; + ros2.node = *app.bound_fqn; + x_medkit_app.ros2 = ros2; } if (!app.component_id.empty()) { - ext.component_id(app.component_id); + x_medkit_app.component_id = app.component_id; + } + if (!app.contributors.empty()) { + x_medkit_app.contributors = sorted_contributors(app.contributors); } - ext.contributors(app.contributors); - response["x-medkit"] = ext.build(); + detail.x_medkit = x_medkit_app; - HandlerContext::send_json(res, response); + HandlerContext::send_dto(res, detail); } 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_get_app: %s", e.what()); @@ -1076,43 +1125,44 @@ void DiscoveryHandlers::handle_app_depends_on(const httplib::Request & req, http return; } - json items = json::array(); + dto::Collection response; for (const auto & [dep_id, dep_opt] : snapshot.dependencies) { - json item; - item["id"] = dep_id; - item["href"] = "/api/v1/apps/" + dep_id; + dto::AppListItem item; + item.id = dep_id; + item.href = "/api/v1/apps/" + dep_id; + item.type = "app"; if (dep_opt) { - item["name"] = dep_opt->name.empty() ? dep_id : dep_opt->name; + item.name = dep_opt->name.empty() ? dep_id : dep_opt->name; - XMedkit ext; - ext.source(dep_opt->source).is_online(dep_opt->is_online); - item["x-medkit"] = ext.build(); + dto::XMedkitApp x_medkit_app; + if (!dep_opt->source.empty()) { + x_medkit_app.source = dep_opt->source; + } + x_medkit_app.is_online = dep_opt->is_online; + item.x_medkit = x_medkit_app; } else { - item["name"] = dep_id; - XMedkit ext; - ext.add("missing", true); - item["x-medkit"] = ext.build(); + item.name = dep_id; + dto::XMedkitApp x_medkit_app; + x_medkit_app.missing = true; + item.x_medkit = x_medkit_app; RCLCPP_WARN(HandlerContext::logger(), "App '%s' declares dependency on unknown app '%s'", app_id.c_str(), dep_id.c_str()); } - items.push_back(item); + response.items.push_back(std::move(item)); } - json response; - response["items"] = items; - - XMedkit resp_ext; - resp_ext.add("total_count", items.size()); - response["x-medkit"] = resp_ext.build(); + dto::XMedkitCollection col_ext; + col_ext.total_count = response.items.size(); + response.x_medkit = col_ext; json links; links["self"] = "/api/v1/apps/" + app_id + "/depends-on"; links["app"] = "/api/v1/apps/" + app_id; - response["_links"] = links; + response.links = links; - HandlerContext::send_json(res, response); + HandlerContext::send_dto(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_depends_on: %s", e.what()); @@ -1157,7 +1207,7 @@ void DiscoveryHandlers::handle_app_belongs_to(const httplib::Request & req, http return; } - json items = json::array(); + dto::Collection response; const auto & app = *snapshot.app; if (!app.component_id.empty()) { @@ -1165,53 +1215,52 @@ void DiscoveryHandlers::handle_app_belongs_to(const httplib::Request & req, http // Mirror handle_app_is_located_on: surface broken parent reference // with x-medkit.missing=true so HATEOAS clients can distinguish // 'no parent area' from 'manifest broken, component gone'. - json item; - item["id"] = ""; - item["name"] = ""; - item["href"] = ""; - XMedkit ext; - ext.add("missing", true); - ext.add("unresolved_component", app.component_id); - item["x-medkit"] = ext.build(); - items.push_back(item); + dto::AreaListItem item; + item.id = ""; + item.name = ""; + item.href = ""; + item.type = "area"; + dto::XMedkitArea x_medkit_area; + x_medkit_area.missing = true; + x_medkit_area.unresolved_component = app.component_id; + item.x_medkit = x_medkit_area; + response.items.push_back(std::move(item)); RCLCPP_WARN(HandlerContext::logger(), "App '%s' belongs-to unresolvable: parent component '%s' is unknown", app_id.c_str(), app.component_id.c_str()); } else if (!snapshot.component->area.empty()) { - const auto & area_id = snapshot.component->area; - json item; - item["id"] = area_id; - item["href"] = "/api/v1/areas/" + area_id; + const auto & area_id_ref = snapshot.component->area; + dto::AreaListItem item; + item.id = area_id_ref; + item.href = "/api/v1/areas/" + area_id_ref; + item.type = "area"; if (snapshot.area) { - item["name"] = snapshot.area->name.empty() ? area_id : snapshot.area->name; + item.name = snapshot.area->name.empty() ? area_id_ref : snapshot.area->name; } else { - item["name"] = area_id; - XMedkit ext; - ext.add("missing", true); - item["x-medkit"] = ext.build(); + item.name = area_id_ref; + dto::XMedkitArea x_medkit_area; + x_medkit_area.missing = true; + item.x_medkit = x_medkit_area; RCLCPP_WARN(HandlerContext::logger(), "App '%s' belongs to unknown area '%s' (via component '%s')", - app_id.c_str(), area_id.c_str(), app.component_id.c_str()); + app_id.c_str(), area_id_ref.c_str(), app.component_id.c_str()); } - items.push_back(item); + response.items.push_back(std::move(item)); } // If component resolves but has no area_id assigned, items stays // empty - that is a legitimate manifest configuration (component // without parent area), not a broken reference. } - json response; - response["items"] = items; - - XMedkit resp_ext; - resp_ext.add("total_count", items.size()); - response["x-medkit"] = resp_ext.build(); + dto::XMedkitCollection col_ext; + col_ext.total_count = response.items.size(); + response.x_medkit = col_ext; json links; links["self"] = "/api/v1/apps/" + app_id + "/belongs-to"; links["app"] = "/api/v1/apps/" + app_id; - response["_links"] = links; + response.links = links; - HandlerContext::send_json(res, response); + HandlerContext::send_dto(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_belongs_to: %s", e.what()); @@ -1251,45 +1300,44 @@ void DiscoveryHandlers::handle_app_is_located_on(const httplib::Request & req, h return; } - json items = json::array(); + dto::Collection response; const auto & app = *snapshot.app; if (!app.component_id.empty()) { if (snapshot.component) { - json item; - item["id"] = snapshot.component->id; - item["name"] = snapshot.component->name.empty() ? snapshot.component->id : snapshot.component->name; - item["href"] = "/api/v1/components/" + snapshot.component->id; - items.push_back(item); + dto::ComponentListItem item; + item.id = snapshot.component->id; + item.name = snapshot.component->name.empty() ? snapshot.component->id : snapshot.component->name; + item.href = "/api/v1/components/" + snapshot.component->id; + item.type = "component"; + response.items.push_back(std::move(item)); } else { - json item; - item["id"] = app.component_id; - item["name"] = app.component_id; - item["href"] = "/api/v1/components/" + app.component_id; + dto::ComponentListItem item; + item.id = app.component_id; + item.name = app.component_id; + item.href = "/api/v1/components/" + app.component_id; + item.type = "component"; - XMedkit ext; - ext.add("missing", true); - item["x-medkit"] = ext.build(); - items.push_back(item); + dto::XMedkitComponent x_medkit_comp; + x_medkit_comp.missing = true; + item.x_medkit = x_medkit_comp; + response.items.push_back(std::move(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(); + dto::XMedkitCollection col_ext; + col_ext.total_count = response.items.size(); + response.x_medkit = col_ext; json links; links["self"] = "/api/v1/apps/" + app_id + "/is-located-on"; links["app"] = "/api/v1/apps/" + app_id; - response["_links"] = links; + response.links = links; - HandlerContext::send_json(res, response); + HandlerContext::send_dto(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()); @@ -1308,35 +1356,35 @@ void DiscoveryHandlers::handle_list_functions(const httplib::Request & req, http const auto & cache = ctx_.node()->get_thread_safe_cache(); auto functions = cache.get_functions(); - json items = json::array(); + dto::Collection response; for (const auto & func : functions) { - json func_item; - func_item["id"] = func.id; - func_item["name"] = func.name.empty() ? func.id : func.name; - func_item["href"] = "/api/v1/functions/" + func.id; + dto::FunctionListItem item; + item.id = func.id; + item.name = func.name.empty() ? func.id : func.name; + item.href = "/api/v1/functions/" + func.id; + item.type = "function"; if (!func.description.empty()) { - func_item["description"] = func.description; + item.description = func.description; } if (!func.tags.empty()) { - func_item["tags"] = func.tags; + item.tags = func.tags; } - XMedkit ext; - ext.source(func.source); - func_item["x-medkit"] = ext.build(); + dto::XMedkitFunction x_medkit_func; + if (!func.source.empty()) { + x_medkit_func.source = func.source; + } + item.x_medkit = x_medkit_func; - items.push_back(func_item); + response.items.push_back(std::move(item)); } - json response; - response["items"] = items; - - XMedkit resp_ext; - resp_ext.add("total_count", functions.size()); - response["x-medkit"] = resp_ext.build(); + dto::XMedkitCollection col_ext; + col_ext.total_count = response.items.size(); + response.x_medkit = col_ext; - HandlerContext::send_json(res, response); + HandlerContext::send_dto(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_list_functions: %s", e.what()); @@ -1373,62 +1421,69 @@ void DiscoveryHandlers::handle_get_function(const httplib::Request & req, httpli const auto & func = *func_opt; - json response; - response["id"] = func.id; - response["name"] = func.name.empty() ? func.id : func.name; + dto::FunctionDetail detail; + detail.id = func.id; + detail.name = func.name.empty() ? func.id : func.name; + detail.type = "function"; if (!func.description.empty()) { - response["description"] = func.description; + detail.description = func.description; } if (!func.translation_id.empty()) { - response["translation_id"] = func.translation_id; + detail.translation_id = func.translation_id; } if (!func.tags.empty()) { - response["tags"] = func.tags; + detail.tags = func.tags; } std::string base_uri = "/api/v1/functions/" + func.id; - response["hosts"] = base_uri + "/hosts"; - response["data"] = base_uri + "/data"; - response["operations"] = base_uri + "/operations"; - response["configurations"] = base_uri + "/configurations"; - response["faults"] = base_uri + "/faults"; - response["logs"] = base_uri + "/logs"; - response["bulk-data"] = base_uri + "/bulk-data"; - response["x-medkit-graph"] = base_uri + "/x-medkit-graph"; - response["cyclic-subscriptions"] = base_uri + "/cyclic-subscriptions"; - response["triggers"] = base_uri + "/triggers"; + detail.hosts = base_uri + "/hosts"; + detail.data = base_uri + "/data"; + detail.operations = base_uri + "/operations"; + detail.configurations = base_uri + "/configurations"; + detail.faults = base_uri + "/faults"; + detail.logs = base_uri + "/logs"; + detail.bulk_data = base_uri + "/bulk-data"; + detail.x_medkit_graph = base_uri + "/x-medkit-graph"; + detail.cyclic_subscriptions = base_uri + "/cyclic-subscriptions"; + detail.triggers = base_uri + "/triggers"; using Cap = CapabilityBuilder::Capability; std::vector caps = {Cap::HOSTS, Cap::DATA, Cap::OPERATIONS, Cap::CONFIGURATIONS, Cap::FAULTS, Cap::LOGS, Cap::BULK_DATA, Cap::CYCLIC_SUBSCRIPTIONS, Cap::TRIGGERS}; - response["capabilities"] = CapabilityBuilder::build_capabilities("functions", func.id, caps); - append_plugin_capabilities(response["capabilities"], "functions", func.id, SovdEntityType::FUNCTION, ctx_.node()); + auto func_caps = CapabilityBuilder::build_capabilities("functions", func.id, caps); + append_plugin_capabilities(func_caps, "functions", func.id, SovdEntityType::FUNCTION, ctx_.node()); + detail.capabilities = func_caps; LinksBuilder links; links.self("/api/v1/functions/" + func.id).collection("/api/v1/functions"); - response["_links"] = links.build(); + auto links_json = links.build(); if (!func.depends_on.empty()) { json depends_links = json::array(); for (const auto & dep_id : func.depends_on) { depends_links.push_back("/api/v1/functions/" + dep_id); } - response["_links"]["depends-on"] = depends_links; + links_json["depends-on"] = depends_links; } + detail.links = links_json; - XMedkit ext; - ext.source(func.source); + dto::XMedkitFunction x_medkit_func; + if (!func.source.empty()) { + x_medkit_func.source = func.source; + } if (!func.hosts.empty()) { - ext.add("hosts", nlohmann::json(func.hosts)); + x_medkit_func.hosts = func.hosts; } if (!func.description.empty()) { - ext.add("description", func.description); + x_medkit_func.description = func.description; + } + if (!func.contributors.empty()) { + x_medkit_func.contributors = sorted_contributors(func.contributors); } - ext.contributors(func.contributors); - response["x-medkit"] = ext.build(); + detail.x_medkit = x_medkit_func; - HandlerContext::send_json(res, response); + HandlerContext::send_dto(res, detail); } 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_get_function: %s", e.what()); @@ -1467,39 +1522,42 @@ void DiscoveryHandlers::handle_function_hosts(const httplib::Request & req, http // Use the Function's hosts list directly (includes merged hosts from all peers) const auto & host_ids = func_opt->hosts; - json items = json::array(); + dto::Collection response; for (const auto & app_id : host_ids) { auto app_opt = cache.get_app(app_id); if (app_opt) { - json item; - item["id"] = app_opt->id; - item["name"] = app_opt->name.empty() ? app_opt->id : app_opt->name; - item["href"] = "/api/v1/apps/" + app_opt->id; - - XMedkit ext; - ext.is_online(app_opt->is_online).source(app_opt->source); + dto::AppListItem item; + item.id = app_opt->id; + item.name = app_opt->name.empty() ? app_opt->id : app_opt->name; + item.href = "/api/v1/apps/" + app_opt->id; + item.type = "app"; + + dto::XMedkitApp x_medkit_app; + x_medkit_app.is_online = app_opt->is_online; + if (!app_opt->source.empty()) { + x_medkit_app.source = app_opt->source; + } if (app_opt->bound_fqn) { - ext.ros2_node(*app_opt->bound_fqn); + dto::XMedkitRos2 ros2; + ros2.node = *app_opt->bound_fqn; + x_medkit_app.ros2 = ros2; } - item["x-medkit"] = ext.build(); + item.x_medkit = x_medkit_app; - items.push_back(item); + response.items.push_back(std::move(item)); } } - json response; - response["items"] = items; - - XMedkit resp_ext; - resp_ext.add("total_count", items.size()); - response["x-medkit"] = resp_ext.build(); + dto::XMedkitCollection col_ext; + col_ext.total_count = response.items.size(); + response.x_medkit = col_ext; json links; links["self"] = "/api/v1/functions/" + function_id + "/hosts"; links["function"] = "/api/v1/functions/" + function_id; - response["_links"] = links; + response.links = links; - HandlerContext::send_json(res, response); + HandlerContext::send_dto(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_function_hosts: %s", e.what()); From 2716e29c5d34d99f9d3491a3a0496d9f19fb973f Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 10:41:04 +0200 Subject: [PATCH 16/51] refactor(gateway): retype entity routes, drop entity schema factories Replace all EntityList/EntityDetail $ref usages with typed DTO schema names (AreaList, ComponentList, AppList, FunctionList, AreaDetail, ComponentDetail, AppDetail, FunctionDetail). Each top-level collection, entity detail, and sub-collection route now references the concrete DTO schema that matches what the handler actually emits. Delete SchemaBuilder::entity_detail_schema() and entity_list_schema() which are fully superseded by the DTO layer. Remove their entries from component_schemas() - the DTO-generated schemas win on all collisions. Add entity_type_to_list_name/detail_name helpers in path_builder.cpp so build_entity_collection/build_entity_detail emit $ref to the correct DTO schema for each entity keyword. Update test_schema_builder and test_path_builder: remove tests for the deleted factories and replace with assertions against the DTO registry. AllRefsResolveToRegisteredSchemas passes with zero dangling $ref. --- .../src/http/rest_server.cpp | 40 +++++++++--------- .../src/openapi/path_builder.cpp | 42 ++++++++++++++++++- .../src/openapi/schema_builder.cpp | 38 +---------------- .../src/openapi/schema_builder.hpp | 6 --- .../test/test_path_builder.cpp | 7 ++-- .../test/test_schema_builder.cpp | 42 +++++++++---------- 6 files changed, 86 insertions(+), 89 deletions(-) diff --git a/src/ros2_medkit_gateway/src/http/rest_server.cpp b/src/ros2_medkit_gateway/src/http/rest_server.cpp index 709cf6c1..465cefa6 100644 --- a/src/ros2_medkit_gateway/src/http/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/http/rest_server.cpp @@ -351,7 +351,7 @@ void RESTServer::setup_routes() { .tag("Discovery") .summary("List areas") .description("Lists all discovered areas in the system.") - .response(200, "Area list", SB::ref("EntityList")) + .response(200, "Area list", SB::ref("AreaList")) .operation_id("listAreas"); reg.get("/apps", @@ -361,7 +361,7 @@ void RESTServer::setup_routes() { .tag("Discovery") .summary("List apps") .description("Lists all discovered apps (ROS 2 nodes) in the system.") - .response(200, "App list", SB::ref("EntityList")) + .response(200, "App list", SB::ref("AppList")) .operation_id("listApps"); reg.get("/components", @@ -371,7 +371,7 @@ void RESTServer::setup_routes() { .tag("Discovery") .summary("List components") .description("Lists all discovered components in the system.") - .response(200, "Component list", SB::ref("EntityList")) + .response(200, "Component list", SB::ref("ComponentList")) .operation_id("listComponents"); reg.get("/functions", @@ -381,7 +381,7 @@ void RESTServer::setup_routes() { .tag("Discovery") .summary("List functions") .description("Lists all discovered functions in the system.") - .response(200, "Function list", SB::ref("EntityList")) + .response(200, "Function list", SB::ref("FunctionList")) .operation_id("listFunctions"); // === Per-entity-type resource routes === @@ -395,14 +395,16 @@ void RESTServer::setup_routes() { const char * type; const char * singular; HandlerFn detail_handler; + const char * collection_schema_name; // DTO name for Collection + const char * detail_schema_name; // DTO name for XxxDetail }; // clang-format off std::vector entity_types = { - {"areas", "area", [this](auto & req, auto & res) { discovery_handlers_->handle_get_area(req, res); }}, - {"components", "component", [this](auto & req, auto & res) { discovery_handlers_->handle_get_component(req, res); }}, - {"apps", "app", [this](auto & req, auto & res) { discovery_handlers_->handle_get_app(req, res); }}, - {"functions", "function", [this](auto & req, auto & res) { discovery_handlers_->handle_get_function(req, res); }}, + {"areas", "area", [this](auto & req, auto & res) { discovery_handlers_->handle_get_area(req, res); }, "AreaList", "AreaDetail"}, + {"components", "component", [this](auto & req, auto & res) { discovery_handlers_->handle_get_component(req, res); }, "ComponentList", "ComponentDetail"}, + {"apps", "app", [this](auto & req, auto & res) { discovery_handlers_->handle_get_app(req, res); }, "AppList", "AppDetail"}, + {"functions", "function", [this](auto & req, auto & res) { discovery_handlers_->handle_get_function(req, res); }, "FunctionList", "FunctionDetail"}, }; // clang-format on @@ -1064,7 +1066,7 @@ void RESTServer::setup_routes() { .tag("Discovery") .summary("List components in area") .description("Lists components belonging to this area.") - .response(200, "Component list", SB::ref("EntityList")) + .response(200, "Component list", SB::ref("ComponentList")) .operation_id("listAreaComponents"); reg.get(entity_path + "/subareas", @@ -1074,7 +1076,7 @@ void RESTServer::setup_routes() { .tag("Discovery") .summary("List subareas") .description("Lists subareas within this area.") - .response(200, "Subarea list", SB::ref("EntityList")) + .response(200, "Subarea list", SB::ref("AreaList")) .operation_id("listSubareas"); reg.get(entity_path + "/contains", @@ -1084,7 +1086,7 @@ void RESTServer::setup_routes() { .tag("Discovery") .summary("List entities contained in area") .description("Lists all entities contained in this area.") - .response(200, "Contained entities", SB::ref("EntityList")) + .response(200, "Contained entities", SB::ref("ComponentList")) .operation_id("listAreaContains"); } @@ -1096,7 +1098,7 @@ void RESTServer::setup_routes() { .tag("Discovery") .summary("List subcomponents") .description("Lists subcomponents of this component.") - .response(200, "Subcomponent list", SB::ref("EntityList")) + .response(200, "Subcomponent list", SB::ref("ComponentList")) .operation_id("listSubcomponents"); reg.get(entity_path + "/hosts", @@ -1106,7 +1108,7 @@ void RESTServer::setup_routes() { .tag("Discovery") .summary("List component hosts") .description("Lists apps hosted by this component.") - .response(200, "Host list", SB::ref("EntityList")) + .response(200, "Host list", SB::ref("AppList")) .operation_id("listComponentHosts"); reg.get(entity_path + "/depends-on", @@ -1116,7 +1118,7 @@ void RESTServer::setup_routes() { .tag("Discovery") .summary("List component dependencies") .description("Lists components this component depends on.") - .response(200, "Dependency list", SB::ref("EntityList")) + .response(200, "Dependency list", SB::ref("ComponentList")) .operation_id("listComponentDependencies"); } @@ -1128,7 +1130,7 @@ void RESTServer::setup_routes() { .tag("Discovery") .summary("Get app host component") .description("Returns the component hosting this app as a single-element collection.") - .response(200, "Host component(s)", SB::ref("EntityList")) + .response(200, "Host component(s)", SB::ref("ComponentList")) .operation_id("getAppHost"); reg.get(entity_path + "/belongs-to", @@ -1140,7 +1142,7 @@ void RESTServer::setup_routes() { .description( "Returns the area this app belongs to via its parent component, as a 0-or-1 element " "collection.") - .response(200, "Parent area", SB::ref("EntityList")) + .response(200, "Parent area", SB::ref("AreaList")) .operation_id("getAppArea"); reg.get(entity_path + "/depends-on", @@ -1150,7 +1152,7 @@ void RESTServer::setup_routes() { .tag("Discovery") .summary("List app dependencies") .description("Lists apps this app depends on.") - .response(200, "Dependency list", SB::ref("EntityList")) + .response(200, "Dependency list", SB::ref("AppList")) .operation_id("listAppDependencies"); } @@ -1162,7 +1164,7 @@ void RESTServer::setup_routes() { .tag("Discovery") .summary("List function hosts") .description("Lists components hosting this function.") - .response(200, "Host list", SB::ref("EntityList")) + .response(200, "Host list", SB::ref("AppList")) .operation_id("listFunctionHosts"); } @@ -1171,7 +1173,7 @@ void RESTServer::setup_routes() { .tag("Discovery") .summary(std::string("Get ") + et.singular + " details") .description(std::string("Returns ") + et.singular + " details with capabilities and resource collection URIs.") - .response(200, "Entity details with capabilities", SB::ref("EntityDetail")) + .response(200, "Entity details with capabilities", SB::ref(et.detail_schema_name)) .operation_id(std::string("get") + capitalize(et.singular)); } diff --git a/src/ros2_medkit_gateway/src/openapi/path_builder.cpp b/src/ros2_medkit_gateway/src/openapi/path_builder.cpp index e8fe93ad..0072cee0 100644 --- a/src/ros2_medkit_gateway/src/openapi/path_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/path_builder.cpp @@ -19,6 +19,42 @@ namespace ros2_medkit_gateway { namespace openapi { +namespace { +/// Map entity-type keyword (e.g. "areas") to its DTO collection schema name. +std::string entity_type_to_list_name(const std::string & entity_type) { + if (entity_type == "areas") { + return "AreaList"; + } + if (entity_type == "components") { + return "ComponentList"; + } + if (entity_type == "apps") { + return "AppList"; + } + if (entity_type == "functions") { + return "FunctionList"; + } + return "AreaList"; // safe fallback +} + +/// Map entity-type keyword (e.g. "areas") to its DTO detail schema name. +std::string entity_type_to_detail_name(const std::string & entity_type) { + if (entity_type == "areas") { + return "AreaDetail"; + } + if (entity_type == "components") { + return "ComponentDetail"; + } + if (entity_type == "apps") { + return "AppDetail"; + } + if (entity_type == "functions") { + return "FunctionDetail"; + } + return "AreaDetail"; // safe fallback +} +} // namespace + PathBuilder::PathBuilder(const SchemaBuilder & schema_builder, bool auth_enabled) : schema_builder_(schema_builder), auth_enabled_(auth_enabled) { } @@ -36,7 +72,8 @@ nlohmann::json PathBuilder::build_entity_collection(const std::string & entity_t get_op["description"] = "Returns the collection of " + entity_type + " entities."; get_op["parameters"] = build_query_params_for_collection(); get_op["responses"]["200"]["description"] = "Successful response"; - get_op["responses"]["200"]["content"]["application/json"]["schema"] = SchemaBuilder::entity_list_schema(); + get_op["responses"]["200"]["content"]["application/json"]["schema"] = + SchemaBuilder::ref(entity_type_to_list_name(entity_type)); // Merge error responses auto errors = error_responses(); @@ -71,7 +108,8 @@ nlohmann::json PathBuilder::build_entity_detail(const std::string & entity_type, nlohmann::json::array({build_path_param(singular + "_id", "The " + singular + " identifier")}); } get_op["responses"]["200"]["description"] = "Successful response"; - get_op["responses"]["200"]["content"]["application/json"]["schema"] = SchemaBuilder::entity_detail_schema(); + get_op["responses"]["200"]["content"]["application/json"]["schema"] = + SchemaBuilder::ref(entity_type_to_detail_name(entity_type)); auto errors = error_responses(); for (auto & [code, val] : 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 58edf847..77468fef 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp @@ -123,38 +123,6 @@ nlohmann::json SchemaBuilder::fault_list_schema() { return items_wrapper(fault_list_item_schema()); } -nlohmann::json SchemaBuilder::entity_detail_schema() { - // Aggregation provenance surfaced on every merged entity (x-medkit vendor - // extension). Present only when non-empty; ordering is stable ("local" - // first when present, then "peer:" entries alphabetically) so that - // clients and snapshot tests can rely on it. See design/aggregation.rst. - nlohmann::json x_medkit_schema = { - {"type", "object"}, - {"properties", - {{"contributors", - {{"type", "array"}, - {"items", {{"type", "string"}}}, - {"description", - "Aggregation provenance: 'local' and/or 'peer:' entries naming the sources that " - "contributed to this merged entity. Sorted with 'local' first and 'peer:*' entries " - "alphabetically. Present only when aggregation is active and the entity has at least " - "one known source."}}}}}, - {"additionalProperties", true}}; - - return {{"type", "object"}, - {"properties", - {{"id", {{"type", "string"}}}, - {"name", {{"type", "string"}}}, - {"type", {{"type", "string"}}}, - {"uri", {{"type", "string"}}}, - {"x-medkit", x_medkit_schema}}}, - {"required", {"id", "name"}}}; -} - -nlohmann::json SchemaBuilder::entity_list_schema() { - return items_wrapper(entity_detail_schema()); -} - nlohmann::json SchemaBuilder::items_wrapper(const nlohmann::json & item_schema) { return {{"type", "object"}, {"properties", {{"items", {{"type", "array"}, {"items", item_schema}}}}}, @@ -588,8 +556,8 @@ nlohmann::json SchemaBuilder::update_status_schema() { {"required", {"phase"}}}; // x-medkit is optional in the SOVD payload (clients may ignore vendor - // extensions; matches the convention in fault_detail_schema and - // entity_detail_schema). When the gateway DOES emit the x-medkit object, + // extensions; matches the convention in fault_detail_schema). + // When the gateway DOES emit the x-medkit object, // however, ``phase`` is mandatory inside it - that scope is enforced by // the inner ``required: {phase}`` above, NOT by listing x-medkit in the // parent's required list. The drift test in test_openapi_response_drift @@ -672,8 +640,6 @@ const std::map & SchemaBuilder::component_schemas() std::map m = { // Core types {"GenericError", generic_error()}, - {"EntityDetail", entity_detail_schema()}, - {"EntityList", items_wrapper_ref("EntityDetail")}, // Faults {"FaultListItem", fault_list_item_schema()}, {"FaultDetail", fault_detail_schema()}, diff --git a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp index 39f04034..433bcdfc 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp @@ -59,12 +59,6 @@ class SchemaBuilder { /// Fault list response schema (items wrapper around fault_list_item_schema) static nlohmann::json fault_list_schema(); - /// Single entity detail schema - static nlohmann::json entity_detail_schema(); - - /// Entity list response schema (items wrapper around entity_detail_schema) - static nlohmann::json entity_list_schema(); - /// Wrap an item schema in a SOVD collection response: {"items": [item_schema]} static nlohmann::json items_wrapper(const nlohmann::json & item_schema); diff --git a/src/ros2_medkit_gateway/test/test_path_builder.cpp b/src/ros2_medkit_gateway/test/test_path_builder.cpp index 6bedb2bd..fc81eaa0 100644 --- a/src/ros2_medkit_gateway/test/test_path_builder.cpp +++ b/src/ros2_medkit_gateway/test/test_path_builder.cpp @@ -47,12 +47,11 @@ TEST_F(PathBuilderTest, EntityCollectionHasGet) { } TEST_F(PathBuilderTest, EntityCollectionHasItemsSchema) { + // The response schema is now a $ref to the DTO-generated collection schema. auto result = path_builder_.build_entity_collection("components"); auto schema = result["get"]["responses"]["200"]["content"]["application/json"]["schema"]; - EXPECT_EQ(schema["type"], "object"); - ASSERT_TRUE(schema.contains("properties")); - ASSERT_TRUE(schema["properties"].contains("items")); - EXPECT_EQ(schema["properties"]["items"]["type"], "array"); + ASSERT_TRUE(schema.contains("$ref")) << "Entity collection schema should be a $ref to the DTO collection type"; + EXPECT_EQ(schema["$ref"], "#/components/schemas/ComponentList"); } TEST_F(PathBuilderTest, EntityCollectionHasQueryParams) { diff --git a/src/ros2_medkit_gateway/test/test_schema_builder.cpp b/src/ros2_medkit_gateway/test/test_schema_builder.cpp index 35078bcf..f4d58ebe 100644 --- a/src/ros2_medkit_gateway/test/test_schema_builder.cpp +++ b/src/ros2_medkit_gateway/test/test_schema_builder.cpp @@ -111,30 +111,28 @@ TEST(SchemaBuilderStaticTest, FaultListSchema) { EXPECT_TRUE(item_schema["properties"].contains("fault_code")); } -TEST(SchemaBuilderStaticTest, EntityDetailSchema) { - auto schema = SchemaBuilder::entity_detail_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("type")); - EXPECT_TRUE(schema["properties"].contains("uri")); - EXPECT_EQ(schema["properties"]["id"]["type"], "string"); - EXPECT_EQ(schema["properties"]["name"]["type"], "string"); -} +// AreaDetail / AreaList etc. are now emitted by the DTO layer (dto::collect_component_schemas). +// The AllRefsResolveToRegisteredSchemas consistency test at the bottom of this file +// covers correct $ref targets for all entity schemas. -TEST(SchemaBuilderStaticTest, EntityListSchema) { - auto schema = SchemaBuilder::entity_list_schema(); - EXPECT_EQ(schema["type"], "object"); - ASSERT_TRUE(schema.contains("properties")); - ASSERT_TRUE(schema["properties"].contains("items")); - EXPECT_EQ(schema["properties"]["items"]["type"], "array"); +TEST(SchemaBuilderStaticTest, EntityDetailRegisteredAsDto) { + // Entity detail schemas are generated by the DTO layer and must be present + // in component_schemas() under the typed names. + const auto & schemas = SchemaBuilder::component_schemas(); + EXPECT_TRUE(schemas.count("AreaDetail") > 0); + EXPECT_TRUE(schemas.count("ComponentDetail") > 0); + EXPECT_TRUE(schemas.count("AppDetail") > 0); + EXPECT_TRUE(schemas.count("FunctionDetail") > 0); +} - // Items should contain the entity detail schema - auto & item_schema = schema["properties"]["items"]["items"]; - EXPECT_EQ(item_schema["type"], "object"); - EXPECT_TRUE(item_schema["properties"].contains("id")); - EXPECT_TRUE(item_schema["properties"].contains("name")); +TEST(SchemaBuilderStaticTest, EntityListRegisteredAsDto) { + // Entity collection schemas are generated by the DTO layer and must be present + // in component_schemas() under the typed names. + const auto & schemas = SchemaBuilder::component_schemas(); + EXPECT_TRUE(schemas.count("AreaList") > 0); + EXPECT_TRUE(schemas.count("ComponentList") > 0); + EXPECT_TRUE(schemas.count("AppList") > 0); + EXPECT_TRUE(schemas.count("FunctionList") > 0); } TEST(SchemaBuilderStaticTest, ItemsWrapper) { From a6b10efb837ba9b0c9b5a5a2f01fffcad524b1f5 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 10:52:19 +0200 Subject: [PATCH 17/51] feat(gateway): add fault DTOs Add FaultListItem, FaultStatus, FaultItem, FaultEnvironmentData, FaultXMedkit, FaultDetail and Collection (named FaultList) DTOs matching the exact wire shapes of fault_msg_conversions.cpp and FaultHandlers::build_sovd_fault_response. Register all new types in AllDtos; EveryRegisteredDtoRoundTrips passes. --- .../ros2_medkit_gateway/dto/faults.hpp | 180 ++++++++++++++++++ .../ros2_medkit_gateway/dto/registry.hpp | 11 +- 2 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/faults.hpp diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/faults.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/faults.hpp new file mode 100644 index 00000000..f39f63a1 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/faults.hpp @@ -0,0 +1,180 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "ros2_medkit_gateway/dto/contract.hpp" +#include "ros2_medkit_gateway/dto/entities.hpp" +#include "ros2_medkit_gateway/dto/enums.hpp" + +namespace ros2_medkit_gateway { +namespace dto { + +// ============================================================================= +// FaultListItem - flat fault list item emitted by fault_msg_conversions.cpp +// (fault_to_json shape), wrapped in Collection for list endpoints. +// +// Wire keys (exact, from fault_msg_conversions.cpp): +// fault_code, severity, description, first_occurred, last_occurred, +// occurrence_count, status, reporting_sources, severity_label +// ============================================================================= +struct FaultListItem { + std::string fault_code; + int64_t severity{0}; + std::optional description; + std::optional first_occurred; + std::optional last_occurred; + std::optional occurrence_count; + std::string status; + std::optional> reporting_sources; + std::optional severity_label; // enum: INFO|WARN|ERROR|CRITICAL|UNKNOWN +}; + +template <> +inline constexpr auto dto_fields = std::make_tuple( + field("fault_code", &FaultListItem::fault_code), field("severity", &FaultListItem::severity), + field("description", &FaultListItem::description), field("first_occurred", &FaultListItem::first_occurred), + field("last_occurred", &FaultListItem::last_occurred), field("occurrence_count", &FaultListItem::occurrence_count), + field("status", &FaultListItem::status), field("reporting_sources", &FaultListItem::reporting_sources), + field("severity_label", &FaultListItem::severity_label)); + +template <> +inline constexpr std::string_view dto_name = "FaultListItem"; + +// ============================================================================= +// FaultStatus - SOVD status sub-object inside FaultDetail.item +// +// Wire keys (from build_status_object in fault_handlers.cpp): +// aggregatedStatus (required, enum), testFailed, confirmedDTC, pendingDTC +// ============================================================================= +struct FaultStatus { + std::string aggregated_status; // wire key: "aggregatedStatus" + std::optional test_failed; // wire key: "testFailed" + std::optional confirmed_dtc; // wire key: "confirmedDTC" + std::optional pending_dtc; // wire key: "pendingDTC" +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field_enum("aggregatedStatus", &FaultStatus::aggregated_status, kFaultAggregatedStatusValues), + field("testFailed", &FaultStatus::test_failed), field("confirmedDTC", &FaultStatus::confirmed_dtc), + field("pendingDTC", &FaultStatus::pending_dtc)); + +template <> +inline constexpr std::string_view dto_name = "FaultStatus"; + +// ============================================================================= +// FaultItem - SOVD "item" sub-object inside FaultDetail +// +// Wire keys (from build_sovd_fault_response): +// code (required), fault_name (optional), severity (required), status (required) +// ============================================================================= +struct FaultItem { + std::string code; + std::optional fault_name; + int64_t severity{0}; + FaultStatus status; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("code", &FaultItem::code), field("fault_name", &FaultItem::fault_name), + field("severity", &FaultItem::severity), field("status", &FaultItem::status)); + +template <> +inline constexpr std::string_view dto_name = "FaultItem"; + +// ============================================================================= +// FaultEnvironmentData - SOVD "environment_data" sub-object inside FaultDetail +// +// Wire keys (from build_sovd_fault_response): +// extended_data_records (free-form JSON object, optional), +// snapshots (free-form JSON array - discriminated freeze_frame|rosbag, optional) +// +// Both fields are genuinely free-form: snapshots carry a runtime type +// discriminator ("type"/"snapshot_type") with per-variant optional fields. +// ============================================================================= +struct FaultEnvironmentData { + std::optional extended_data_records; + std::optional snapshots; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("extended_data_records", &FaultEnvironmentData::extended_data_records), + field("snapshots", &FaultEnvironmentData::snapshots)); + +template <> +inline constexpr std::string_view dto_name = "FaultEnvironmentData"; + +// ============================================================================= +// FaultXMedkit - x-medkit vendor extension inside FaultDetail +// +// Wire keys (from build_sovd_fault_response): +// occurrence_count, reporting_sources, severity_label, status_raw +// ============================================================================= +struct FaultXMedkit { + std::optional occurrence_count; + std::optional> reporting_sources; + std::optional severity_label; + std::optional status_raw; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("occurrence_count", &FaultXMedkit::occurrence_count), + field("reporting_sources", &FaultXMedkit::reporting_sources), + field("severity_label", &FaultXMedkit::severity_label), + field("status_raw", &FaultXMedkit::status_raw)); + +template <> +inline constexpr std::string_view dto_name = "FaultXMedkit"; + +// ============================================================================= +// FaultDetail - SOVD nested fault detail response +// Emitted by FaultHandlers::handle_get_fault via build_sovd_fault_response. +// +// Wire keys: +// item (required), environment_data (required), x-medkit (optional) +// ============================================================================= +struct FaultDetail { + FaultItem item; + FaultEnvironmentData environment_data; + std::optional x_medkit; // wire key: "x-medkit" +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("item", &FaultDetail::item), field("environment_data", &FaultDetail::environment_data), + field("x-medkit", &FaultDetail::x_medkit)); + +template <> +inline constexpr std::string_view dto_name = "FaultDetail"; + +// ============================================================================= +// Collection - named "FaultList" +// ============================================================================= +template <> +inline constexpr std::string_view dto_name> = "FaultList"; + +} // namespace dto +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp index f2e58c80..8fe6a44d 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp @@ -22,6 +22,7 @@ #include "ros2_medkit_gateway/dto/contract.hpp" #include "ros2_medkit_gateway/dto/entities.hpp" #include "ros2_medkit_gateway/dto/errors.hpp" +#include "ros2_medkit_gateway/dto/faults.hpp" #include "ros2_medkit_gateway/dto/schema_writer.hpp" #include "ros2_medkit_gateway/dto/x_medkit.hpp" @@ -30,10 +31,12 @@ namespace dto { /// The single compile-time list of every named DTO. Each domain header /// (Phase 2/3) appends its types here. Order is irrelevant. -using AllDtos = std::tuple, - Collection, Collection, Collection>; +using AllDtos = + std::tuple, Collection, Collection, + Collection, FaultListItem, Collection, FaultStatus, FaultItem, + FaultEnvironmentData, FaultXMedkit, FaultDetail>; namespace detail { template From 8ed15865522168de4237b3ce2709c60b919a0c6c Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 11:07:36 +0200 Subject: [PATCH 18/51] refactor(gateway): migrate fault handlers to DTO contract - generic_error() now delegates to SchemaWriter::schema() (DTO is source of truth; deleted the redundant hand-built implementation) - build_sovd_fault_response() returns dto::FaultDetail instead of nlohmann::json; handle_get_fault calls HandlerContext::send_dto(res, detail) - path_builder: build_faults_collection emits ref("FaultList") instead of inline fault_list_schema() - Deleted fault_list_item_schema(), fault_detail_schema(), fault_list_schema() from SchemaBuilder; FaultListItem/FaultDetail/FaultList are now emitted by dto::collect_component_schemas() via AllDtos - Updated test_schema_builder, test_path_builder, test_fault_handlers to assert against DTO-generated schemas and the dto::FaultDetail struct - AllRefsResolveToRegisteredSchemas and EveryRegisteredDtoRoundTrips pass; 91/91 gateway unit tests green --- .../http/handlers/fault_handlers.hpp | 7 +- .../src/http/handlers/fault_handlers.cpp | 61 +++++++++----- .../src/openapi/path_builder.cpp | 2 +- .../src/openapi/schema_builder.cpp | 84 +------------------ .../src/openapi/schema_builder.hpp | 9 -- .../test/test_fault_handlers.cpp | 67 ++++++++++----- .../test/test_path_builder.cpp | 10 +-- .../test/test_schema_builder.cpp | 83 ++++++++++-------- 8 files changed, 141 insertions(+), 182 deletions(-) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/fault_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/fault_handlers.hpp index 014fdf0f..48c9bd0a 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/fault_handlers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/fault_handlers.hpp @@ -17,6 +17,7 @@ #include #include +#include "ros2_medkit_gateway/dto/faults.hpp" #include "ros2_medkit_gateway/http/handlers/handler_context.hpp" namespace ros2_medkit_gateway { @@ -101,9 +102,9 @@ class FaultHandlers { * @param entity_path Entity path used to construct rosbag bulk_data_uri. * @return SOVD-compliant JSON response */ - static nlohmann::json build_sovd_fault_response(const nlohmann::json & fault_json, - const nlohmann::json & env_data_json, - const std::string & entity_path); + static dto::FaultDetail build_sovd_fault_response(const nlohmann::json & fault_json, + const nlohmann::json & env_data_json, + const std::string & entity_path); private: HandlerContext & ctx_; diff --git a/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp index 4c60328a..99c6e02c 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp @@ -31,6 +31,8 @@ #include "ros2_medkit_gateway/core/http/x_medkit.hpp" #include "ros2_medkit_gateway/core/plugins/plugin_manager.hpp" #include "ros2_medkit_gateway/core/providers/fault_provider.hpp" +#include "ros2_medkit_gateway/dto/faults.hpp" +#include "ros2_medkit_gateway/dto/json_writer.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" using json = nlohmann::json; @@ -171,19 +173,28 @@ json extract_primary_value(const std::string & message_type, const json & full_d // The transport adapter performs ros2_medkit_msgs -> JSON translation; the // handler post-processes the intermediate snapshot shape (freeze_frame parse, // rosbag bulk_data_uri) using the entity_path it has at request time. -json FaultHandlers::build_sovd_fault_response(const json & fault_json, const json & env_data_json, - const std::string & entity_path) { - json response; - +dto::FaultDetail FaultHandlers::build_sovd_fault_response(const json & fault_json, const json & env_data_json, + const std::string & entity_path) { const std::string fault_code = fault_json.value("fault_code", ""); const std::string status = fault_json.value("status", ""); const uint8_t severity = fault_json.value("severity", static_cast(0)); - // === SOVD "item" structure === - response["item"] = {{"code", fault_code}, - {"fault_name", fault_json.value("description", "")}, - {"severity", severity}, - {"status", build_status_object(status)}}; + // === Build dto::FaultItem === + dto::FaultDetail detail; + + detail.item.code = fault_code; + const std::string description = fault_json.value("description", ""); + if (!description.empty()) { + detail.item.fault_name = description; + } + detail.item.severity = static_cast(severity); + + // Build FaultStatus from raw status string + auto status_obj = build_status_object(status); + detail.item.status.aggregated_status = status_obj.value("aggregatedStatus", "cleared"); + detail.item.status.test_failed = status_obj.value("testFailed", std::string{}); + detail.item.status.confirmed_dtc = status_obj.value("confirmedDTC", std::string{}); + detail.item.status.pending_dtc = status_obj.value("pendingDTC", std::string{}); // === SOVD "environment_data" === json snapshots = json::array(); @@ -251,27 +262,31 @@ json FaultHandlers::build_sovd_fault_response(const json & fault_json, const jso } } - json env_obj; if (env_data_json.contains("extended_data_records")) { - env_obj["extended_data_records"] = env_data_json["extended_data_records"]; + detail.environment_data.extended_data_records = env_data_json["extended_data_records"]; } else { - env_obj["extended_data_records"] = {{"first_occurrence", ""}, {"last_occurrence", ""}}; + detail.environment_data.extended_data_records = json{{"first_occurrence", ""}, {"last_occurrence", ""}}; } - env_obj["snapshots"] = snapshots; - response["environment_data"] = env_obj; + detail.environment_data.snapshots = snapshots; // === x-medkit extensions === - json reporting_sources = json::array(); + std::vector reporting_sources; if (fault_json.contains("reporting_sources") && fault_json["reporting_sources"].is_array()) { - reporting_sources = fault_json["reporting_sources"]; + for (const auto & src : fault_json["reporting_sources"]) { + reporting_sources.push_back(src.get()); + } } - response["x-medkit"] = {{"occurrence_count", fault_json.value("occurrence_count", static_cast(0))}, - {"reporting_sources", reporting_sources}, - {"severity_label", severity_to_label(severity)}, - {"status_raw", status}}; + dto::FaultXMedkit xm; + xm.occurrence_count = static_cast(fault_json.value("occurrence_count", static_cast(0))); + if (!reporting_sources.empty()) { + xm.reporting_sources = std::move(reporting_sources); + } + xm.severity_label = severity_to_label(severity); + xm.status_raw = status; + detail.x_medkit = std::move(xm); - return response; + return detail; } void FaultHandlers::handle_list_all_faults(const httplib::Request & req, httplib::Response & res) { @@ -684,9 +699,9 @@ void FaultHandlers::handle_get_fault(const httplib::Request & req, httplib::Resp // The transport handed back `result.data = { "fault": {...}, "environment_data": {...} }`. const auto & fault_json = result.data.value("fault", json::object()); const auto & env_data_json = result.data.value("environment_data", json::object()); - auto response = build_sovd_fault_response(fault_json, env_data_json, entity_path_info->entity_path); + auto detail = build_sovd_fault_response(fault_json, env_data_json, entity_path_info->entity_path); - HandlerContext::send_json(res, response); + HandlerContext::send_dto(res, detail); } else { // Check if it's a "not found" error if (result.error_message.find("not found") != std::string::npos || diff --git a/src/ros2_medkit_gateway/src/openapi/path_builder.cpp b/src/ros2_medkit_gateway/src/openapi/path_builder.cpp index 0072cee0..fa549f38 100644 --- a/src/ros2_medkit_gateway/src/openapi/path_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/path_builder.cpp @@ -376,7 +376,7 @@ nlohmann::json PathBuilder::build_faults_collection(const std::string & entity_p entity_path.empty() ? "Returns all faults." : "Returns all faults associated with this entity."; get_op["parameters"] = build_query_params_for_collection(); get_op["responses"]["200"]["description"] = "Successful response"; - get_op["responses"]["200"]["content"]["application/json"]["schema"] = SchemaBuilder::fault_list_schema(); + get_op["responses"]["200"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("FaultList"); auto errors = error_responses(); for (auto & [code, val] : 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 77468fef..6a49657c 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp @@ -44,83 +44,7 @@ nlohmann::json SchemaBuilder::from_ros_srv_response(const std::string & srv_type } nlohmann::json SchemaBuilder::generic_error() { - return { - {"type", "object"}, - {"properties", - {{"error_code", {{"type", "string"}}}, {"message", {{"type", "string"}}}, {"parameters", {{"type", "object"}}}}}, - {"required", {"error_code", "message"}}}; -} - -nlohmann::json SchemaBuilder::fault_list_item_schema() { - return { - {"type", "object"}, - {"properties", - {{"fault_code", {{"type", "string"}}}, - {"severity", {{"type", "integer"}, {"description", "Numeric severity level"}}}, - {"severity_label", {{"type", "string"}, {"enum", {"INFO", "WARN", "ERROR", "CRITICAL"}}}}, - {"description", {{"type", "string"}}}, - {"first_occurred", {{"type", "number"}, {"description", "Unix timestamp (seconds with nanosecond fraction)"}}}, - {"last_occurred", {{"type", "number"}, {"description", "Unix timestamp (seconds with nanosecond fraction)"}}}, - {"occurrence_count", {{"type", "integer"}}}, - {"status", {{"type", "string"}}}, - {"reporting_sources", {{"type", "array"}, {"items", {{"type", "string"}}}}}}}, - {"required", {"fault_code", "severity", "status"}}}; -} - -nlohmann::json SchemaBuilder::fault_detail_schema() { - // SOVD nested structure from FaultHandlers::build_sovd_fault_response - nlohmann::json status_schema = { - {"type", "object"}, - {"properties", - {{"aggregatedStatus", {{"type", "string"}, {"enum", {"active", "passive", "cleared"}}}}, - {"testFailed", {{"type", "string"}}}, - {"confirmedDTC", {{"type", "string"}}}, - {"pendingDTC", {{"type", "string"}}}}}, - {"required", {"aggregatedStatus"}}}; - - nlohmann::json item_schema = {{"type", "object"}, - {"properties", - {{"code", {{"type", "string"}}}, - {"fault_name", {{"type", "string"}}}, - {"severity", {{"type", "integer"}}}, - {"status", status_schema}}}, - {"required", {"code", "severity", "status"}}}; - - nlohmann::json snapshot_schema = {{"type", "object"}, - {"properties", - {{"type", {{"type", "string"}}}, - {"name", {{"type", "string"}}}, - {"data", {{"description", "Snapshot data"}}}, - {"bulk_data_uri", {{"type", "string"}}}, - {"size_bytes", {{"type", "integer"}}}, - {"duration_sec", {{"type", "number"}}}, - {"format", {{"type", "string"}}}, - {"x-medkit", {{"type", "object"}, {"additionalProperties", true}}}}}}; - - nlohmann::json env_data_schema = {{"type", "object"}, - {"properties", - {{"extended_data_records", - {{"type", "object"}, - {"properties", - {{"first_occurrence", {{"type", "string"}, {"format", "date-time"}}}, - {"last_occurrence", {{"type", "string"}, {"format", "date-time"}}}}}}}, - {"snapshots", {{"type", "array"}, {"items", snapshot_schema}}}}}}; - - nlohmann::json x_medkit_schema = {{"type", "object"}, - {"additionalProperties", true}, - {"properties", - {{"occurrence_count", {{"type", "integer"}}}, - {"reporting_sources", {{"type", "array"}, {"items", {{"type", "string"}}}}}, - {"severity_label", {{"type", "string"}}}, - {"status_raw", {{"type", "string"}}}}}}; - - return {{"type", "object"}, - {"properties", {{"item", item_schema}, {"environment_data", env_data_schema}, {"x-medkit", x_medkit_schema}}}, - {"required", {"item", "environment_data"}}}; -} - -nlohmann::json SchemaBuilder::fault_list_schema() { - return items_wrapper(fault_list_item_schema()); + return dto::SchemaWriter::schema(); } nlohmann::json SchemaBuilder::items_wrapper(const nlohmann::json & item_schema) { @@ -556,7 +480,7 @@ nlohmann::json SchemaBuilder::update_status_schema() { {"required", {"phase"}}}; // x-medkit is optional in the SOVD payload (clients may ignore vendor - // extensions; matches the convention in fault_detail_schema). + // extensions; same convention as FaultDetail x-medkit extension). // When the gateway DOES emit the x-medkit object, // however, ``phase`` is mandatory inside it - that scope is enforced by // the inner ``required: {phase}`` above, NOT by listing x-medkit in the @@ -640,10 +564,6 @@ const std::map & SchemaBuilder::component_schemas() std::map m = { // Core types {"GenericError", generic_error()}, - // Faults - {"FaultListItem", fault_list_item_schema()}, - {"FaultDetail", fault_detail_schema()}, - {"FaultList", items_wrapper_ref("FaultListItem")}, // Configuration {"ConfigurationMetaData", configuration_metadata_schema()}, {"ConfigurationMetaDataList", items_wrapper_ref("ConfigurationMetaData")}, diff --git a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp index 433bcdfc..2e9af8bd 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp @@ -50,15 +50,6 @@ class SchemaBuilder { /// SOVD GenericError schema (7.4.2) static nlohmann::json generic_error(); - /// Fault list item schema (flat format from ros2::conversions::fault_to_json) - static nlohmann::json fault_list_item_schema(); - - /// Fault detail schema (SOVD nested format from FaultHandlers::build_sovd_fault_response) - static nlohmann::json fault_detail_schema(); - - /// Fault list response schema (items wrapper around fault_list_item_schema) - static nlohmann::json fault_list_schema(); - /// Wrap an item schema in a SOVD collection response: {"items": [item_schema]} static nlohmann::json items_wrapper(const nlohmann::json & item_schema); diff --git a/src/ros2_medkit_gateway/test/test_fault_handlers.cpp b/src/ros2_medkit_gateway/test/test_fault_handlers.cpp index c0b5a5e0..5847be55 100644 --- a/src/ros2_medkit_gateway/test/test_fault_handlers.cpp +++ b/src/ros2_medkit_gateway/test/test_fault_handlers.cpp @@ -16,6 +16,8 @@ #include +#include "ros2_medkit_gateway/dto/faults.hpp" +#include "ros2_medkit_gateway/dto/json_writer.hpp" #include "ros2_medkit_gateway/http/handlers/fault_handlers.hpp" #include "ros2_medkit_gateway/ros2/conversions/fault_msg_conversions.hpp" #include "ros2_medkit_msgs/msg/environment_data.hpp" @@ -26,11 +28,15 @@ using json = nlohmann::json; using ros2_medkit_gateway::handlers::FaultHandlers; namespace conversions = ros2_medkit_gateway::ros2::conversions; +namespace dto = ros2_medkit_gateway::dto; // The handler now consumes JSON shaped by the transport adapter. These tests // drive that contract end-to-end by using the same conversions module the // adapter uses to translate ros2_medkit_msgs into JSON, then call the handler -// to produce the final SOVD response. +// to produce the final SOVD response (now a dto::FaultDetail struct). +// +// Tests convert the DTO back to JSON via JsonWriter for comparison so existing +// assertions can remain wire-level checks. class FaultHandlersTest : public ::testing::Test { protected: @@ -40,6 +46,11 @@ class FaultHandlersTest : public ::testing::Test { static json env_json(const ros2_medkit_msgs::msg::EnvironmentData & e) { return conversions::environment_data_to_json(e); } + /// Convert the DTO returned by build_sovd_fault_response to JSON for + /// wire-level assertions (keeps test bodies as close to original as possible). + static json to_json(const dto::FaultDetail & detail) { + return dto::JsonWriter::write(detail); + } }; // @verifies REQ_INTEROP_013 @@ -56,8 +67,8 @@ TEST_F(FaultHandlersTest, BuildSovdFaultResponseBasicFields) { env_data.extended_data_records.first_occurrence_ns = 1707044400000000000; env_data.extended_data_records.last_occurrence_ns = 1707044460000000000; - auto response = - FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/motor_controller"); + auto response = to_json( + FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/motor_controller")); // Verify item structure EXPECT_EQ(response["item"]["code"], "TEST_FAULT"); @@ -94,7 +105,8 @@ TEST_F(FaultHandlersTest, BuildSovdFaultResponseWithFreezeFrame) { freeze_frame.captured_at_ns = 1707044400000000000; env_data.snapshots.push_back(freeze_frame); - auto response = FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/motor"); + auto response = + to_json(FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/motor")); auto & snap = response["environment_data"]["snapshots"][0]; EXPECT_EQ(snap["type"], "freeze_frame"); @@ -150,8 +162,8 @@ TEST_F(FaultHandlersTest, BuildSovdFaultResponseWithRosbag) { rosbag.format = "mcap"; env_data.snapshots.push_back(rosbag); - auto response = - FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/motor_controller"); + auto response = to_json( + FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/motor_controller")); auto & snap = response["environment_data"]["snapshots"][0]; EXPECT_EQ(snap["type"], "rosbag"); @@ -172,8 +184,8 @@ TEST_F(FaultHandlersTest, BuildSovdFaultResponseNestedEntityPath) { rosbag.bulk_data_id = "NESTED_FAULT"; env_data.snapshots.push_back(rosbag); - auto response = FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), - "/areas/perception/subareas/lidar"); + auto response = to_json(FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), + "/areas/perception/subareas/lidar")); auto & snap = response["environment_data"]["snapshots"][0]; EXPECT_EQ(snap["bulk_data_uri"], "/areas/perception/subareas/lidar/bulk-data/rosbags/NESTED_FAULT"); @@ -187,7 +199,8 @@ TEST_F(FaultHandlersTest, BuildSovdFaultResponseStatusCleared) { ros2_medkit_msgs::msg::EnvironmentData env_data; - auto response = FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/test"); + auto response = + to_json(FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/test")); auto status = response["item"]["status"]; EXPECT_EQ(status["aggregatedStatus"], "cleared"); @@ -204,7 +217,8 @@ TEST_F(FaultHandlersTest, BuildSovdFaultResponseStatusPassive) { ros2_medkit_msgs::msg::EnvironmentData env_data; - auto response = FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/test"); + auto response = + to_json(FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/test")); auto status = response["item"]["status"]; EXPECT_EQ(status["aggregatedStatus"], "passive"); @@ -221,35 +235,40 @@ TEST_F(FaultHandlersTest, BuildSovdFaultResponseSeverityLabels) { { ros2_medkit_msgs::msg::Fault fault; fault.severity = ros2_medkit_msgs::msg::Fault::SEVERITY_INFO; - auto response = FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/test"); + auto response = + to_json(FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/test")); EXPECT_EQ(response["x-medkit"]["severity_label"], "INFO"); } // Test WARN (1) { ros2_medkit_msgs::msg::Fault fault; fault.severity = ros2_medkit_msgs::msg::Fault::SEVERITY_WARN; - auto response = FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/test"); + auto response = + to_json(FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/test")); EXPECT_EQ(response["x-medkit"]["severity_label"], "WARN"); } // Test ERROR (2) { ros2_medkit_msgs::msg::Fault fault; fault.severity = ros2_medkit_msgs::msg::Fault::SEVERITY_ERROR; - auto response = FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/test"); + auto response = + to_json(FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/test")); EXPECT_EQ(response["x-medkit"]["severity_label"], "ERROR"); } // Test CRITICAL (3) { ros2_medkit_msgs::msg::Fault fault; fault.severity = ros2_medkit_msgs::msg::Fault::SEVERITY_CRITICAL; - auto response = FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/test"); + auto response = + to_json(FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/test")); EXPECT_EQ(response["x-medkit"]["severity_label"], "CRITICAL"); } // Test UNKNOWN (255) - any value outside the SEVERITY_* range { ros2_medkit_msgs::msg::Fault fault; fault.severity = 255; - auto response = FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/test"); + auto response = + to_json(FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/test")); EXPECT_EQ(response["x-medkit"]["severity_label"], "UNKNOWN"); } } @@ -268,7 +287,8 @@ TEST_F(FaultHandlersTest, BuildSovdFaultResponseWithInvalidJson) { freeze_frame.message_type = "std_msgs/msg/String"; env_data.snapshots.push_back(freeze_frame); - auto response = FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/test"); + auto response = + to_json(FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/test")); auto & snap = response["environment_data"]["snapshots"][0]; EXPECT_EQ(snap["data"], "not valid json {"); // Raw data returned @@ -284,7 +304,8 @@ TEST_F(FaultHandlersTest, BuildSovdFaultResponseExtendedDataRecords) { env_data.extended_data_records.first_occurrence_ns = 1770458400000000000; // 2026-02-08 env_data.extended_data_records.last_occurrence_ns = 1770458460000000000; - auto response = FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/test"); + auto response = + to_json(FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/test")); auto & edr = response["environment_data"]["extended_data_records"]; std::string first = edr["first_occurrence"].get(); @@ -311,7 +332,8 @@ TEST_F(FaultHandlersTest, BuildSovdFaultResponsePrimaryValueExtraction) { env_data.snapshots.clear(); env_data.snapshots.push_back(snap); - auto response = FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/test"); + auto response = + to_json(FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/test")); EXPECT_DOUBLE_EQ(response["environment_data"]["snapshots"][0]["data"].get(), 42.5); } @@ -325,7 +347,8 @@ TEST_F(FaultHandlersTest, BuildSovdFaultResponsePrimaryValueExtraction) { env_data.snapshots.clear(); env_data.snapshots.push_back(snap); - auto response = FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/test"); + auto response = + to_json(FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/test")); auto data = response["environment_data"]["snapshots"][0]["data"]; EXPECT_EQ(data["foo"], "bar"); EXPECT_EQ(data["baz"], 123); @@ -340,7 +363,8 @@ TEST_F(FaultHandlersTest, BuildSovdFaultResponseMultipleSources) { ros2_medkit_msgs::msg::EnvironmentData env_data; - auto response = FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/test"); + auto response = + to_json(FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/apps/test")); auto sources = response["x-medkit"]["reporting_sources"]; ASSERT_EQ(sources.size(), 3); @@ -373,7 +397,8 @@ TEST_F(FaultHandlersTest, BuildSovdFaultResponseMixedSnapshots) { rosbag.format = "mcap"; env_data.snapshots.push_back(rosbag); - auto response = FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/components/motor"); + auto response = + to_json(FaultHandlers::build_sovd_fault_response(fault_json(fault), env_json(env_data), "/components/motor")); ASSERT_EQ(response["environment_data"]["snapshots"].size(), 2); diff --git a/src/ros2_medkit_gateway/test/test_path_builder.cpp b/src/ros2_medkit_gateway/test/test_path_builder.cpp index fc81eaa0..7942af71 100644 --- a/src/ros2_medkit_gateway/test/test_path_builder.cpp +++ b/src/ros2_medkit_gateway/test/test_path_builder.cpp @@ -325,14 +325,12 @@ TEST_F(PathBuilderTest, FaultsHasGetAndDelete) { } TEST_F(PathBuilderTest, FaultsGetReturnsFaultList) { + // build_faults_collection now emits a $ref to the registered FaultList DTO schema. auto result = path_builder_.build_faults_collection("apps/engine"); auto schema = result["get"]["responses"]["200"]["content"]["application/json"]["schema"]; - EXPECT_EQ(schema["type"], "object"); - ASSERT_TRUE(schema.contains("properties")); - ASSERT_TRUE(schema["properties"].contains("items")); - // Items should be fault objects - auto & item_schema = schema["properties"]["items"]["items"]; - EXPECT_TRUE(item_schema["properties"].contains("fault_code")); + // The schema is a $ref to FaultList, not an inline object. + ASSERT_TRUE(schema.contains("$ref")); + EXPECT_EQ(schema["$ref"], "#/components/schemas/FaultList"); } TEST_F(PathBuilderTest, FaultsDeleteReturns204) { diff --git a/src/ros2_medkit_gateway/test/test_schema_builder.cpp b/src/ros2_medkit_gateway/test/test_schema_builder.cpp index f4d58ebe..67d5da5d 100644 --- a/src/ros2_medkit_gateway/test/test_schema_builder.cpp +++ b/src/ros2_medkit_gateway/test/test_schema_builder.cpp @@ -28,6 +28,8 @@ using ros2_medkit_gateway::openapi::SchemaBuilder; // ============================================================================= TEST(SchemaBuilderStaticTest, GenericErrorSchema) { + // generic_error() now delegates to SchemaWriter::schema(). + // The DTO is the source of truth; the test asserts the DTO-generated shape. auto schema = SchemaBuilder::generic_error(); EXPECT_EQ(schema["type"], "object"); ASSERT_TRUE(schema.contains("properties")); @@ -36,17 +38,26 @@ TEST(SchemaBuilderStaticTest, GenericErrorSchema) { EXPECT_TRUE(schema["properties"].contains("parameters")); EXPECT_EQ(schema["properties"]["error_code"]["type"], "string"); EXPECT_EQ(schema["properties"]["message"]["type"], "string"); - EXPECT_EQ(schema["properties"]["parameters"]["type"], "object"); + // parameters is std::optional: schema_of = {} (free-form, no type constraint). + // This is intentional - parameters accepts any JSON object per the SOVD spec. + EXPECT_TRUE(schema["properties"]["parameters"].is_object()); - // Required fields + // Required fields: error_code and message; parameters is optional. ASSERT_TRUE(schema.contains("required")); auto required = schema["required"].get>(); EXPECT_NE(std::find(required.begin(), required.end(), "error_code"), required.end()); EXPECT_NE(std::find(required.begin(), required.end(), "message"), required.end()); + EXPECT_EQ(std::find(required.begin(), required.end(), "parameters"), required.end()); } -TEST(SchemaBuilderStaticTest, FaultListItemSchema) { - auto schema = SchemaBuilder::fault_list_item_schema(); +// Fault schemas are now emitted by the DTO layer (dto::collect_component_schemas). +// Tests assert against the registered schemas in component_schemas() instead of +// the deleted factory functions. + +TEST(SchemaBuilderStaticTest, FaultListItemRegisteredAsDto) { + const auto & schemas = SchemaBuilder::component_schemas(); + ASSERT_TRUE(schemas.count("FaultListItem") > 0) << "FaultListItem must be in component_schemas()"; + const auto & schema = schemas.at("FaultListItem"); EXPECT_EQ(schema["type"], "object"); ASSERT_TRUE(schema.contains("properties")); EXPECT_TRUE(schema["properties"].contains("fault_code")); @@ -62,53 +73,51 @@ TEST(SchemaBuilderStaticTest, FaultListItemSchema) { EXPECT_EQ(schema["properties"]["severity"]["type"], "integer"); EXPECT_EQ(schema["properties"]["status"]["type"], "string"); EXPECT_EQ(schema["properties"]["reporting_sources"]["type"], "array"); + + // Required fields: fault_code and status + ASSERT_TRUE(schema.contains("required")); + auto required = schema["required"].get>(); + EXPECT_NE(std::find(required.begin(), required.end(), "fault_code"), required.end()); + EXPECT_NE(std::find(required.begin(), required.end(), "status"), required.end()); } -TEST(SchemaBuilderStaticTest, FaultDetailSchema) { - auto schema = SchemaBuilder::fault_detail_schema(); +TEST(SchemaBuilderStaticTest, FaultDetailRegisteredAsDto) { + const auto & schemas = SchemaBuilder::component_schemas(); + ASSERT_TRUE(schemas.count("FaultDetail") > 0) << "FaultDetail must be in component_schemas()"; + const auto & schema = schemas.at("FaultDetail"); EXPECT_EQ(schema["type"], "object"); ASSERT_TRUE(schema.contains("properties")); - // SOVD nested structure + // SOVD nested structure: item, environment_data, x-medkit EXPECT_TRUE(schema["properties"].contains("item")); EXPECT_TRUE(schema["properties"].contains("environment_data")); EXPECT_TRUE(schema["properties"].contains("x-medkit")); - // item subfields - auto & item = schema["properties"]["item"]; - EXPECT_EQ(item["type"], "object"); - EXPECT_TRUE(item["properties"].contains("code")); - EXPECT_TRUE(item["properties"].contains("fault_name")); - EXPECT_TRUE(item["properties"].contains("severity")); - EXPECT_TRUE(item["properties"].contains("status")); - EXPECT_EQ(item["properties"]["status"]["type"], "object"); - EXPECT_TRUE(item["properties"]["status"]["properties"].contains("aggregatedStatus")); - - // environment_data subfields - auto & env = schema["properties"]["environment_data"]; - EXPECT_EQ(env["type"], "object"); - EXPECT_TRUE(env["properties"].contains("extended_data_records")); - EXPECT_TRUE(env["properties"].contains("snapshots")); - - // x-medkit subfields - auto & xmedkit = schema["properties"]["x-medkit"]; - EXPECT_EQ(xmedkit["type"], "object"); - EXPECT_TRUE(xmedkit["properties"].contains("occurrence_count")); - EXPECT_TRUE(xmedkit["properties"].contains("reporting_sources")); - EXPECT_TRUE(xmedkit["properties"].contains("severity_label")); - EXPECT_TRUE(xmedkit["properties"].contains("status_raw")); -} - -TEST(SchemaBuilderStaticTest, FaultListSchema) { - auto schema = SchemaBuilder::fault_list_schema(); + // item is a $ref to FaultItem + EXPECT_TRUE(schema["properties"]["item"].contains("$ref")); + + // environment_data is a $ref to FaultEnvironmentData + EXPECT_TRUE(schema["properties"]["environment_data"].contains("$ref")); + + // Required: item, environment_data + ASSERT_TRUE(schema.contains("required")); + auto required = schema["required"].get>(); + EXPECT_NE(std::find(required.begin(), required.end(), "item"), required.end()); + EXPECT_NE(std::find(required.begin(), required.end(), "environment_data"), required.end()); +} + +TEST(SchemaBuilderStaticTest, FaultListRegisteredAsDto) { + const auto & schemas = SchemaBuilder::component_schemas(); + ASSERT_TRUE(schemas.count("FaultList") > 0) << "FaultList must be in component_schemas()"; + const auto & schema = schemas.at("FaultList"); EXPECT_EQ(schema["type"], "object"); ASSERT_TRUE(schema.contains("properties")); ASSERT_TRUE(schema["properties"].contains("items")); EXPECT_EQ(schema["properties"]["items"]["type"], "array"); - // Items should contain the fault list item schema + // Items array references FaultListItem via $ref auto & item_schema = schema["properties"]["items"]["items"]; - EXPECT_EQ(item_schema["type"], "object"); - EXPECT_TRUE(item_schema["properties"].contains("fault_code")); + ASSERT_TRUE(item_schema.contains("$ref")); + EXPECT_EQ(item_schema["$ref"], "#/components/schemas/FaultListItem"); } // AreaDetail / AreaList etc. are now emitted by the DTO layer (dto::collect_component_schemas). From ab456383b14336d80060a3c6d3a83394cd6cbb90 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 11:20:04 +0200 Subject: [PATCH 19/51] feat(gateway): add operation DTOs --- .../ros2_medkit_gateway/dto/operations.hpp | 167 ++++++++++++++++++ .../ros2_medkit_gateway/dto/registry.hpp | 4 +- 2 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/operations.hpp diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/operations.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/operations.hpp new file mode 100644 index 00000000..45461581 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/operations.hpp @@ -0,0 +1,167 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include +#include +#include + +#include "ros2_medkit_gateway/dto/contract.hpp" +#include "ros2_medkit_gateway/dto/entities.hpp" +#include "ros2_medkit_gateway/dto/enums.hpp" +#include "ros2_medkit_gateway/dto/x_medkit.hpp" + +namespace ros2_medkit_gateway { +namespace dto { + +// ============================================================================= +// XMedkitOperationItem - x-medkit vendor extension on OperationExecution +// responses (handle_get_execution / handle_create_execution). +// +// Wire keys (from handle_get_execution XMedkit builder): +// goal_id - UUID of the tracked ROS 2 action goal +// ros2_status - raw ROS 2 goal status string (accepted|executing|canceling| +// succeeded|canceled|aborted|unknown) +// ros2 - nested ROS 2 metadata sub-object (action path + type) +// ============================================================================= +struct XMedkitOperationItem { + std::string goal_id; + std::optional ros2_status; // raw ROS 2 enum string + std::optional ros2; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("goal_id", &XMedkitOperationItem::goal_id), + field("ros2_status", &XMedkitOperationItem::ros2_status), + field("ros2", &XMedkitOperationItem::ros2)); + +template <> +inline constexpr std::string_view dto_name = "XMedkitOperationItem"; + +// ============================================================================= +// OperationItem - single item emitted in handle_list_operations "items" array +// and wrapped inside OperationDetail. +// +// Wire keys (from handle_list_operations / handle_get_operation): +// id - operation name (required) +// name - operation name (required) +// proximity_proof_required - bool (required, always false for ROS 2) +// asynchronous_execution - bool (required; false for services, true for actions) +// x-medkit - free-form JSON; built by XMedkit() fluent builder +// containing ros2.{service|action,type,kind}, +// entity_id, source, and optional type_info. +// Kept as nlohmann::json because type_info carries +// dynamic ROS IDL schemas. +// ============================================================================= +struct OperationItem { + std::string id; + std::string name; + bool proximity_proof_required{false}; + bool asynchronous_execution{false}; + std::optional x_medkit; // wire key: "x-medkit" +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("id", &OperationItem::id), field("name", &OperationItem::name), + field("proximity_proof_required", &OperationItem::proximity_proof_required), + field("asynchronous_execution", &OperationItem::asynchronous_execution), + field("x-medkit", &OperationItem::x_medkit)); + +template <> +inline constexpr std::string_view dto_name = "OperationItem"; + +// ============================================================================= +// OperationDetail - response shape for GET /{entity}/{id}/operations/{op_id}. +// +// Wire keys (from handle_get_operation): +// item - required; the full OperationItem for this operation +// ============================================================================= +struct OperationDetail { + OperationItem item; +}; + +template <> +inline constexpr auto dto_fields = std::make_tuple(field("item", &OperationDetail::item)); + +template <> +inline constexpr std::string_view dto_name = "OperationDetail"; + +// ============================================================================= +// OperationExecution - execution status response shape. +// +// Used by: +// - GET /{entity}/{id}/operations/{op_id}/executions/{exec_id} +// (handle_get_execution): status + capability + optional parameters + +// optional x-medkit (goal_id / ros2_status / ros2.action / ros2.type) +// - POST /{entity}/{id}/operations/{op_id}/executions (202 for actions): +// id + status +// - PUT /{entity}/{id}/operations/{op_id}/executions/{exec_id} (202 stop): +// id + status +// +// Wire keys: +// id - execution / goal UUID (optional; set on 202 responses) +// status - execution status enum (required) +// capability - control capability in context (optional; "execute" on GET) +// parameters - dynamic ROS payload (optional; last_feedback on GET) +// x-medkit - vendor extension (optional; goal tracking data on GET) +// ============================================================================= +struct OperationExecution { + std::optional id; + std::string status; // enum: pending|running|completed|failed + std::optional capability; + std::optional parameters; // free-form: last_feedback / result + std::optional x_medkit; // wire key: "x-medkit" +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("id", &OperationExecution::id), + field_enum("status", &OperationExecution::status, kOperationExecutionStatusValues), + field("capability", &OperationExecution::capability), + field("parameters", &OperationExecution::parameters), + field("x-medkit", &OperationExecution::x_medkit)); + +template <> +inline constexpr std::string_view dto_name = "OperationExecution"; + +// ============================================================================= +// ExecutionUpdateRequest - PUT request body for execution control. +// +// Wire keys (from handle_update_execution / execution_update_request_schema): +// capability - required; one of: stop | execute | freeze | reset +// ============================================================================= +struct ExecutionUpdateRequest { + std::string capability; // enum: stop|execute|freeze|reset +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field_enum("capability", &ExecutionUpdateRequest::capability, kExecutionCapabilityValues)); + +template <> +inline constexpr std::string_view dto_name = "ExecutionUpdateRequest"; + +// ============================================================================= +// Collection - named "OperationList" +// ============================================================================= +template <> +inline constexpr std::string_view dto_name> = "OperationList"; + +} // namespace dto +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp index 8fe6a44d..7fa62a1b 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp @@ -23,6 +23,7 @@ #include "ros2_medkit_gateway/dto/entities.hpp" #include "ros2_medkit_gateway/dto/errors.hpp" #include "ros2_medkit_gateway/dto/faults.hpp" +#include "ros2_medkit_gateway/dto/operations.hpp" #include "ros2_medkit_gateway/dto/schema_writer.hpp" #include "ros2_medkit_gateway/dto/x_medkit.hpp" @@ -36,7 +37,8 @@ using AllDtos = AreaListItem, AreaDetail, ComponentListItem, ComponentDetail, AppListItem, AppDetail, FunctionListItem, FunctionDetail, Collection, Collection, Collection, Collection, FaultListItem, Collection, FaultStatus, FaultItem, - FaultEnvironmentData, FaultXMedkit, FaultDetail>; + FaultEnvironmentData, FaultXMedkit, FaultDetail, XMedkitOperationItem, OperationItem, + Collection, OperationDetail, OperationExecution, ExecutionUpdateRequest>; namespace detail { template From 8a166a28d94b78af0842cc5749218a8478907d2b Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 11:32:18 +0200 Subject: [PATCH 20/51] refactor(gateway): migrate operation handlers to DTO contract --- .../core/http/fan_out_helpers.hpp | 41 ++++ .../ros2_medkit_gateway/dto/operations.hpp | 59 +++-- .../ros2_medkit_gateway/dto/registry.hpp | 4 +- .../src/http/handlers/operation_handlers.cpp | 211 +++++++++--------- .../src/http/rest_server.cpp | 2 +- .../src/openapi/path_builder.cpp | 5 +- .../src/openapi/schema_builder.cpp | 44 +--- .../src/openapi/schema_builder.hpp | 12 - .../test/test_operation_handlers.cpp | 4 +- .../test/test_path_builder.cpp | 9 +- .../test/test_schema_builder.cpp | 20 +- 11 files changed, 220 insertions(+), 191 deletions(-) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/fan_out_helpers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/fan_out_helpers.hpp index 406f96a6..a7856549 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/fan_out_helpers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/fan_out_helpers.hpp @@ -149,4 +149,45 @@ inline void merge_peer_items(AggregationManager * agg, const httplib::Request & } } +/// Overload for handlers that have been migrated off the XMedkit fluent +/// builder. The partial/failed_peers aggregation metadata is written +/// directly into `ext_json` (a JSON object) instead of going through XMedkit. +inline void merge_peer_items(AggregationManager * agg, const httplib::Request & req, nlohmann::json & result, + nlohmann::json & ext_json) { + if (agg == nullptr) { + return; + } + if (req.has_header("X-Medkit-No-Fan-Out")) { + return; + } + if (agg->healthy_peer_count() == 0) { + return; + } + std::optional> contributors_buffer; + const std::vector * target_peers = nullptr; + if (auto entity_id = extract_entity_id_for_fan_out(req.path); entity_id.has_value()) { + contributors_buffer = agg->get_peer_contributors(*entity_id); + if (contributors_buffer->empty()) { + return; + } + target_peers = &contributors_buffer.value(); + } + auto fan_path = build_fan_out_path(req); + auto fan_result = agg->fan_out_get(fan_path, req.get_header_value("Authorization"), target_peers); + if (fan_result.merged_items.is_array() && !fan_result.merged_items.empty()) { + if (!result.contains("items") || !result["items"].is_array()) { + result["items"] = nlohmann::json::array(); + } + for (const auto & item : fan_result.merged_items) { + if (item.is_object()) { + result["items"].push_back(item); + } + } + } + if (fan_result.is_partial) { + ext_json["partial"] = true; + ext_json["failed_peers"] = fan_result.failed_peers; + } +} + } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/operations.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/operations.hpp index 45461581..6569f98e 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/operations.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/operations.hpp @@ -29,8 +29,38 @@ namespace ros2_medkit_gateway { namespace dto { // ============================================================================= -// XMedkitOperationItem - x-medkit vendor extension on OperationExecution -// responses (handle_get_execution / handle_create_execution). +// XMedkitOperationItem - x-medkit vendor extension on OperationItem responses +// (handle_list_operations / handle_get_operation). +// +// Wire keys (from XMedkit builder in those handlers): +// ros2 - nested ROS 2 metadata sub-object (service|action path, type, +// kind) +// entity_id - SOVD entity the operation belongs to +// source - always "ros2_medkit_gateway" +// type_info - optional dynamic ROS IDL schema JSON (request/response for +// services; goal/result/feedback for actions); kept as +// nlohmann::json because the structure is runtime-determined +// by type introspection and cannot be statically typed +// ============================================================================= +struct XMedkitOperationItem { + std::optional ros2; + std::optional entity_id; + std::optional source; + std::optional type_info; // free-form: dynamic ROS IDL schemas +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("ros2", &XMedkitOperationItem::ros2), field("entity_id", &XMedkitOperationItem::entity_id), + field("source", &XMedkitOperationItem::source), + field("type_info", &XMedkitOperationItem::type_info)); + +template <> +inline constexpr std::string_view dto_name = "XMedkitOperationItem"; + +// ============================================================================= +// XMedkitOperationExecution - x-medkit vendor extension on OperationExecution +// responses (handle_get_execution). // // Wire keys (from handle_get_execution XMedkit builder): // goal_id - UUID of the tracked ROS 2 action goal @@ -38,20 +68,20 @@ namespace dto { // succeeded|canceled|aborted|unknown) // ros2 - nested ROS 2 metadata sub-object (action path + type) // ============================================================================= -struct XMedkitOperationItem { +struct XMedkitOperationExecution { std::string goal_id; std::optional ros2_status; // raw ROS 2 enum string std::optional ros2; }; template <> -inline constexpr auto dto_fields = - std::make_tuple(field("goal_id", &XMedkitOperationItem::goal_id), - field("ros2_status", &XMedkitOperationItem::ros2_status), - field("ros2", &XMedkitOperationItem::ros2)); +inline constexpr auto dto_fields = + std::make_tuple(field("goal_id", &XMedkitOperationExecution::goal_id), + field("ros2_status", &XMedkitOperationExecution::ros2_status), + field("ros2", &XMedkitOperationExecution::ros2)); template <> -inline constexpr std::string_view dto_name = "XMedkitOperationItem"; +inline constexpr std::string_view dto_name = "XMedkitOperationExecution"; // ============================================================================= // OperationItem - single item emitted in handle_list_operations "items" array @@ -62,18 +92,15 @@ inline constexpr std::string_view dto_name = "XMedkitOpera // name - operation name (required) // proximity_proof_required - bool (required, always false for ROS 2) // asynchronous_execution - bool (required; false for services, true for actions) -// x-medkit - free-form JSON; built by XMedkit() fluent builder -// containing ros2.{service|action,type,kind}, -// entity_id, source, and optional type_info. -// Kept as nlohmann::json because type_info carries -// dynamic ROS IDL schemas. +// x-medkit - typed vendor extension; carries ros2.{service|action, +// type,kind}, entity_id, source, and optional type_info // ============================================================================= struct OperationItem { std::string id; std::string name; bool proximity_proof_required{false}; bool asynchronous_execution{false}; - std::optional x_medkit; // wire key: "x-medkit" + std::optional x_medkit; // wire key: "x-medkit" }; template <> @@ -125,8 +152,8 @@ struct OperationExecution { std::optional id; std::string status; // enum: pending|running|completed|failed std::optional capability; - std::optional parameters; // free-form: last_feedback / result - std::optional x_medkit; // wire key: "x-medkit" + std::optional parameters; // free-form: last_feedback / result + std::optional x_medkit; // wire key: "x-medkit" }; template <> diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp index 7fa62a1b..a898146f 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp @@ -37,8 +37,8 @@ using AllDtos = AreaListItem, AreaDetail, ComponentListItem, ComponentDetail, AppListItem, AppDetail, FunctionListItem, FunctionDetail, Collection, Collection, Collection, Collection, FaultListItem, Collection, FaultStatus, FaultItem, - FaultEnvironmentData, FaultXMedkit, FaultDetail, XMedkitOperationItem, OperationItem, - Collection, OperationDetail, OperationExecution, ExecutionUpdateRequest>; + FaultEnvironmentData, FaultXMedkit, FaultDetail, XMedkitOperationItem, XMedkitOperationExecution, + OperationItem, Collection, OperationDetail, OperationExecution, ExecutionUpdateRequest>; namespace detail { template diff --git a/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp index d3b408a4..4dff80fa 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp @@ -19,10 +19,11 @@ #include "ros2_medkit_gateway/core/http/error_codes.hpp" #include "ros2_medkit_gateway/core/http/fan_out_helpers.hpp" #include "ros2_medkit_gateway/core/http/http_utils.hpp" -#include "ros2_medkit_gateway/core/http/x_medkit.hpp" #include "ros2_medkit_gateway/core/managers/operation_manager.hpp" #include "ros2_medkit_gateway/core/plugins/plugin_manager.hpp" #include "ros2_medkit_gateway/core/providers/operation_provider.hpp" +#include "ros2_medkit_gateway/dto/json_writer.hpp" +#include "ros2_medkit_gateway/dto/operations.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" #include "ros2_medkit_serialization/type_introspection.hpp" @@ -112,24 +113,23 @@ void OperationHandlers::handle_list_operations(const httplib::Request & req, htt entity_type.c_str(), entity_id.c_str(), ops.services.size(), ops.actions.size()); // Build response with services and actions - json operations = json::array(); + dto::Collection collection; // Get type introspection for schema info auto data_access_mgr = ctx_.node()->get_data_access_manager(); auto type_introspection = data_access_mgr->get_type_introspection(); for (const auto & svc : ops.services) { - // Response format - json svc_json = { - {"id", svc.name}, {"name", svc.name}, {"proximity_proof_required", false}, {"asynchronous_execution", false}}; - // Build x-medkit extension with ROS2-specific data - auto x_medkit = XMedkit() - .ros2_service(svc.full_path) - .ros2_type(svc.type) - .ros2_kind("service") - .entity_id(entity_id) - .source("ros2_medkit_gateway"); + dto::XMedkitRos2 ros2; + ros2.service = svc.full_path; + ros2.type = svc.type; + ros2.kind = "service"; + + dto::XMedkitOperationItem x_medkit; + x_medkit.ros2 = ros2; + x_medkit.entity_id = entity_id; + x_medkit.source = "ros2_medkit_gateway"; // Build type_info with request/response schemas for services try { @@ -139,56 +139,64 @@ void OperationHandlers::handle_list_operations(const httplib::Request & req, htt auto response_info = type_introspection->get_type_info(svc.type + "_Response"); type_info_json["request"] = request_info.schema; type_info_json["response"] = response_info.schema; - x_medkit.add("type_info", type_info_json); + x_medkit.type_info = type_info_json; } catch (const std::exception & e) { RCLCPP_DEBUG(HandlerContext::logger(), "Could not get type info for service '%s': %s", svc.type.c_str(), e.what()); } - svc_json["x-medkit"] = x_medkit.build(); - operations.push_back(svc_json); + dto::OperationItem item; + item.id = svc.name; + item.name = svc.name; + item.proximity_proof_required = false; + item.asynchronous_execution = false; + item.x_medkit = x_medkit; + collection.items.push_back(std::move(item)); } for (const auto & act : ops.actions) { - // Response format - json act_json = { - {"id", act.name}, {"name", act.name}, {"proximity_proof_required", false}, {"asynchronous_execution", true}}; - // Build x-medkit extension with ROS2-specific data - auto x_medkit = XMedkit() - .ros2_action(act.full_path) - .ros2_type(act.type) - .ros2_kind("action") - .entity_id(entity_id) - .source("ros2_medkit_gateway"); + dto::XMedkitRos2 ros2; + ros2.action = act.full_path; + ros2.type = act.type; + ros2.kind = "action"; + + dto::XMedkitOperationItem x_medkit; + x_medkit.ros2 = ros2; + x_medkit.entity_id = entity_id; + x_medkit.source = "ros2_medkit_gateway"; // Build type_info with goal/result/feedback schemas for actions try { json type_info_json; // Action types: pkg/action/Type -> Goal: pkg/action/Type_Goal, etc. - auto goal_info = type_introspection->get_type_info(act.type + "_Goal"); + auto goal_info_entry = type_introspection->get_type_info(act.type + "_Goal"); auto result_info = type_introspection->get_type_info(act.type + "_Result"); auto feedback_info = type_introspection->get_type_info(act.type + "_Feedback"); - type_info_json["goal"] = goal_info.schema; + type_info_json["goal"] = goal_info_entry.schema; type_info_json["result"] = result_info.schema; type_info_json["feedback"] = feedback_info.schema; - x_medkit.add("type_info", type_info_json); + x_medkit.type_info = type_info_json; } catch (const std::exception & e) { RCLCPP_DEBUG(HandlerContext::logger(), "Could not get type info for action '%s': %s", act.type.c_str(), e.what()); } - act_json["x-medkit"] = x_medkit.build(); - operations.push_back(act_json); + dto::OperationItem item; + item.id = act.name; + item.name = act.name; + item.proximity_proof_required = false; + item.asynchronous_execution = true; + item.x_medkit = x_medkit; + collection.items.push_back(std::move(item)); } - // Return response with items array - json response; - response["items"] = operations; - XMedkit ext; - merge_peer_items(ctx_.aggregation_manager(), req, response, ext); - if (!ext.empty()) { - response["x-medkit"] = ext.build(); + // Serialize to JSON, apply aggregation fan-out, then send + json response = dto::JsonWriter>::write(collection); + json ext_json = json::object(); + merge_peer_items(ctx_.aggregation_manager(), req, response, ext_json); + if (!ext_json.empty()) { + response["x-medkit"] = ext_json; } HandlerContext::send_json(res, response); } catch (const std::exception & e) { @@ -308,21 +316,19 @@ void OperationHandlers::handle_get_operation(const httplib::Request & req, httpl auto data_access_mgr = ctx_.node()->get_data_access_manager(); auto type_introspection = data_access_mgr->get_type_introspection(); - // Build response - json item; + // Build OperationDetail DTO + dto::OperationDetail detail; if (service_info.has_value()) { - item["id"] = service_info->name; - item["name"] = service_info->name; - item["proximity_proof_required"] = false; - item["asynchronous_execution"] = false; - - auto x_medkit = XMedkit() - .ros2_service(service_info->full_path) - .ros2_type(service_info->type) - .ros2_kind("service") - .entity_id(entity_id) - .source("ros2_medkit_gateway"); + dto::XMedkitRos2 ros2; + ros2.service = service_info->full_path; + ros2.type = service_info->type; + ros2.kind = "service"; + + dto::XMedkitOperationItem x_medkit; + x_medkit.ros2 = ros2; + x_medkit.entity_id = entity_id; + x_medkit.source = "ros2_medkit_gateway"; try { json type_info_json; @@ -330,46 +336,50 @@ void OperationHandlers::handle_get_operation(const httplib::Request & req, httpl auto response_info = type_introspection->get_type_info(service_info->type + "_Response"); type_info_json["request"] = request_info.schema; type_info_json["response"] = response_info.schema; - x_medkit.add("type_info", type_info_json); + x_medkit.type_info = type_info_json; } catch (const std::exception & e) { RCLCPP_DEBUG(HandlerContext::logger(), "Could not get type info for service '%s': %s", service_info->type.c_str(), e.what()); } - item["x-medkit"] = x_medkit.build(); + detail.item.id = service_info->name; + detail.item.name = service_info->name; + detail.item.proximity_proof_required = false; + detail.item.asynchronous_execution = false; + detail.item.x_medkit = x_medkit; } else { - item["id"] = action_info->name; - item["name"] = action_info->name; - item["proximity_proof_required"] = false; - item["asynchronous_execution"] = true; - - auto x_medkit = XMedkit() - .ros2_action(action_info->full_path) - .ros2_type(action_info->type) - .ros2_kind("action") - .entity_id(entity_id) - .source("ros2_medkit_gateway"); + dto::XMedkitRos2 ros2; + ros2.action = action_info->full_path; + ros2.type = action_info->type; + ros2.kind = "action"; + + dto::XMedkitOperationItem x_medkit; + x_medkit.ros2 = ros2; + x_medkit.entity_id = entity_id; + x_medkit.source = "ros2_medkit_gateway"; try { json type_info_json; - auto goal_info = type_introspection->get_type_info(action_info->type + "_Goal"); + auto goal_info_entry = type_introspection->get_type_info(action_info->type + "_Goal"); auto result_info = type_introspection->get_type_info(action_info->type + "_Result"); auto feedback_info = type_introspection->get_type_info(action_info->type + "_Feedback"); - type_info_json["goal"] = goal_info.schema; + type_info_json["goal"] = goal_info_entry.schema; type_info_json["result"] = result_info.schema; type_info_json["feedback"] = feedback_info.schema; - x_medkit.add("type_info", type_info_json); + x_medkit.type_info = type_info_json; } catch (const std::exception & e) { RCLCPP_DEBUG(HandlerContext::logger(), "Could not get type info for action '%s': %s", action_info->type.c_str(), e.what()); } - item["x-medkit"] = x_medkit.build(); + detail.item.id = action_info->name; + detail.item.name = action_info->name; + detail.item.proximity_proof_required = false; + detail.item.asynchronous_execution = true; + detail.item.x_medkit = x_medkit; } - json response; - response["item"] = item; - HandlerContext::send_json(res, response); + HandlerContext::send_dto(res, detail); } catch (const std::exception & e) { HandlerContext::send_error(res, 500, ERR_INTERNAL_ERROR, "Failed to get operation details", @@ -549,7 +559,9 @@ void OperationHandlers::handle_create_execution(const httplib::Request & req, ht if (action_result.success && action_result.goal_accepted) { // Return 202 Accepted with Location header for async operations - json response = {{"id", action_result.goal_id}, {"status", "running"}}; + dto::OperationExecution exec_dto; + exec_dto.id = action_result.goal_id; + exec_dto.status = "running"; // Add Location header pointing to execution status endpoint std::string base_path = (entity_type == "app") ? "/api/v1/apps/" : "/api/v1/components/"; @@ -558,7 +570,7 @@ void OperationHandlers::handle_create_execution(const httplib::Request & req, ht res.set_header("Location", location); res.status = 202; - res.set_content(response.dump(), "application/json"); + res.set_content(dto::JsonWriter::write(exec_dto).dump(), "application/json"); } else if (action_result.success && !action_result.goal_accepted) { HandlerContext::send_error( res, 400, ERR_X_MEDKIT_ROS2_ACTION_REJECTED, "Goal rejected", @@ -709,23 +721,28 @@ void OperationHandlers::handle_get_execution(const httplib::Request & req, httpl return; } - // Response - json response = {{"status", sovd_status_from_ros2(goal_info->status)}, {"capability", "execute"}}; + // Build OperationExecution DTO for the response + dto::OperationExecution exec_dto; + exec_dto.status = sovd_status_from_ros2(goal_info->status); + exec_dto.capability = "execute"; // Add feedback as parameters if available if (!goal_info->last_feedback.is_null() && !goal_info->last_feedback.empty()) { - response["parameters"] = goal_info->last_feedback; + exec_dto.parameters = goal_info->last_feedback; } // Add x-medkit extension for ROS2-specific details - auto x_medkit = XMedkit() - .add("goal_id", execution_id) - .add("ros2_status", action_status_to_string(goal_info->status)) - .ros2_action(goal_info->action_path) - .ros2_type(goal_info->action_type); - response["x-medkit"] = x_medkit.build(); + dto::XMedkitRos2 exec_ros2; + exec_ros2.action = goal_info->action_path; + exec_ros2.type = goal_info->action_type; - HandlerContext::send_json(res, response); + dto::XMedkitOperationExecution exec_x_medkit; + exec_x_medkit.goal_id = execution_id; + exec_x_medkit.ros2_status = action_status_to_string(goal_info->status); + exec_x_medkit.ros2 = exec_ros2; + exec_dto.x_medkit = exec_x_medkit; + + HandlerContext::send_dto(res, exec_dto); } catch (const std::exception & e) { HandlerContext::send_error(res, 500, ERR_INTERNAL_ERROR, "Failed to get execution status", @@ -841,25 +858,12 @@ void OperationHandlers::handle_update_execution(const httplib::Request & req, ht } } - // Parse request body - json body = json::object(); - if (!req.body.empty()) { - try { - body = json::parse(req.body); - } catch (const json::parse_error & e) { - HandlerContext::send_error(res, 400, ERR_INVALID_REQUEST, "Invalid JSON in request body", - {{"details", e.what()}}); - return; - } + // Parse and validate request body via DTO + auto body_opt = ctx_.parse_body(req, res); + if (!body_opt) { + return; // 400 already sent by parse_body } - - // Validate required 'capability' field - if (!body.contains("capability") || !body["capability"].is_string()) { - HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Missing required 'capability' field"); - return; - } - - std::string capability = body["capability"].get(); + const std::string capability = body_opt->capability; auto operation_mgr = ctx_.node()->get_operation_manager(); auto goal_info = operation_mgr->get_tracked_goal(execution_id); @@ -885,9 +889,12 @@ void OperationHandlers::handle_update_execution(const httplib::Request & req, ht std::string location = base_path + entity_id + "/operations/" + operation_id + "/executions/" + execution_id; res.set_header("Location", location); - json response = {{"id", execution_id}, {"status", "running"}}; // Canceling is still "running" in SOVD terms + // Canceling is still "running" in SOVD terms + dto::OperationExecution exec_dto; + exec_dto.id = execution_id; + exec_dto.status = "running"; res.status = 202; - res.set_content(response.dump(), "application/json"); + res.set_content(dto::JsonWriter::write(exec_dto).dump(), "application/json"); } else { std::string error_msg; switch (result.return_code) { diff --git a/src/ros2_medkit_gateway/src/http/rest_server.cpp b/src/ros2_medkit_gateway/src/http/rest_server.cpp index 465cefa6..590494a1 100644 --- a/src/ros2_medkit_gateway/src/http/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/http/rest_server.cpp @@ -476,7 +476,7 @@ void RESTServer::setup_routes() { .tag("Operations") .summary(std::string("List operations for ") + et.singular) .description(std::string("Lists all ROS 2 services and actions available on this ") + et.singular + ".") - .response(200, "Operation list", SB::ref("OperationItemList")) + .response(200, "Operation list", SB::ref("OperationList")) .operation_id(std::string("list") + capitalize(et.singular) + "Operations"); reg.get(entity_path + "/operations/{operation_id}", diff --git a/src/ros2_medkit_gateway/src/openapi/path_builder.cpp b/src/ros2_medkit_gateway/src/openapi/path_builder.cpp index fa549f38..91d9e1b7 100644 --- a/src/ros2_medkit_gateway/src/openapi/path_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/path_builder.cpp @@ -213,8 +213,7 @@ nlohmann::json PathBuilder::build_operations_collection(const std::string & enti 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::operation_item_schema()); + get_op["responses"]["200"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("OperationList"); auto errors = error_responses(); for (auto & [code, val] : errors.items()) { @@ -305,7 +304,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"] = SchemaBuilder::operation_execution_schema(); + post_op["responses"]["202"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("OperationExecution"); 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 6a49657c..fdc4e2ea 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp @@ -264,31 +264,6 @@ nlohmann::json SchemaBuilder::binary_schema() { return {{"type", "string"}, {"format", "binary"}}; } -nlohmann::json SchemaBuilder::operation_item_schema() { - return {{"type", "object"}, - {"properties", - {{"id", {{"type", "string"}}}, - {"name", {{"type", "string"}}}, - {"proximity_proof_required", {{"type", "boolean"}, {"description", "Whether proximity proof is needed"}}}, - {"asynchronous_execution", {{"type", "boolean"}, {"description", "Whether operation runs asynchronously"}}}, - {"x-medkit", {{"type", "object"}, {"additionalProperties", true}}}}}, - {"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", - {{"id", {{"type", "string"}}}, - {"status", {{"type", "string"}, {"enum", {"pending", "running", "completed", "failed"}}}}, - {"progress", {{"type", "number"}}}, - {"result", {{"type", "object"}}}}}, - {"required", {"id", "status"}}}; -} - nlohmann::json SchemaBuilder::trigger_condition_schema() { return {{"type", "object"}, {"properties", {{"condition_type", {{"type", "string"}}}}}, @@ -512,16 +487,6 @@ nlohmann::json SchemaBuilder::data_write_request_schema() { {"required", {"type", "data"}}}; } -nlohmann::json SchemaBuilder::execution_update_request_schema() { - return {{"type", "object"}, - {"properties", - {{"capability", - {{"type", "string"}, - {"enum", {"stop", "execute", "freeze", "reset"}}, - {"description", "Control command for the running execution"}}}}}, - {"required", {"capability"}}}; -} - nlohmann::json SchemaBuilder::script_control_request_schema() { return {{"type", "object"}, {"properties", @@ -582,13 +547,10 @@ const std::map & SchemaBuilder::component_schemas() {"DataItem", data_item_schema()}, {"DataItemList", items_wrapper_ref("DataItem")}, {"DataWriteRequest", data_write_request_schema()}, - // Operations - {"OperationItem", operation_item_schema()}, - {"OperationItemList", items_wrapper_ref("OperationItem")}, - {"OperationDetail", operation_detail_schema()}, - {"OperationExecution", operation_execution_schema()}, + // Operations - OperationItem, OperationDetail, OperationExecution, + // ExecutionUpdateRequest now come from DTO (dto/operations.hpp). + // OperationExecutionList is kept here as a thin wrapper over the DTO type. {"OperationExecutionList", items_wrapper_ref("OperationExecution")}, - {"ExecutionUpdateRequest", execution_update_request_schema()}, // Triggers {"Trigger", trigger_schema()}, {"TriggerList", items_wrapper_ref("Trigger")}, diff --git a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp index 2e9af8bd..4a300cc7 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp @@ -89,15 +89,6 @@ class SchemaBuilder { /// Binary content schema (for file downloads) static nlohmann::json binary_schema(); - /// 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(); - /// Trigger schema (CRUD responses) static nlohmann::json trigger_schema(); @@ -155,9 +146,6 @@ class SchemaBuilder { /// Data write request schema (PUT /data/{id}) static nlohmann::json data_write_request_schema(); - /// Execution update request schema (PUT /operations/{id}/executions/{id}) - static nlohmann::json execution_update_request_schema(); - /// Script control request schema (PUT /scripts/{id}/executions/{id}) static nlohmann::json script_control_request_schema(); diff --git a/src/ros2_medkit_gateway/test/test_operation_handlers.cpp b/src/ros2_medkit_gateway/test/test_operation_handlers.cpp index 703a8b44..c468530e 100644 --- a/src/ros2_medkit_gateway/test/test_operation_handlers.cpp +++ b/src/ros2_medkit_gateway/test/test_operation_handlers.cpp @@ -544,7 +544,9 @@ TEST_F(OperationHandlersFixtureTest, UpdateExecutionMissingCapabilityReturns400) handlers_->handle_update_execution(req, res); + // parse_body validates the required 'capability' field + // and returns ERR_INVALID_REQUEST on missing/invalid fields. EXPECT_EQ(res.status, 400); auto body = parse_json(res); - EXPECT_EQ(body["error_code"], ros2_medkit_gateway::ERR_INVALID_PARAMETER); + EXPECT_EQ(body["error_code"], ros2_medkit_gateway::ERR_INVALID_REQUEST); } diff --git a/src/ros2_medkit_gateway/test/test_path_builder.cpp b/src/ros2_medkit_gateway/test/test_path_builder.cpp index 7942af71..0ede7179 100644 --- a/src/ros2_medkit_gateway/test/test_path_builder.cpp +++ b/src/ros2_medkit_gateway/test/test_path_builder.cpp @@ -209,13 +209,14 @@ TEST_F(PathBuilderTest, OperationsCollectionHasGet) { EXPECT_TRUE(result["get"]["responses"].contains("200")); } -TEST_F(PathBuilderTest, OperationsCollectionResponseHasItems) { +TEST_F(PathBuilderTest, OperationsCollectionResponseRefersToOperationList) { + // build_operations_collection now uses SchemaBuilder::ref("OperationList") - a $ref to + // the DTO-generated Collection schema. AggregatedOperations ops; auto result = path_builder_.build_operations_collection("apps/engine", ops); auto schema = result["get"]["responses"]["200"]["content"]["application/json"]["schema"]; - EXPECT_EQ(schema["type"], "object"); - ASSERT_TRUE(schema.contains("properties")); - ASSERT_TRUE(schema["properties"].contains("items")); + ASSERT_TRUE(schema.contains("$ref")); + EXPECT_EQ(schema["$ref"], "#/components/schemas/OperationList"); } // ============================================================================= diff --git a/src/ros2_medkit_gateway/test/test_schema_builder.cpp b/src/ros2_medkit_gateway/test/test_schema_builder.cpp index 67d5da5d..f117f307 100644 --- a/src/ros2_medkit_gateway/test/test_schema_builder.cpp +++ b/src/ros2_medkit_gateway/test/test_schema_builder.cpp @@ -222,17 +222,16 @@ TEST(SchemaBuilderStaticTest, ConfigurationReadValueSchema) { } // @verifies REQ_INTEROP_002 -TEST(SchemaBuilderStaticTest, OperationDetailSchema) { - auto schema = SchemaBuilder::operation_detail_schema(); +TEST(SchemaBuilderStaticTest, OperationDetailSchemaComeFromDto) { + // OperationDetail is now generated from the DTO; verify via component_schemas(). + const auto & schemas = SchemaBuilder::component_schemas(); + ASSERT_TRUE(schemas.count("OperationDetail") > 0); + const auto & schema = schemas.at("OperationDetail"); EXPECT_EQ(schema["type"], "object"); ASSERT_TRUE(schema.contains("properties")); EXPECT_TRUE(schema["properties"].contains("item")); - // item references OperationItem via $ref + // item references OperationItem via $ref (DTO SchemaWriter uses $ref for nested DTOs) 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 @@ -565,8 +564,11 @@ TEST(SchemaBuilderStaticTest, DataWriteRequestSchema) { } // @verifies REQ_INTEROP_002 -TEST(SchemaBuilderStaticTest, ExecutionUpdateRequestSchema) { - auto schema = SchemaBuilder::execution_update_request_schema(); +TEST(SchemaBuilderStaticTest, ExecutionUpdateRequestSchemaComesFromDto) { + // ExecutionUpdateRequest is now generated from the DTO; verify via component_schemas(). + const auto & schemas = SchemaBuilder::component_schemas(); + ASSERT_TRUE(schemas.count("ExecutionUpdateRequest") > 0); + const auto & schema = schemas.at("ExecutionUpdateRequest"); EXPECT_EQ(schema["type"], "object"); ASSERT_TRUE(schema.contains("properties")); EXPECT_TRUE(schema["properties"].contains("capability")); From 03bee4169a80b4326a48fe3e1bcc4f773cccea60 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 12:00:06 +0200 Subject: [PATCH 21/51] refactor(gateway): type fault x-medkit, drop legacy builder from fault handlers Add FaultListXMedkit and FaultListAggXMedkit typed DTO structs covering the five fault-list response shapes (global, per-app, function/component/area aggregated). Replace all XMedkit fluent builder usages in fault_handlers.cpp with typed struct construction serialized via dto::JsonWriter; use the json-overload of merge_peer_items for fan-out partial/failed_peers injection. Remove the #include of core/http/x_medkit.hpp from fault_handlers.cpp. Register the two new structs in AllDtos so EveryRegisteredDtoRoundTrips and AllRefsResolveToRegisteredSchemas cover them. --- .../ros2_medkit_gateway/dto/faults.hpp | 84 +++++++++++ .../ros2_medkit_gateway/dto/registry.hpp | 7 +- .../src/http/handlers/fault_handlers.cpp | 132 +++++++++--------- 3 files changed, 156 insertions(+), 67 deletions(-) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/faults.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/faults.hpp index f39f63a1..3ab32279 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/faults.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/faults.hpp @@ -126,6 +126,90 @@ inline constexpr auto dto_fields = template <> inline constexpr std::string_view dto_name = "FaultEnvironmentData"; +// ============================================================================= +// FaultListXMedkit - x-medkit vendor extension on fault list responses: +// handle_list_all_faults (global, no entity_id) and +// handle_list_faults APP branch (per-app, has entity_id + source_id). +// +// Wire keys: +// count - total items in the response (required after fan-out merge) +// muted_count - number of muted/correlated faults (from FaultManager) +// cluster_count - number of fault clusters (from FaultManager) +// entity_id - SOVD entity ID (optional; absent for global endpoint) +// source_id - namespace_path used for filtering (optional; App only) +// muted_faults - detailed muted fault list (optional; only if requested) +// clusters - detailed cluster list (optional; only if requested) +// partial - true when a fan-out peer request failed (optional) +// failed_peers - list of peer addresses that returned errors (optional) +// ============================================================================= +struct FaultListXMedkit { + int64_t count{0}; + std::optional muted_count; + std::optional cluster_count; + std::optional entity_id; + std::optional source_id; + std::optional muted_faults; // free-form: FaultManager output + std::optional clusters; // free-form: FaultManager output + std::optional partial; + std::optional> failed_peers; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("count", &FaultListXMedkit::count), field("muted_count", &FaultListXMedkit::muted_count), + field("cluster_count", &FaultListXMedkit::cluster_count), + field("entity_id", &FaultListXMedkit::entity_id), field("source_id", &FaultListXMedkit::source_id), + field("muted_faults", &FaultListXMedkit::muted_faults), + field("clusters", &FaultListXMedkit::clusters), field("partial", &FaultListXMedkit::partial), + field("failed_peers", &FaultListXMedkit::failed_peers)); + +template <> +inline constexpr std::string_view dto_name = "FaultListXMedkit"; + +// ============================================================================= +// FaultListAggXMedkit - x-medkit vendor extension on aggregated fault list +// responses: handle_list_faults FUNCTION / COMPONENT / AREA branches. +// +// Wire keys: +// entity_id - SOVD entity ID being queried +// aggregation_level - one of: "function", "component", "area" +// aggregated - always true (signals multi-source aggregation) +// host_count - number of host apps (Function only) +// app_count - number of apps (Component / Area) +// component_count - number of components (Area only) +// aggregation_sources - FQNs used for filtering (array of strings) +// count - total items after fan-out merge +// partial - true when a fan-out peer request failed (optional) +// failed_peers - list of peer addresses that returned errors (optional) +// ============================================================================= +struct FaultListAggXMedkit { + std::optional entity_id; + std::optional aggregation_level; + std::optional aggregated; + std::optional host_count; + std::optional app_count; + std::optional component_count; + std::optional> aggregation_sources; + int64_t count{0}; + std::optional partial; + std::optional> failed_peers; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("entity_id", &FaultListAggXMedkit::entity_id), + field("aggregation_level", &FaultListAggXMedkit::aggregation_level), + field("aggregated", &FaultListAggXMedkit::aggregated), + field("host_count", &FaultListAggXMedkit::host_count), + field("app_count", &FaultListAggXMedkit::app_count), + field("component_count", &FaultListAggXMedkit::component_count), + field("aggregation_sources", &FaultListAggXMedkit::aggregation_sources), + field("count", &FaultListAggXMedkit::count), field("partial", &FaultListAggXMedkit::partial), + field("failed_peers", &FaultListAggXMedkit::failed_peers)); + +template <> +inline constexpr std::string_view dto_name = "FaultListAggXMedkit"; + // ============================================================================= // FaultXMedkit - x-medkit vendor extension inside FaultDetail // diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp index a898146f..0761dfc5 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp @@ -36,9 +36,10 @@ using AllDtos = std::tuple, Collection, Collection, - Collection, FaultListItem, Collection, FaultStatus, FaultItem, - FaultEnvironmentData, FaultXMedkit, FaultDetail, XMedkitOperationItem, XMedkitOperationExecution, - OperationItem, Collection, OperationDetail, OperationExecution, ExecutionUpdateRequest>; + Collection, FaultListItem, Collection, FaultListXMedkit, + FaultListAggXMedkit, FaultStatus, FaultItem, FaultEnvironmentData, FaultXMedkit, FaultDetail, + XMedkitOperationItem, XMedkitOperationExecution, OperationItem, Collection, + OperationDetail, OperationExecution, ExecutionUpdateRequest>; namespace detail { template diff --git a/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp index 99c6e02c..a98abdc4 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp @@ -28,7 +28,6 @@ #include "ros2_medkit_gateway/core/http/error_codes.hpp" #include "ros2_medkit_gateway/core/http/fan_out_helpers.hpp" #include "ros2_medkit_gateway/core/http/http_utils.hpp" -#include "ros2_medkit_gateway/core/http/x_medkit.hpp" #include "ros2_medkit_gateway/core/plugins/plugin_manager.hpp" #include "ros2_medkit_gateway/core/providers/fault_provider.hpp" #include "ros2_medkit_gateway/dto/faults.hpp" @@ -313,18 +312,18 @@ void FaultHandlers::handle_list_all_faults(const httplib::Request & req, httplib // Format: items array at top level json response = {{"items", result.data["faults"]}}; - // x-medkit extension for ros2_medkit-specific fields - XMedkit ext; - ext.add("count", result.data["count"]); - ext.add("muted_count", result.data["muted_count"]); - ext.add("cluster_count", result.data["cluster_count"]); + // x-medkit extension for ros2_medkit-specific fields (typed DTO) + dto::FaultListXMedkit xm; + xm.count = result.data.value("count", static_cast(0)); + xm.muted_count = result.data.value("muted_count", static_cast(0)); + xm.cluster_count = result.data.value("cluster_count", static_cast(0)); // Include detailed correlation data if requested and present if (result.data.contains("muted_faults")) { - ext.add("muted_faults", result.data["muted_faults"]); + xm.muted_faults = result.data["muted_faults"]; } if (result.data.contains("clusters")) { - ext.add("clusters", result.data["clusters"]); + xm.clusters = result.data["clusters"]; } // Fan-out to peers: faults are managed by FaultManager, not cached, @@ -337,14 +336,12 @@ void FaultHandlers::handle_list_all_faults(const httplib::Request & req, httplib } } if (fan_result.is_partial) { - ext.add("partial", true); - ext.add("failed_peers", fan_result.failed_peers); + xm.partial = true; + xm.failed_peers = fan_result.failed_peers; } } - if (!ext.empty()) { - response["x-medkit"] = ext.build(); - } + response["x-medkit"] = dto::JsonWriter::write(xm); res.status = 200; HandlerContext::send_json(res, response); @@ -457,21 +454,23 @@ void FaultHandlers::handle_list_faults(const httplib::Request & req, httplib::Re // Build response json response = {{"items", filtered_faults}}; - XMedkit ext; - ext.entity_id(entity_id); - ext.add("aggregation_level", "function"); - ext.add("aggregated", true); - ext.add("host_count", host_fqns.size()); - // Include source app IDs for cross-referencing aggregated results - json source_ids = json::array(); - for (const auto & fqn : host_fqns) { - source_ids.push_back(fqn); + // x-medkit extension (typed DTO) + dto::FaultListAggXMedkit xm; + xm.entity_id = entity_id; + xm.aggregation_level = "function"; + xm.aggregated = true; + xm.host_count = static_cast(host_fqns.size()); + { + std::vector sources(host_fqns.begin(), host_fqns.end()); + if (!sources.empty()) { + xm.aggregation_sources = std::move(sources); + } } - ext.add("aggregation_sources", source_ids); - merge_peer_items(ctx_.aggregation_manager(), req, response, ext); - ext.add("count", response["items"].size()); - response["x-medkit"] = ext.build(); + auto xm_json = dto::JsonWriter::write(xm); + merge_peer_items(ctx_.aggregation_manager(), req, response, xm_json); + xm_json["count"] = static_cast(response["items"].size()); + response["x-medkit"] = std::move(xm_json); HandlerContext::send_json(res, response); return; } @@ -509,21 +508,23 @@ void FaultHandlers::handle_list_faults(const httplib::Request & req, httplib::Re // Build response json response = {{"items", filtered_faults}}; - XMedkit ext; - ext.entity_id(entity_id); - ext.add("aggregation_level", "component"); - ext.add("aggregated", true); - ext.add("app_count", app_fqns.size()); - // Include source app FQNs for cross-referencing aggregated results - json source_fqns = json::array(); - for (const auto & fqn : app_fqns) { - source_fqns.push_back(fqn); + // x-medkit extension (typed DTO) + dto::FaultListAggXMedkit xm; + xm.entity_id = entity_id; + xm.aggregation_level = "component"; + xm.aggregated = true; + xm.app_count = static_cast(app_fqns.size()); + { + std::vector sources(app_fqns.begin(), app_fqns.end()); + if (!sources.empty()) { + xm.aggregation_sources = std::move(sources); + } } - ext.add("aggregation_sources", source_fqns); - merge_peer_items(ctx_.aggregation_manager(), req, response, ext); - ext.add("count", response["items"].size()); - response["x-medkit"] = ext.build(); + auto xm_json = dto::JsonWriter::write(xm); + merge_peer_items(ctx_.aggregation_manager(), req, response, xm_json); + xm_json["count"] = static_cast(response["items"].size()); + response["x-medkit"] = std::move(xm_json); HandlerContext::send_json(res, response); return; } @@ -564,22 +565,24 @@ void FaultHandlers::handle_list_faults(const httplib::Request & req, httplib::Re // Build response json response = {{"items", filtered_faults}}; - XMedkit ext; - ext.entity_id(entity_id); - ext.add("aggregation_level", "area"); - ext.add("aggregated", true); - ext.add("component_count", comp_ids.size()); - ext.add("app_count", app_fqns.size()); - // Include source app FQNs for cross-referencing aggregated results - json area_source_fqns = json::array(); - for (const auto & fqn : app_fqns) { - area_source_fqns.push_back(fqn); + // x-medkit extension (typed DTO) + dto::FaultListAggXMedkit xm; + xm.entity_id = entity_id; + xm.aggregation_level = "area"; + xm.aggregated = true; + xm.component_count = static_cast(comp_ids.size()); + xm.app_count = static_cast(app_fqns.size()); + { + std::vector sources(app_fqns.begin(), app_fqns.end()); + if (!sources.empty()) { + xm.aggregation_sources = std::move(sources); + } } - ext.add("aggregation_sources", area_source_fqns); - merge_peer_items(ctx_.aggregation_manager(), req, response, ext); - ext.add("count", response["items"].size()); - response["x-medkit"] = ext.build(); + auto xm_json = dto::JsonWriter::write(xm); + merge_peer_items(ctx_.aggregation_manager(), req, response, xm_json); + xm_json["count"] = static_cast(response["items"].size()); + response["x-medkit"] = std::move(xm_json); HandlerContext::send_json(res, response); return; } @@ -594,24 +597,25 @@ void FaultHandlers::handle_list_faults(const httplib::Request & req, httplib::Re // Format: items array at top level json response = {{"items", result.data["faults"]}}; - // x-medkit extension for ros2_medkit-specific fields - XMedkit ext; - ext.entity_id(entity_id); - ext.add("source_id", namespace_path); + // x-medkit extension for ros2_medkit-specific fields (typed DTO) + dto::FaultListXMedkit xm; + xm.entity_id = entity_id; + xm.source_id = namespace_path; // Include detailed correlation data if requested and present if (result.data.contains("muted_faults")) { - ext.add("muted_faults", result.data["muted_faults"]); + xm.muted_faults = result.data["muted_faults"]; } if (result.data.contains("clusters")) { - ext.add("clusters", result.data["clusters"]); + xm.clusters = result.data["clusters"]; } - merge_peer_items(ctx_.aggregation_manager(), req, response, ext); - ext.add("count", response["items"].size()); - ext.add("muted_count", result.data["muted_count"]); - ext.add("cluster_count", result.data["cluster_count"]); - response["x-medkit"] = ext.build(); + auto xm_json = dto::JsonWriter::write(xm); + merge_peer_items(ctx_.aggregation_manager(), req, response, xm_json); + xm_json["count"] = static_cast(response["items"].size()); + xm_json["muted_count"] = result.data.value("muted_count", static_cast(0)); + xm_json["cluster_count"] = result.data.value("cluster_count", static_cast(0)); + response["x-medkit"] = std::move(xm_json); HandlerContext::send_json(res, response); } else { HandlerContext::send_error(res, 503, ERR_SERVICE_UNAVAILABLE, "Failed to get faults", From 0cfc84b73894313fa09b25af49f68e78d194262c Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 12:15:29 +0200 Subject: [PATCH 22/51] feat(gateway): add configuration DTOs Introduce typed DTO structs for the CONFIGURATIONS domain: - ConfigXMedkitItem: per-item x-medkit in list responses - ConfigurationMetaData: list item (id, name, type, x-medkit) - Collection named "ConfigurationList" - ConfigListXMedkit: x-medkit on list response root - ConfigValueXMedkit: x-medkit on GET/PUT value responses - ConfigurationReadValue: GET/PUT response shape - ConfigurationWriteRequest: PUT request body - ConfigurationDeleteResultItem: 207 multi-status result entry - ConfigurationDeleteMultiStatus: 207 multi-status response body Provide dto_sample specializations for the two DTOs that carry a non-optional nlohmann::json field (data), ensuring EveryRegisteredDtoRoundTrips passes. Register all new types in AllDtos in registry.hpp. --- .../ros2_medkit_gateway/dto/config.hpp | 269 ++++++++++++++++++ .../ros2_medkit_gateway/dto/registry.hpp | 7 +- 2 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/config.hpp diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/config.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/config.hpp new file mode 100644 index 00000000..61a32e73 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/config.hpp @@ -0,0 +1,269 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "ros2_medkit_gateway/dto/contract.hpp" +#include "ros2_medkit_gateway/dto/entities.hpp" +#include "ros2_medkit_gateway/dto/sample.hpp" +#include "ros2_medkit_gateway/dto/x_medkit.hpp" + +namespace ros2_medkit_gateway { +namespace dto { + +// ============================================================================= +// ConfigXMedkitItem - x-medkit block on each item inside the configurations +// list ("items" array). Emitted by handle_list_configurations per-parameter. +// +// Wire keys (from config_handlers.cpp per-item x-medkit construction): +// source - app_id that owns this parameter (aggregated entities only) +// node - node FQN providing this parameter (in all_parameters tracking list) +// ============================================================================= +struct ConfigXMedkitItem { + std::optional source; // app_id that owns this parameter + std::optional node; // node FQN (only in parameters tracking list) +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("source", &ConfigXMedkitItem::source), field("node", &ConfigXMedkitItem::node)); + +template <> +inline constexpr std::string_view dto_name = "ConfigXMedkitItem"; + +// ============================================================================= +// ConfigurationMetaData - single item in the configurations list response. +// Emitted by handle_list_configurations per discovered parameter. +// +// Wire keys (from config_handlers.cpp handle_list_configurations): +// id - unique param ID (app_id:param_name for aggregated, param_name otherwise) +// name - parameter name (without app_id prefix) +// type - always "parameter" +// x-medkit - optional; present only for aggregated entities (source=app_id) +// ============================================================================= +struct ConfigurationMetaData { + std::string id; + std::string name; + std::string type; // always "parameter" + std::optional x_medkit; // wire key: "x-medkit" +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("id", &ConfigurationMetaData::id), field("name", &ConfigurationMetaData::name), + field("type", &ConfigurationMetaData::type), field("x-medkit", &ConfigurationMetaData::x_medkit)); + +template <> +inline constexpr std::string_view dto_name = "ConfigurationMetaData"; + +// ============================================================================= +// ConfigListXMedkit - x-medkit vendor extension on configuration list responses. +// Emitted by handle_list_configurations (root of the response object). +// +// Wire keys (from config_handlers.cpp XMedkit builder calls): +// entity_id - SOVD entity ID being queried +// source - always "runtime" +// aggregation_level - level string from EntityCache (e.g. "app", "component") +// is_aggregated - true when multiple nodes contribute parameters +// parameters - full parameter details including value/type/read_only +// (free-form: raw ConfigurationManager output + x-medkit) +// source_ids - namespace/FQN strings used for node lookup +// queried_nodes - list of node FQNs successfully queried +// partial - true when a fan-out peer request failed +// failed_peers - list of peer addresses that returned errors +// ============================================================================= +struct ConfigListXMedkit { + std::optional entity_id; + std::optional source; + std::optional aggregation_level; + std::optional is_aggregated; + std::optional parameters; // free-form: array of raw param JSON + std::optional> source_ids; + std::optional> queried_nodes; + std::optional partial; + std::optional> failed_peers; +}; + +template <> +inline constexpr auto dto_fields = std::make_tuple( + field("entity_id", &ConfigListXMedkit::entity_id), field("source", &ConfigListXMedkit::source), + field("aggregation_level", &ConfigListXMedkit::aggregation_level), + field("is_aggregated", &ConfigListXMedkit::is_aggregated), field("parameters", &ConfigListXMedkit::parameters), + field("source_ids", &ConfigListXMedkit::source_ids), field("queried_nodes", &ConfigListXMedkit::queried_nodes), + field("partial", &ConfigListXMedkit::partial), field("failed_peers", &ConfigListXMedkit::failed_peers)); + +template <> +inline constexpr std::string_view dto_name = "ConfigListXMedkit"; + +// ============================================================================= +// ConfigValueXMedkit - x-medkit vendor extension on configuration read/write +// value responses. Emitted by handle_get_configuration and +// handle_set_configuration. +// +// Wire keys (from config_handlers.cpp XMedkit builder calls): +// ros2 - nested ROS 2 metadata sub-object (node FQN) +// entity_id - SOVD entity ID +// source - always "runtime" +// parameter - full raw parameter detail JSON (value, type, read_only, etc.) +// source_app - app_id (present for aggregated entities only) +// ============================================================================= +struct ConfigValueXMedkit { + std::optional ros2; + std::optional entity_id; + std::optional source; + std::optional parameter; // free-form: raw ConfigurationManager output + std::optional source_app; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("ros2", &ConfigValueXMedkit::ros2), field("entity_id", &ConfigValueXMedkit::entity_id), + field("source", &ConfigValueXMedkit::source), field("parameter", &ConfigValueXMedkit::parameter), + field("source_app", &ConfigValueXMedkit::source_app)); + +template <> +inline constexpr std::string_view dto_name = "ConfigValueXMedkit"; + +// ============================================================================= +// ConfigurationReadValue - response shape for GET/PUT /{entity}/configurations/{id}. +// Emitted by handle_get_configuration and handle_set_configuration. +// +// Wire keys (from config_handlers.cpp): +// id - parameter ID as used in the request +// data - parameter value (free-form: any JSON scalar or object) +// x-medkit - optional vendor extension +// ============================================================================= +struct ConfigurationReadValue { + std::string id; + nlohmann::json data; // free-form: value from ConfigurationManager + std::optional x_medkit; // wire key: "x-medkit" +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("id", &ConfigurationReadValue::id), field("data", &ConfigurationReadValue::data), + field("x-medkit", &ConfigurationReadValue::x_medkit)); + +template <> +inline constexpr std::string_view dto_name = "ConfigurationReadValue"; + +// ============================================================================= +// ConfigurationWriteRequest - PUT request body for /{entity}/configurations/{id}. +// Parsed by handle_set_configuration via parse_body. +// +// Wire keys (SOVD convention, from config_handlers.cpp): +// data - configuration value to set (free-form: any JSON scalar or object) +// ============================================================================= +struct ConfigurationWriteRequest { + nlohmann::json data; // free-form: any JSON value +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("data", &ConfigurationWriteRequest::data)); + +template <> +inline constexpr std::string_view dto_name = "ConfigurationWriteRequest"; + +// ============================================================================= +// ConfigurationDeleteResultItem - single entry in the 207 multi-status results +// array from handle_delete_all_configurations. +// +// Wire keys (from config_handlers.cpp multi_status construction): +// node - fully qualified node name +// app_id - SOVD app entity ID +// success - true if reset succeeded +// error - error message (present on failure) +// details - additional detail data (present on success if data non-empty) +// ============================================================================= +struct ConfigurationDeleteResultItem { + std::string node; + std::string app_id; + bool success{false}; + std::optional error; + std::optional details; // free-form: reset result data +}; + +template <> +inline constexpr auto dto_fields = std::make_tuple( + field("node", &ConfigurationDeleteResultItem::node), field("app_id", &ConfigurationDeleteResultItem::app_id), + field("success", &ConfigurationDeleteResultItem::success), field("error", &ConfigurationDeleteResultItem::error), + field("details", &ConfigurationDeleteResultItem::details)); + +template <> +inline constexpr std::string_view dto_name = "ConfigurationDeleteResultItem"; + +// ============================================================================= +// ConfigurationDeleteMultiStatus - 207 Multi-Status response body from +// handle_delete_all_configurations when partial failure occurs. +// +// Wire keys (from config_handlers.cpp): +// entity_id - SOVD entity ID for which delete was attempted +// results - per-node reset outcome list +// ============================================================================= +struct ConfigurationDeleteMultiStatus { + std::string entity_id; + std::vector results; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("entity_id", &ConfigurationDeleteMultiStatus::entity_id), + field("results", &ConfigurationDeleteMultiStatus::results)); + +template <> +inline constexpr std::string_view dto_name = "ConfigurationDeleteMultiStatus"; + +// ============================================================================= +// Collection - named "ConfigurationList" +// ============================================================================= +template <> +inline constexpr std::string_view dto_name> = "ConfigurationList"; + +// ============================================================================= +// dto_sample specializations for DTOs with non-optional nlohmann::json fields. +// +// The generic sample path produces nlohmann::json{} (null) for bare json fields, +// which the round-trip reader treats as missing (null == absent for required +// fields). Provide explicit samples with a non-null value to ensure +// EveryRegisteredDtoRoundTrips passes. +// ============================================================================= +template <> +struct dto_sample { + static ConfigurationReadValue make() { + ConfigurationReadValue obj; + obj.id = "sample"; + obj.data = nlohmann::json{42}; // non-null: int scalar representative value + return obj; + } +}; + +template <> +struct dto_sample { + static ConfigurationWriteRequest make() { + ConfigurationWriteRequest obj; + obj.data = nlohmann::json{42}; // non-null: int scalar representative value + return obj; + } +}; + +} // namespace dto +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp index 0761dfc5..8206cf7a 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp @@ -19,6 +19,7 @@ #include #include +#include "ros2_medkit_gateway/dto/config.hpp" #include "ros2_medkit_gateway/dto/contract.hpp" #include "ros2_medkit_gateway/dto/entities.hpp" #include "ros2_medkit_gateway/dto/errors.hpp" @@ -39,7 +40,11 @@ using AllDtos = Collection, FaultListItem, Collection, FaultListXMedkit, FaultListAggXMedkit, FaultStatus, FaultItem, FaultEnvironmentData, FaultXMedkit, FaultDetail, XMedkitOperationItem, XMedkitOperationExecution, OperationItem, Collection, - OperationDetail, OperationExecution, ExecutionUpdateRequest>; + OperationDetail, OperationExecution, ExecutionUpdateRequest, + // Configuration domain DTOs + ConfigXMedkitItem, ConfigurationMetaData, Collection, ConfigListXMedkit, + ConfigValueXMedkit, ConfigurationReadValue, ConfigurationWriteRequest, ConfigurationDeleteResultItem, + ConfigurationDeleteMultiStatus>; namespace detail { template From 7631f4e7e50c4d6d46421cd39dd2165ad7a8013e Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 12:25:36 +0200 Subject: [PATCH 23/51] refactor(gateway): migrate configuration handlers to DTO contract Replace all XMedkit fluent builder usages in config_handlers.cpp with typed DTO structs from dto/config.hpp: - handle_list_configurations: ConfigListXMedkit + ConfigXMedkitItem per item - handle_get_configuration: ConfigurationReadValue + ConfigValueXMedkit - handle_set_configuration: parse_body for body parsing; ConfigurationReadValue + ConfigValueXMedkit for response - handle_delete_all_configurations: ConfigurationDeleteMultiStatus for 207 Remove all four hand-written config schema factories from schema_builder (configuration_metadata_schema, configuration_read_value_schema, configuration_write_value_schema, configuration_delete_multi_status_schema). DTO-generated schemas in collect_component_schemas() now own these names. Retype config routes in rest_server.cpp and path_builder.cpp to use $ref to DTO names (ConfigurationList, ConfigurationReadValue, ConfigurationWriteRequest, ConfigurationDeleteMultiStatus). Update test_schema_builder.cpp and test_path_builder.cpp to assert against DTO-generated schemas via component_schemas(). --- .../ros2_medkit_gateway/dto/config.hpp | 12 +- .../src/http/handlers/config_handlers.cpp | 185 ++++++++++-------- .../src/http/rest_server.cpp | 4 +- .../src/openapi/path_builder.cpp | 5 +- .../src/openapi/schema_builder.cpp | 54 ----- .../src/openapi/schema_builder.hpp | 12 -- .../test/test_path_builder.cpp | 8 +- .../test/test_schema_builder.cpp | 63 +++--- 8 files changed, 148 insertions(+), 195 deletions(-) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/config.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/config.hpp index 61a32e73..85a8d5e7 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/config.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/config.hpp @@ -170,15 +170,20 @@ inline constexpr std::string_view dto_name = "Configurat // Parsed by handle_set_configuration via parse_body. // // Wire keys (SOVD convention, from config_handlers.cpp): -// data - configuration value to set (free-form: any JSON scalar or object) +// data - configuration value to set (free-form: any JSON scalar or object) +// value - legacy alias accepted as fallback (used by older clients) +// +// At least one of "data" or "value" must be present; handler enforces this +// after parse and prefers "data" when both are supplied. // ============================================================================= struct ConfigurationWriteRequest { - nlohmann::json data; // free-form: any JSON value + std::optional data; // preferred + std::optional value; // legacy alias, accepted as a fallback }; template <> inline constexpr auto dto_fields = - std::make_tuple(field("data", &ConfigurationWriteRequest::data)); + std::make_tuple(field("data", &ConfigurationWriteRequest::data), field("value", &ConfigurationWriteRequest::value)); template <> inline constexpr std::string_view dto_name = "ConfigurationWriteRequest"; @@ -261,6 +266,7 @@ struct dto_sample { static ConfigurationWriteRequest make() { ConfigurationWriteRequest obj; obj.data = nlohmann::json{42}; // non-null: int scalar representative value + // value intentionally omitted; data is the preferred field return obj; } }; diff --git a/src/ros2_medkit_gateway/src/http/handlers/config_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/config_handlers.cpp index a99922ab..a1a57be0 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/config_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/config_handlers.cpp @@ -20,7 +20,8 @@ #include "ros2_medkit_gateway/core/http/error_codes.hpp" #include "ros2_medkit_gateway/core/http/fan_out_helpers.hpp" #include "ros2_medkit_gateway/core/http/http_utils.hpp" -#include "ros2_medkit_gateway/core/http/x_medkit.hpp" +#include "ros2_medkit_gateway/dto/config.hpp" +#include "ros2_medkit_gateway/dto/json_writer.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" using json = nlohmann::json; @@ -244,12 +245,14 @@ void ConfigHandlers::handle_list_configurations(const httplib::Request & req, ht json response; response["items"] = json::array(); - XMedkit ext; - ext.entity_id(entity_id).source("runtime"); - ext.add("aggregation_level", agg_configs.aggregation_level); - ext.add("is_aggregated", agg_configs.is_aggregated); - merge_peer_items(ctx_.aggregation_manager(), req, response, ext); - response["x-medkit"] = ext.build(); + dto::ConfigListXMedkit xm; + xm.entity_id = entity_id; + xm.source = "runtime"; + xm.aggregation_level = agg_configs.aggregation_level; + xm.is_aggregated = agg_configs.is_aggregated; + auto xm_json = dto::JsonWriter::write(xm); + merge_peer_items(ctx_.aggregation_manager(), req, response, xm_json); + response["x-medkit"] = std::move(xm_json); HandlerContext::send_json(res, response); return; @@ -311,14 +314,21 @@ void ConfigHandlers::handle_list_configurations(const httplib::Request & req, ht // Add source info for aggregated configurations if (agg_configs.is_aggregated) { - config_meta["x-medkit"] = {{"source", node_info.app_id}}; + dto::ConfigXMedkitItem item_xm; + item_xm.source = node_info.app_id; + config_meta["x-medkit"] = dto::JsonWriter::write(item_xm); } items.push_back(config_meta); // Also track full parameter info json param_with_source = param; - param_with_source["x-medkit"] = {{"source", node_info.app_id}, {"node", node_info.node_fqn}}; + { + dto::ConfigXMedkitItem param_xm; + param_xm.source = node_info.app_id; + param_xm.node = node_info.node_fqn; + param_with_source["x-medkit"] = dto::JsonWriter::write(param_xm); + } all_parameters.push_back(param_with_source); } } @@ -336,19 +346,25 @@ void ConfigHandlers::handle_list_configurations(const httplib::Request & req, ht return; } - // Build x-medkit extension - XMedkit ext; - ext.entity_id(entity_id).source("runtime"); - ext.add("parameters", all_parameters); - ext.add("aggregation_level", agg_configs.aggregation_level); - ext.add("is_aggregated", agg_configs.is_aggregated); - ext.add("source_ids", agg_configs.source_ids); - ext.add("queried_nodes", queried_nodes); + // Build x-medkit extension (typed DTO) + dto::ConfigListXMedkit xm; + xm.entity_id = entity_id; + xm.source = "runtime"; + xm.parameters = all_parameters; + xm.aggregation_level = agg_configs.aggregation_level; + xm.is_aggregated = agg_configs.is_aggregated; + if (!agg_configs.source_ids.empty()) { + xm.source_ids = agg_configs.source_ids; + } + if (!queried_nodes.empty()) { + xm.queried_nodes = queried_nodes; + } json response; response["items"] = items; - merge_peer_items(ctx_.aggregation_manager(), req, response, ext); - response["x-medkit"] = ext.build(); + auto xm_json = dto::JsonWriter::write(xm); + merge_peer_items(ctx_.aggregation_manager(), req, response, xm_json); + response["x-medkit"] = std::move(xm_json); HandlerContext::send_json(res, response); } catch (const std::exception & e) { @@ -411,17 +427,19 @@ void ConfigHandlers::handle_get_configuration(const httplib::Request & req, http auto result = config_mgr->get_parameter(node_info->node_fqn, parsed.param_name); if (result.success) { - json response; - response["id"] = param_id; - response["data"] = result.data.contains("value") ? result.data["value"] : result.data; - - XMedkit ext; - ext.ros2_node(node_info->node_fqn).entity_id(entity_id).source("runtime"); - ext.add("parameter", result.data); - ext.add("source_app", parsed.app_id); - response["x-medkit"] = ext.build(); - - HandlerContext::send_json(res, response); + dto::ConfigValueXMedkit xm; + xm.ros2 = dto::XMedkitRos2{}; + xm.ros2->node = node_info->node_fqn; + xm.entity_id = entity_id; + xm.source = "runtime"; + xm.parameter = result.data; + xm.source_app = parsed.app_id; + + dto::ConfigurationReadValue resp_dto; + resp_dto.id = param_id; + resp_dto.data = result.data.contains("value") ? result.data["value"] : result.data; + resp_dto.x_medkit = std::move(xm); + HandlerContext::send_dto(res, resp_dto); } else { auto err = classify_parameter_error(result); HandlerContext::send_error(res, err.status_code, err.error_code, @@ -440,19 +458,21 @@ void ConfigHandlers::handle_get_configuration(const httplib::Request & req, http auto result = config_mgr->get_parameter(node_info.node_fqn, parsed.param_name); if (result.success) { - json response; - response["id"] = parsed.param_name; - response["data"] = result.data.contains("value") ? result.data["value"] : result.data; - - XMedkit ext; - ext.ros2_node(node_info.node_fqn).entity_id(entity_id).source("runtime"); - ext.add("parameter", result.data); + dto::ConfigValueXMedkit xm; + xm.ros2 = dto::XMedkitRos2{}; + xm.ros2->node = node_info.node_fqn; + xm.entity_id = entity_id; + xm.source = "runtime"; + xm.parameter = result.data; if (agg_configs.is_aggregated) { - ext.add("source_app", node_info.app_id); + xm.source_app = node_info.app_id; } - response["x-medkit"] = ext.build(); - HandlerContext::send_json(res, response); + dto::ConfigurationReadValue resp_dto; + resp_dto.id = parsed.param_name; + resp_dto.data = result.data.contains("value") ? result.data["value"] : result.data; + resp_dto.x_medkit = std::move(xm); + HandlerContext::send_dto(res, resp_dto); return; } @@ -510,25 +530,21 @@ void ConfigHandlers::handle_set_configuration(const httplib::Request & req, http return; } - // Parse request body before checking entity existence - json body; - try { - body = json::parse(req.body); - } catch (const json::parse_error & e) { - HandlerContext::send_error(res, 400, ERR_INVALID_REQUEST, "Invalid JSON in request body", - {{"details", e.what()}}); - return; + // Parse and validate request body via typed DTO. + // Both "data" and "value" fields are optional at the DTO level so that + // legacy clients sending {"value": ...} are not rejected by parse_body. + // We enforce "at least one present" here and prefer "data" over "value". + auto body_opt = ctx_.parse_body(req, res); + if (!body_opt) { + return; // 400 already sent by parse_body } - - // SOVD uses "data" field, but also support legacy "value" field - json value; - if (body.contains("data")) { - value = body["data"]; - } else if (body.contains("value")) { - value = body["value"]; + json config_value; + if (body_opt->data.has_value()) { + config_value = *body_opt->data; + } else if (body_opt->value.has_value()) { + config_value = *body_opt->value; } else { - HandlerContext::send_error(res, 400, ERR_INVALID_REQUEST, "Missing 'data' field", - {{"details", "Request body must contain 'data' field"}}); + HandlerContext::send_error(res, 400, ERR_INVALID_REQUEST, "Request body must contain a 'data' field"); return; } @@ -561,19 +577,21 @@ void ConfigHandlers::handle_set_configuration(const httplib::Request & req, http // Helper to handle set result and send response auto handle_set_result = [&](const auto & result, const std::string & node_fqn, const std::string & app_id) { if (result.success) { - json response; - response["id"] = param_id; - response["data"] = result.data.contains("value") ? result.data["value"] : result.data; - - XMedkit ext; - ext.ros2_node(node_fqn).entity_id(entity_id).source("runtime"); - ext.add("parameter", result.data); + dto::ConfigValueXMedkit xm; + xm.ros2 = dto::XMedkitRos2{}; + xm.ros2->node = node_fqn; + xm.entity_id = entity_id; + xm.source = "runtime"; + xm.parameter = result.data; if (agg_configs.is_aggregated) { - ext.add("source_app", app_id); + xm.source_app = app_id; } - response["x-medkit"] = ext.build(); - HandlerContext::send_json(res, response); + dto::ConfigurationReadValue resp_dto; + resp_dto.id = param_id; + resp_dto.data = result.data.contains("value") ? result.data["value"] : result.data; + resp_dto.x_medkit = std::move(xm); + HandlerContext::send_dto(res, resp_dto); return true; } @@ -590,7 +608,7 @@ void ConfigHandlers::handle_set_configuration(const httplib::Request & req, http return; } - auto result = config_mgr->set_parameter(node_info->node_fqn, parsed.param_name, value); + auto result = config_mgr->set_parameter(node_info->node_fqn, parsed.param_name, config_value); handle_set_result(result, node_info->node_fqn, parsed.app_id); return; } @@ -598,7 +616,7 @@ void ConfigHandlers::handle_set_configuration(const httplib::Request & req, http // For non-aggregated: use the single node if (!agg_configs.is_aggregated && !agg_configs.nodes.empty()) { const auto & node_info = agg_configs.nodes[0]; - auto result = config_mgr->set_parameter(node_info.node_fqn, parsed.param_name, value); + auto result = config_mgr->set_parameter(node_info.node_fqn, parsed.param_name, config_value); handle_set_result(result, node_info.node_fqn, node_info.app_id); return; } @@ -735,29 +753,26 @@ void ConfigHandlers::handle_delete_all_configurations(const httplib::Request & r auto config_mgr = ctx_.node()->get_configuration_manager(); bool all_success = true; - json multi_status = json::array(); + dto::ConfigurationDeleteMultiStatus multi_status_dto; + multi_status_dto.entity_id = entity_id; // Reset all parameters on all nodes for (const auto & node_info : agg_configs.nodes) { auto result = config_mgr->reset_all_parameters(node_info.node_fqn); + dto::ConfigurationDeleteResultItem entry; + entry.node = node_info.node_fqn; + entry.app_id = node_info.app_id; if (!result.success) { all_success = false; - json status_entry; - status_entry["node"] = node_info.node_fqn; - status_entry["app_id"] = node_info.app_id; - status_entry["success"] = false; - status_entry["error"] = result.error_message; - multi_status.push_back(status_entry); + entry.success = false; + entry.error = result.error_message; } else { - json status_entry; - status_entry["node"] = node_info.node_fqn; - status_entry["app_id"] = node_info.app_id; - status_entry["success"] = true; + entry.success = true; if (result.data.is_object() || result.data.is_array()) { - status_entry["details"] = result.data; + entry.details = result.data; } - multi_status.push_back(status_entry); } + multi_status_dto.results.push_back(std::move(entry)); } if (all_success) { @@ -765,11 +780,9 @@ void ConfigHandlers::handle_delete_all_configurations(const httplib::Request & r res.status = 204; } else { // Partial success - return 207 Multi-Status - json response; - response["entity_id"] = entity_id; - response["results"] = multi_status; res.status = 207; - res.set_content(response.dump(2), "application/json"); + res.set_content(dto::JsonWriter::write(multi_status_dto).dump(2), + "application/json"); } } catch (const std::exception & e) { HandlerContext::send_error(res, 500, ERR_INTERNAL_ERROR, "Failed to reset configurations", diff --git a/src/ros2_medkit_gateway/src/http/rest_server.cpp b/src/ros2_medkit_gateway/src/http/rest_server.cpp index 590494a1..2aab4ec2 100644 --- a/src/ros2_medkit_gateway/src/http/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/http/rest_server.cpp @@ -552,7 +552,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("ConfigurationMetaDataList")) + .response(200, "Configuration list", SB::ref("ConfigurationList")) .operation_id(std::string("list") + capitalize(et.singular) + "Configurations"); reg.get(entity_path + "/configurations/{config_id}", @@ -572,7 +572,7 @@ 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("ConfigurationWriteValue")) + .request_body("Configuration value", SB::ref("ConfigurationWriteRequest")) .response(200, "Updated configuration", SB::ref("ConfigurationReadValue")) .operation_id(std::string("set") + capitalize(et.singular) + "Configuration"); diff --git a/src/ros2_medkit_gateway/src/openapi/path_builder.cpp b/src/ros2_medkit_gateway/src/openapi/path_builder.cpp index 91d9e1b7..15d50ab7 100644 --- a/src/ros2_medkit_gateway/src/openapi/path_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/path_builder.cpp @@ -331,8 +331,7 @@ nlohmann::json PathBuilder::build_configurations_collection(const std::string & get_op["description"] = "Returns all configuration parameters for this entity."; 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_metadata_schema()); + get_op["responses"]["200"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("ConfigurationList"); auto errors = error_responses(); for (auto & [code, val] : errors.items()) { @@ -349,7 +348,7 @@ nlohmann::json PathBuilder::build_configurations_collection(const std::string & 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(); + SchemaBuilder::ref("ConfigurationDeleteMultiStatus"); auto del_errors = error_responses(); for (auto & [code, val] : del_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 fdc4e2ea..3bb6d41b 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp @@ -53,40 +53,6 @@ nlohmann::json SchemaBuilder::items_wrapper(const nlohmann::json & item_schema) {"required", {"items"}}}; } -nlohmann::json SchemaBuilder::configuration_metadata_schema() { - return { - {"type", "object"}, - {"properties", - {{"id", {{"type", "string"}, {"description", "Configuration parameter ID"}}}, - {"name", {{"type", "string"}, {"description", "Parameter name"}}}, - {"type", {{"type", "string"}, {"description", "Parameter type (e.g. 'parameter')"}}}, - {"x-medkit", - {{"type", "object"}, - {"description", "Vendor extensions (medkit)"}, - {"properties", - {{"source", - {{"type", "string"}, - {"description", "App ID that owns this parameter (only present in aggregated configurations)"}}}, - {"node", - {{"type", "string"}, - {"description", "Node FQN providing this parameter (only present in aggregated configurations)"}}}}}}}}}, - {"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() { nlohmann::json context_schema = {{"type", "object"}, {"properties", @@ -392,20 +358,6 @@ nlohmann::json SchemaBuilder::trigger_create_request_schema() { {"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", @@ -529,12 +481,6 @@ const std::map & SchemaBuilder::component_schemas() std::map m = { // Core types {"GenericError", generic_error()}, - // Configuration - {"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", log_entry_list_schema()}, diff --git a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp index 4a300cc7..572aa5ec 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp @@ -53,15 +53,6 @@ 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 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(); @@ -128,9 +119,6 @@ class SchemaBuilder { /// 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(); diff --git a/src/ros2_medkit_gateway/test/test_path_builder.cpp b/src/ros2_medkit_gateway/test/test_path_builder.cpp index 0ede7179..51a0fa83 100644 --- a/src/ros2_medkit_gateway/test/test_path_builder.cpp +++ b/src/ros2_medkit_gateway/test/test_path_builder.cpp @@ -293,12 +293,12 @@ TEST_F(PathBuilderTest, ConfigurationsHasGetAndDelete) { EXPECT_FALSE(result.contains("put")); } -TEST_F(PathBuilderTest, ConfigurationsGetReturnsItemsWrapper) { +TEST_F(PathBuilderTest, ConfigurationsGetReturnsConfigurationListRef) { + // build_configurations_collection now emits a $ref to ConfigurationList DTO schema. auto result = path_builder_.build_configurations_collection("apps/sensor"); auto schema = result["get"]["responses"]["200"]["content"]["application/json"]["schema"]; - EXPECT_EQ(schema["type"], "object"); - ASSERT_TRUE(schema.contains("properties")); - ASSERT_TRUE(schema["properties"].contains("items")); + ASSERT_TRUE(schema.contains("$ref")); + EXPECT_EQ(schema["$ref"], "#/components/schemas/ConfigurationList"); } TEST_F(PathBuilderTest, ConfigurationsDeleteHasSummary) { diff --git a/src/ros2_medkit_gateway/test/test_schema_builder.cpp b/src/ros2_medkit_gateway/test/test_schema_builder.cpp index f117f307..2ed5291f 100644 --- a/src/ros2_medkit_gateway/test/test_schema_builder.cpp +++ b/src/ros2_medkit_gateway/test/test_schema_builder.cpp @@ -160,17 +160,18 @@ TEST(SchemaBuilderStaticTest, ItemsWrapper) { } // @verifies REQ_INTEROP_002 -TEST(SchemaBuilderStaticTest, ConfigurationMetaDataSchema) { - auto schema = SchemaBuilder::configuration_metadata_schema(); +TEST(SchemaBuilderStaticTest, ConfigurationMetaDataSchemaFromDto) { + // ConfigurationMetaData is now generated from the DTO; verify via + // component_schemas() which merges DTO-generated schemas on top. + const auto & schemas = SchemaBuilder::component_schemas(); + ASSERT_TRUE(schemas.count("ConfigurationMetaData") > 0); + const auto & schema = schemas.at("ConfigurationMetaData"); 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("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")); @@ -182,37 +183,33 @@ TEST(SchemaBuilderStaticTest, ConfigurationMetaDataSchema) { } // @verifies REQ_INTEROP_002 -TEST(SchemaBuilderStaticTest, ConfigurationMetaDataXMedkitDeclaresAllEmittedFields) { +TEST(SchemaBuilderStaticTest, ConfigurationMetaDataXMedkitDtoDeclaresSourceAndNode) { // Regression: the x-medkit object emitted by config_handlers.cpp on every // per-parameter entry contains both `source` (app_id) and `node` (FQN). - // The schema must declare both, otherwise generated typed clients drop - // or fail-type the undeclared field - exactly the drift this PR fixes - // for x-medkit.phase. additionalProperties is intentionally left open - // (other endpoints use the same convention), so the drift integration - // test cannot detect missing properties here; this static check does. - auto schema = SchemaBuilder::configuration_metadata_schema(); + // The ConfigXMedkitItem DTO declares both; verify via component_schemas(). + const auto & schemas = SchemaBuilder::component_schemas(); + ASSERT_TRUE(schemas.count("ConfigXMedkitItem") > 0); + const auto & schema = schemas.at("ConfigXMedkitItem"); + EXPECT_EQ(schema["type"], "object"); ASSERT_TRUE(schema.contains("properties")); - ASSERT_TRUE(schema.at("properties").contains("x-medkit")); - const auto & x_medkit = schema.at("properties").at("x-medkit"); - EXPECT_EQ(x_medkit.at("type"), "object"); - ASSERT_TRUE(x_medkit.contains("properties")); - const auto & x_props = x_medkit.at("properties"); - ASSERT_TRUE(x_props.contains("source")); - EXPECT_EQ(x_props.at("source").at("type"), "string"); - ASSERT_TRUE(x_props.contains("node")); - EXPECT_EQ(x_props.at("node").at("type"), "string"); + const auto & props = schema["properties"]; + ASSERT_TRUE(props.contains("source")); + ASSERT_TRUE(props.contains("node")); } // @verifies REQ_INTEROP_002 -TEST(SchemaBuilderStaticTest, ConfigurationReadValueSchema) { - auto schema = SchemaBuilder::configuration_read_value_schema(); +TEST(SchemaBuilderStaticTest, ConfigurationReadValueSchemaFromDto) { + // ConfigurationReadValue is now generated from the DTO; verify via + // component_schemas(). + const auto & schemas = SchemaBuilder::component_schemas(); + ASSERT_TRUE(schemas.count("ConfigurationReadValue") > 0); + const auto & schema = schemas.at("ConfigurationReadValue"); 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")); @@ -235,17 +232,21 @@ TEST(SchemaBuilderStaticTest, OperationDetailSchemaComeFromDto) { } // @verifies REQ_INTEROP_002 -TEST(SchemaBuilderStaticTest, ConfigurationWriteValueSchema) { - auto schema = SchemaBuilder::configuration_write_value_schema(); +TEST(SchemaBuilderStaticTest, ConfigurationWriteRequestSchemaFromDto) { + // ConfigurationWriteRequest is now generated from the DTO; verify via + // component_schemas(). + // Both "data" and "value" are optional at schema level - the handler enforces + // that at least one is present and prefers "data" over "value". + const auto & schemas = SchemaBuilder::component_schemas(); + ASSERT_TRUE(schemas.count("ConfigurationWriteRequest") > 0); + const auto & schema = schemas.at("ConfigurationWriteRequest"); EXPECT_EQ(schema["type"], "object"); ASSERT_TRUE(schema.contains("properties")); EXPECT_TRUE(schema["properties"].contains("data")); + EXPECT_TRUE(schema["properties"].contains("value")); 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()); + // No "required" array: both fields are optional at the schema level + EXPECT_FALSE(schema.contains("required")); } // @verifies REQ_INTEROP_002 From ca1ce2251259827ceba9574a726338bf6177cd06 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 12:45:32 +0200 Subject: [PATCH 24/51] feat(gateway): add data DTOs Add XMedkitDataItem, DataItem, XMedkitDataList, DataWriteRequest and Collection (named DataList) to the DTO contract layer. All new types are registered in AllDtos and pass EveryRegisteredDtoRoundTrips. --- .../include/ros2_medkit_gateway/dto/data.hpp | 155 ++++++++++++++++++ .../ros2_medkit_gateway/dto/registry.hpp | 5 +- 2 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/data.hpp diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/data.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/data.hpp new file mode 100644 index 00000000..7bc16b91 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/data.hpp @@ -0,0 +1,155 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "ros2_medkit_gateway/dto/contract.hpp" +#include "ros2_medkit_gateway/dto/entities.hpp" +#include "ros2_medkit_gateway/dto/sample.hpp" +#include "ros2_medkit_gateway/dto/x_medkit.hpp" + +namespace ros2_medkit_gateway { +namespace dto { + +// ============================================================================= +// XMedkitDataItem - x-medkit vendor extension on each item inside the data +// collection list ("items" array). Emitted by handle_list_data per-topic. +// +// Wire keys (from data_handlers.cpp per-item XMedkit builder calls): +// ros2.topic - ROS 2 topic path (via ext.ros2_topic()) +// ros2.direction - topic direction: "publish" | "subscribe" | "both" +// (via ext.add_ros2("direction", ...)) +// ros2.type - ROS 2 message type string (via ext.ros2_type()) +// type_info - dynamic type schema + default_value (free-form JSON; +// only present when type introspection succeeds) +// ============================================================================= +struct XMedkitDataItem { + std::optional ros2; + std::optional type_info; // free-form: dynamic ROS IDL schema +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("ros2", &XMedkitDataItem::ros2), field("type_info", &XMedkitDataItem::type_info)); + +template <> +inline constexpr std::string_view dto_name = "XMedkitDataItem"; + +// ============================================================================= +// DataItem - single item emitted in handle_list_data "items" array. +// +// Wire keys (from data_handlers.cpp handle_list_data per-topic construction): +// id - ROS 2 topic path (used as round-trip ID for GET/PUT) +// name - same as id (topic path) +// category - always "currentData" +// x-medkit - optional vendor extension with ros2 topology + type info +// ============================================================================= +struct DataItem { + std::string id; + std::string name; + std::string category; // always "currentData" + std::optional x_medkit; // wire key: "x-medkit" +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("id", &DataItem::id), field("name", &DataItem::name), field("category", &DataItem::category), + field("x-medkit", &DataItem::x_medkit)); + +template <> +inline constexpr std::string_view dto_name = "DataItem"; + +// ============================================================================= +// XMedkitDataList - x-medkit vendor extension on the data collection list +// response (the top-level response object from handle_list_data). +// +// Wire keys (from data_handlers.cpp handle_list_data response-level XMedkit): +// entity_id - SOVD entity ID being queried +// aggregated - true when cache reports aggregated data +// aggregation_sources - list of source IDs when aggregated +// aggregation_level - aggregation level string when aggregated +// total_count - total number of items in the response +// ============================================================================= +struct XMedkitDataList { + std::optional entity_id; + std::optional aggregated; + std::optional> aggregation_sources; + std::optional aggregation_level; + std::optional total_count; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("entity_id", &XMedkitDataList::entity_id), field("aggregated", &XMedkitDataList::aggregated), + field("aggregation_sources", &XMedkitDataList::aggregation_sources), + field("aggregation_level", &XMedkitDataList::aggregation_level), + field("total_count", &XMedkitDataList::total_count)); + +template <> +inline constexpr std::string_view dto_name = "XMedkitDataList"; + +// ============================================================================= +// DataWriteRequest - PUT request body for /{entity}/data/{id}. +// Parsed by handle_put_data_item via parse_body. +// +// Wire keys (SOVD convention, from data_handlers.cpp handle_put_data_item): +// type - ROS 2 message type string (e.g. "std_msgs/msg/Float32"), required +// data - message value to publish (free-form: any JSON object), required +// ============================================================================= +struct DataWriteRequest { + std::string type; + nlohmann::json data; // free-form: message payload +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("type", &DataWriteRequest::type), field("data", &DataWriteRequest::data)); + +template <> +inline constexpr std::string_view dto_name = "DataWriteRequest"; + +// ============================================================================= +// Collection - named "DataList" +// ============================================================================= +template <> +inline constexpr std::string_view dto_name> = "DataList"; + +// ============================================================================= +// dto_sample specialization for DataWriteRequest. +// +// The generic sample path produces nlohmann::json{} (null) for bare json +// fields, which the round-trip reader treats as missing (null == absent for +// required fields). Provide an explicit sample with a non-null value to +// ensure EveryRegisteredDtoRoundTrips passes. +// ============================================================================= +template <> +struct dto_sample { + static DataWriteRequest make() { + DataWriteRequest obj; + obj.type = "std_msgs/msg/Float32"; + obj.data = nlohmann::json{{"data", 0.0}}; // non-null: representative message value + return obj; + } +}; + +} // namespace dto +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp index 8206cf7a..8b55f046 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp @@ -21,6 +21,7 @@ #include "ros2_medkit_gateway/dto/config.hpp" #include "ros2_medkit_gateway/dto/contract.hpp" +#include "ros2_medkit_gateway/dto/data.hpp" #include "ros2_medkit_gateway/dto/entities.hpp" #include "ros2_medkit_gateway/dto/errors.hpp" #include "ros2_medkit_gateway/dto/faults.hpp" @@ -44,7 +45,9 @@ using AllDtos = // Configuration domain DTOs ConfigXMedkitItem, ConfigurationMetaData, Collection, ConfigListXMedkit, ConfigValueXMedkit, ConfigurationReadValue, ConfigurationWriteRequest, ConfigurationDeleteResultItem, - ConfigurationDeleteMultiStatus>; + ConfigurationDeleteMultiStatus, + // Data domain DTOs + XMedkitDataItem, DataItem, Collection, XMedkitDataList, DataWriteRequest>; namespace detail { template From 4c62658b25a5bc43ebfd3768a904c08b22ea824b Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 12:57:03 +0200 Subject: [PATCH 25/51] refactor(gateway): migrate data handlers to DTO contract Migrate handle_list_data and handle_put_data_item off the legacy XMedkit fluent builder. handle_get_data_item keeps JSON passthrough since its payload shape is only known at runtime (live ROS 2 message). - handle_list_data: builds Collection via JsonWriter; per-item x-medkit typed as XMedkitDataItem; collection x-medkit typed as DataListXMedkit; fan-out uses the JSON overload of merge_peer_items - handle_put_data_item: uses parse_body for the request body; write-response x-medkit typed as XMedkitDataItem - Remove data_item_schema() and data_write_request_schema() factory methods; DataItem, DataList, DataWriteRequest now come from DTO registry - path_builder: data collection route uses ref("DataList") instead of the inline items_wrapper(data_item_schema()) call - rest_server: data collection route updated from DataItemList to DataList - Add direction field to XMedkitRos2 for per-topic data direction - Update test_schema_builder to assert against DTO-generated schema --- .../include/ros2_medkit_gateway/dto/data.hpp | 51 +++++-- .../ros2_medkit_gateway/dto/registry.hpp | 2 +- .../ros2_medkit_gateway/dto/x_medkit.hpp | 3 +- .../src/http/handlers/data_handlers.cpp | 133 +++++++++--------- .../src/http/rest_server.cpp | 2 +- .../src/openapi/path_builder.cpp | 3 +- .../src/openapi/schema_builder.cpp | 22 --- .../src/openapi/schema_builder.hpp | 6 - .../test/test_schema_builder.cpp | 7 +- 9 files changed, 113 insertions(+), 116 deletions(-) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/data.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/data.hpp index 7bc16b91..dc86d99a 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/data.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/data.hpp @@ -15,6 +15,7 @@ #pragma once #include +#include #include #include #include @@ -31,25 +32,47 @@ namespace ros2_medkit_gateway { namespace dto { // ============================================================================= -// XMedkitDataItem - x-medkit vendor extension on each item inside the data -// collection list ("items" array). Emitted by handle_list_data per-topic. +// XMedkitDataItem - x-medkit vendor extension emitted on data collection list +// items (handle_list_data per-topic), on data write responses +// (handle_put_data_item), and on data read responses (handle_get_data_item). // -// Wire keys (from data_handlers.cpp per-item XMedkit builder calls): +// Wire keys for list items (from handle_list_data per-item XMedkit builder): // ros2.topic - ROS 2 topic path (via ext.ros2_topic()) // ros2.direction - topic direction: "publish" | "subscribe" | "both" -// (via ext.add_ros2("direction", ...)) +// (via ext.add_ros2("direction", ...); maps to XMedkitRos2::direction) // ros2.type - ROS 2 message type string (via ext.ros2_type()) // type_info - dynamic type schema + default_value (free-form JSON; // only present when type introspection succeeds) +// +// Additional keys for write responses (from handle_put_data_item): +// entity_id - SOVD entity ID (via ext.entity_id()) +// status - publish result status (via ext.add("status", ...)) +// publisher_created - true when a new publisher was created (via ext.add(...)) +// +// Additional keys for read responses (from handle_get_data_item): +// timestamp - sample timestamp in nanoseconds since epoch (int64) +// publisher_count - number of publishers on the topic at sample time (int64) +// subscriber_count - number of subscribers on the topic at sample time (int64) // ============================================================================= struct XMedkitDataItem { std::optional ros2; - std::optional type_info; // free-form: dynamic ROS IDL schema + std::optional type_info; // free-form: dynamic ROS IDL schema + std::optional entity_id; // SOVD entity ID (write + read responses) + std::optional status; // publish result / sample status + std::optional publisher_created; // true when publisher created (write responses) + std::optional timestamp; // sample timestamp in ns (read responses) + std::optional publisher_count; // publisher count at sample time (read responses) + std::optional subscriber_count; // subscriber count at sample time (read responses) }; template <> inline constexpr auto dto_fields = - std::make_tuple(field("ros2", &XMedkitDataItem::ros2), field("type_info", &XMedkitDataItem::type_info)); + std::make_tuple(field("ros2", &XMedkitDataItem::ros2), field("type_info", &XMedkitDataItem::type_info), + field("entity_id", &XMedkitDataItem::entity_id), field("status", &XMedkitDataItem::status), + field("publisher_created", &XMedkitDataItem::publisher_created), + field("timestamp", &XMedkitDataItem::timestamp), + field("publisher_count", &XMedkitDataItem::publisher_count), + field("subscriber_count", &XMedkitDataItem::subscriber_count)); template <> inline constexpr std::string_view dto_name = "XMedkitDataItem"; @@ -79,7 +102,7 @@ template <> inline constexpr std::string_view dto_name = "DataItem"; // ============================================================================= -// XMedkitDataList - x-medkit vendor extension on the data collection list +// DataListXMedkit - x-medkit vendor extension on the data collection list // response (the top-level response object from handle_list_data). // // Wire keys (from data_handlers.cpp handle_list_data response-level XMedkit): @@ -89,7 +112,7 @@ inline constexpr std::string_view dto_name = "DataItem"; // aggregation_level - aggregation level string when aggregated // total_count - total number of items in the response // ============================================================================= -struct XMedkitDataList { +struct DataListXMedkit { std::optional entity_id; std::optional aggregated; std::optional> aggregation_sources; @@ -98,14 +121,14 @@ struct XMedkitDataList { }; template <> -inline constexpr auto dto_fields = - std::make_tuple(field("entity_id", &XMedkitDataList::entity_id), field("aggregated", &XMedkitDataList::aggregated), - field("aggregation_sources", &XMedkitDataList::aggregation_sources), - field("aggregation_level", &XMedkitDataList::aggregation_level), - field("total_count", &XMedkitDataList::total_count)); +inline constexpr auto dto_fields = + std::make_tuple(field("entity_id", &DataListXMedkit::entity_id), field("aggregated", &DataListXMedkit::aggregated), + field("aggregation_sources", &DataListXMedkit::aggregation_sources), + field("aggregation_level", &DataListXMedkit::aggregation_level), + field("total_count", &DataListXMedkit::total_count)); template <> -inline constexpr std::string_view dto_name = "XMedkitDataList"; +inline constexpr std::string_view dto_name = "DataListXMedkit"; // ============================================================================= // DataWriteRequest - PUT request body for /{entity}/data/{id}. diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp index 8b55f046..c2c0bdd8 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp @@ -47,7 +47,7 @@ using AllDtos = ConfigValueXMedkit, ConfigurationReadValue, ConfigurationWriteRequest, ConfigurationDeleteResultItem, ConfigurationDeleteMultiStatus, // Data domain DTOs - XMedkitDataItem, DataItem, Collection, XMedkitDataList, DataWriteRequest>; + XMedkitDataItem, DataItem, Collection, DataListXMedkit, DataWriteRequest>; namespace detail { template diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/x_medkit.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/x_medkit.hpp index 88995690..31995067 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/x_medkit.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/x_medkit.hpp @@ -42,6 +42,7 @@ struct XMedkitRos2 { std::optional service; std::optional action; std::optional kind; + std::optional direction; // topic data direction: "publish"|"subscribe"|"both" }; template <> @@ -49,7 +50,7 @@ inline constexpr auto dto_fields = std::make_tuple(field("node", &XMedkitRos2::node), field("namespace", &XMedkitRos2::ns), field("type", &XMedkitRos2::type), field("topic", &XMedkitRos2::topic), field("service", &XMedkitRos2::service), field("action", &XMedkitRos2::action), - field("kind", &XMedkitRos2::kind)); + field("kind", &XMedkitRos2::kind), field("direction", &XMedkitRos2::direction)); template <> inline constexpr std::string_view dto_name = "XMedkitRos2"; diff --git a/src/ros2_medkit_gateway/src/http/handlers/data_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/data_handlers.cpp index fe7cc264..041cfdce 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/data_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/data_handlers.cpp @@ -21,9 +21,10 @@ #include "ros2_medkit_gateway/core/http/error_codes.hpp" #include "ros2_medkit_gateway/core/http/fan_out_helpers.hpp" #include "ros2_medkit_gateway/core/http/http_utils.hpp" -#include "ros2_medkit_gateway/core/http/x_medkit.hpp" #include "ros2_medkit_gateway/core/plugins/plugin_manager.hpp" #include "ros2_medkit_gateway/core/providers/data_provider.hpp" +#include "ros2_medkit_gateway/dto/data.hpp" +#include "ros2_medkit_gateway/dto/json_writer.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" #include "ros2_medkit_serialization/type_introspection.hpp" @@ -86,54 +87,63 @@ void DataHandlers::handle_list_data(const httplib::Request & req, httplib::Respo auto data_access_mgr = ctx_.node()->get_data_access_manager(); auto type_introspection = data_access_mgr->get_type_introspection(); - // Build items array with ValueMetadata format - json items = json::array(); + // Build items array with ValueMetadata format (typed DTO) + dto::Collection collection; for (const auto & topic : aggregated.topics) { - json item; + dto::DataItem di; // SOVD required fields - use topic.name directly as ID (clients URL-encode for GET/PUT) - item["id"] = topic.name; - item["name"] = topic.name; - item["category"] = "currentData"; + di.id = topic.name; + di.name = topic.name; + di.category = "currentData"; - // x-medkit extension for ROS2-specific data - XMedkit ext; - ext.ros2_topic(topic.name).add_ros2("direction", topic.direction); + // x-medkit extension for ROS2-specific data (typed DTO) + dto::XMedkitDataItem item_xm; + dto::XMedkitRos2 ros2_meta; + ros2_meta.topic = topic.name; + ros2_meta.direction = topic.direction; // Add type info if available (use cached topic types for O(1) lookup) std::string topic_type = cache.get_topic_type(topic.name); if (!topic_type.empty()) { - ext.ros2_type(topic_type); + ros2_meta.type = topic_type; try { auto type_info = type_introspection->get_type_info(topic_type); json type_info_obj; type_info_obj["schema"] = type_info.schema; type_info_obj["default_value"] = type_info.default_value; - ext.type_info(type_info_obj); + item_xm.type_info = type_info_obj; } catch (const std::exception & e) { RCLCPP_DEBUG(HandlerContext::logger(), "Could not get type info for topic '%s': %s", topic.name.c_str(), e.what()); } } - - item["x-medkit"] = ext.build(); - items.push_back(item); + item_xm.ros2 = ros2_meta; + di.x_medkit = item_xm; + collection.items.push_back(di); } - // Build response with x-medkit for total_count - json response; - response["items"] = items; + // Build response JSON from collection, then attach typed collection x-medkit + json response = dto::JsonWriter>::write(collection); + + // Fan-out peer merge: use JSON-overload to avoid the legacy XMedkit builder + json ext_json = json::object(); + merge_peer_items(ctx_.aggregation_manager(), req, response, ext_json); - XMedkit resp_ext; - resp_ext.entity_id(entity_id); + dto::DataListXMedkit resp_xm; + resp_xm.entity_id = entity_id; if (aggregated.is_aggregated) { - resp_ext.add("aggregated", true); - resp_ext.add("aggregation_sources", aggregated.source_ids); - resp_ext.add("aggregation_level", aggregated.aggregation_level); + resp_xm.aggregated = true; + resp_xm.aggregation_sources = aggregated.source_ids; + resp_xm.aggregation_level = aggregated.aggregation_level; } - merge_peer_items(ctx_.aggregation_manager(), req, response, resp_ext); - resp_ext.add("total_count", response["items"].size()); - response["x-medkit"] = resp_ext.build(); + resp_xm.total_count = response["items"].size(); + // Merge any partial/failed_peers injected by merge_peer_items into the typed xm + json resp_xm_json = dto::JsonWriter::write(resp_xm); + for (const auto & [k, v] : ext_json.items()) { + resp_xm_json[k] = v; + } + response["x-medkit"] = resp_xm_json; HandlerContext::send_json(res, response); } catch (const std::exception & e) { @@ -240,11 +250,13 @@ void DataHandlers::handle_get_data_item(const httplib::Request & req, httplib::R response["data"] = json::object(); } - // Build x-medkit extension with ROS2-specific data - XMedkit ext; - ext.ros2_topic(full_topic_path).entity_id(entity_id); + // Build typed x-medkit extension with ROS2-specific data + dto::XMedkitDataItem xm; + dto::XMedkitRos2 ros2_meta; + ros2_meta.topic = full_topic_path; + xm.entity_id = entity_id; if (!sample.message_type.empty()) { - ext.ros2_type(sample.message_type); + ros2_meta.type = sample.message_type; // Add type_info schema for the message type auto type_introspection = data_access_mgr->get_type_introspection(); @@ -253,17 +265,18 @@ void DataHandlers::handle_get_data_item(const httplib::Request & req, httplib::R json type_info_obj; type_info_obj["schema"] = type_info.schema; type_info_obj["default_value"] = type_info.default_value; - ext.type_info(type_info_obj); + xm.type_info = type_info_obj; } catch (const std::exception & e) { RCLCPP_DEBUG(HandlerContext::logger(), "Could not get type info for topic '%s': %s", full_topic_path.c_str(), e.what()); } } - ext.add("timestamp", sample.timestamp_ns); - ext.add("publisher_count", sample.publisher_count); - ext.add("subscriber_count", sample.subscriber_count); - ext.add("status", sample.has_data ? "data" : "metadata_only"); - response["x-medkit"] = ext.build(); + xm.ros2 = ros2_meta; + xm.timestamp = sample.timestamp_ns; + xm.publisher_count = static_cast(sample.publisher_count); + xm.subscriber_count = static_cast(sample.subscriber_count); + xm.status = json(sample.has_data ? "data" : "metadata_only"); + response["x-medkit"] = dto::JsonWriter::write(xm); HandlerContext::send_json(res, response); } catch (const TopicNotAvailableException & e) { @@ -352,31 +365,13 @@ void DataHandlers::handle_put_data_item(const httplib::Request & req, httplib::R return; } - // Parse request body - json body; - try { - body = json::parse(req.body); - } catch (const json::parse_error & e) { - HandlerContext::send_error(res, 400, ERR_INVALID_REQUEST, "Invalid JSON in request body", - {{"details", e.what()}}); - return; - } - - // Validate required fields: type and data - if (!body.contains("type") || !body["type"].is_string()) { - HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Missing or invalid 'type' field", - {{"details", "Request body must contain 'type' string field"}}); - return; - } - - if (!body.contains("data")) { - HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Missing 'data' field", - {{"details", "Request body must contain 'data' field"}}); - return; + // Parse and validate request body via DTO (checks type:string + data:json required) + auto body_opt = ctx_.parse_body(req, res); + if (!body_opt) { + return; // 400 already sent by parse_body } - - std::string msg_type = body["type"].get(); - json data = body["data"]; + const std::string & msg_type = body_opt->type; + const json & data = body_opt->data; // Validate message type format (e.g., std_msgs/msg/Float32) auto slash_count = static_cast(std::count(msg_type.begin(), msg_type.end(), '/')); @@ -401,20 +396,24 @@ void DataHandlers::handle_put_data_item(const httplib::Request & req, httplib::R auto data_access_mgr = ctx_.node()->get_data_access_manager(); json result = data_access_mgr->publish_to_topic(full_topic_path, msg_type, data); - // Build response with x-medkit extension (id must match what list returns for round-trip) + // Build response with typed x-medkit extension (id must match what list returns for round-trip) json response; response["id"] = full_topic_path; response["data"] = data; // Echo back the written data - XMedkit ext; - ext.ros2_topic(full_topic_path).ros2_type(msg_type).entity_id(entity_id); + dto::XMedkitDataItem xm; + dto::XMedkitRos2 ros2_meta; + ros2_meta.topic = full_topic_path; + ros2_meta.type = msg_type; + xm.ros2 = ros2_meta; + xm.entity_id = entity_id; if (result.contains("status")) { - ext.add("status", result["status"]); + xm.status = result["status"]; } - if (result.contains("publisher_created")) { - ext.add("publisher_created", result["publisher_created"]); + if (result.contains("publisher_created") && result["publisher_created"].is_boolean()) { + xm.publisher_created = result["publisher_created"].get(); } - response["x-medkit"] = ext.build(); + response["x-medkit"] = dto::JsonWriter::write(xm); HandlerContext::send_json(res, response); } catch (const std::exception & e) { diff --git a/src/ros2_medkit_gateway/src/http/rest_server.cpp b/src/ros2_medkit_gateway/src/http/rest_server.cpp index 2aab4ec2..d71f0f74 100644 --- a/src/ros2_medkit_gateway/src/http/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/http/rest_server.cpp @@ -465,7 +465,7 @@ void RESTServer::setup_routes() { .tag("Data") .summary(std::string("List data items for ") + et.singular) .description(std::string("Lists all data items (ROS 2 topics) available on this ") + et.singular + ".") - .response(200, "Data item list", SB::ref("DataItemList")) + .response(200, "Data item list", SB::ref("DataList")) .operation_id(std::string("list") + capitalize(et.singular) + "Data"); // --- Operations --- diff --git a/src/ros2_medkit_gateway/src/openapi/path_builder.cpp b/src/ros2_medkit_gateway/src/openapi/path_builder.cpp index 15d50ab7..a9d12f20 100644 --- a/src/ros2_medkit_gateway/src/openapi/path_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/path_builder.cpp @@ -135,8 +135,7 @@ nlohmann::json PathBuilder::build_data_collection(const std::string & entity_pat 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::data_item_schema()); + get_op["responses"]["200"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("DataList"); auto errors = error_responses(); for (auto & [code, val] : 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 3bb6d41b..b7dedb39 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp @@ -212,16 +212,6 @@ nlohmann::json SchemaBuilder::root_overview_schema() { {"required", {"name", "version", "api_base", "endpoints", "capabilities"}}}; } -nlohmann::json SchemaBuilder::data_item_schema() { - return {{"type", "object"}, - {"properties", - {{"id", {{"type", "string"}}}, - {"name", {{"type", "string"}}}, - {"category", {{"type", "string"}}}, - {"x-medkit", {{"type", "object"}, {"additionalProperties", true}}}}}, - {"required", {"id", "name"}}}; -} - nlohmann::json SchemaBuilder::generic_object_schema() { return {{"type", "object"}}; } @@ -431,14 +421,6 @@ nlohmann::json SchemaBuilder::log_configuration_schema() { {"max_entries", {{"type", "integer"}, {"minimum", 1}, {"maximum", 10000}}}}}}; } -nlohmann::json SchemaBuilder::data_write_request_schema() { - return {{"type", "object"}, - {"properties", - {{"type", {{"type", "string"}, {"description", "ROS 2 message type (e.g. 'std_msgs/msg/Float32')"}}}, - {"data", {{"description", "Message value to publish"}}}}}, - {"required", {"type", "data"}}}; -} - nlohmann::json SchemaBuilder::script_control_request_schema() { return {{"type", "object"}, {"properties", @@ -489,10 +471,6 @@ const std::map & SchemaBuilder::component_schemas() {"HealthStatus", health_schema()}, {"VersionInfo", version_info_schema()}, {"RootOverview", root_overview_schema()}, - // Data - {"DataItem", data_item_schema()}, - {"DataItemList", items_wrapper_ref("DataItem")}, - {"DataWriteRequest", data_write_request_schema()}, // Operations - OperationItem, OperationDetail, OperationExecution, // ExecutionUpdateRequest now come from DTO (dto/operations.hpp). // OperationExecutionList is kept here as a thin wrapper over the DTO type. diff --git a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp index 572aa5ec..da054d9c 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp @@ -71,9 +71,6 @@ class SchemaBuilder { /// API root overview response schema (GET /) static nlohmann::json root_overview_schema(); - /// Data item in collection list - static nlohmann::json data_item_schema(); - /// Generic object schema (for dynamic ROS 2 message payloads) static nlohmann::json generic_object_schema(); @@ -131,9 +128,6 @@ class SchemaBuilder { /// Log configuration schema (GET/PUT) static nlohmann::json log_configuration_schema(); - /// Data write request schema (PUT /data/{id}) - static nlohmann::json data_write_request_schema(); - /// Script control request schema (PUT /scripts/{id}/executions/{id}) static nlohmann::json script_control_request_schema(); diff --git a/src/ros2_medkit_gateway/test/test_schema_builder.cpp b/src/ros2_medkit_gateway/test/test_schema_builder.cpp index 2ed5291f..b3a648c5 100644 --- a/src/ros2_medkit_gateway/test/test_schema_builder.cpp +++ b/src/ros2_medkit_gateway/test/test_schema_builder.cpp @@ -550,8 +550,11 @@ TEST(SchemaBuilderStaticTest, ExtendLockRequestSchema) { } // @verifies REQ_INTEROP_002 -TEST(SchemaBuilderStaticTest, DataWriteRequestSchema) { - auto schema = SchemaBuilder::data_write_request_schema(); +TEST(SchemaBuilderStaticTest, DataWriteRequestSchemaComesFromDto) { + // DataWriteRequest is now generated from the DTO; verify via component_schemas(). + const auto & schemas = SchemaBuilder::component_schemas(); + ASSERT_TRUE(schemas.count("DataWriteRequest") > 0); + const auto & schema = schemas.at("DataWriteRequest"); EXPECT_EQ(schema["type"], "object"); ASSERT_TRUE(schema.contains("properties")); EXPECT_TRUE(schema["properties"].contains("type")); From 3e5b6f0113a5bc8b3a48e5e92615139381200437 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 13:15:38 +0200 Subject: [PATCH 26/51] feat(gateway): add lock DTOs Add Lock, AcquireLockRequest, ExtendLockRequest typed DTOs in dto/locks.hpp. Register all three (plus Collection as LockList) in AllDtos so the EveryRegisteredDtoRoundTrips contract test covers them. Wire shapes match the existing lock_schema / acquire_lock_request_schema / extend_lock_request_schema factories exactly. --- .../include/ros2_medkit_gateway/dto/locks.hpp | 106 ++++++++++++++++++ .../ros2_medkit_gateway/dto/registry.hpp | 5 +- 2 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/locks.hpp diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/locks.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/locks.hpp new file mode 100644 index 00000000..4cd8e9e1 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/locks.hpp @@ -0,0 +1,106 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include +#include +#include + +#include "ros2_medkit_gateway/dto/contract.hpp" +#include "ros2_medkit_gateway/dto/entities.hpp" + +namespace ros2_medkit_gateway { +namespace dto { + +// ============================================================================= +// Lock - SOVD lock response object. +// +// Emitted by all lock response handlers (acquire, list, get): +// handle_acquire_lock (201 body), handle_list_locks (items array element), +// handle_get_lock (200 body). +// +// Wire keys (from LockHandlers::lock_to_json): +// id - lock UUID (required) +// owned - true when X-Client-Id matches the lock owner (required) +// scopes - lock scope strings e.g. ["configurations", "operations"] +// (optional - absent when lock has no specific scopes) +// lock_expiration - ISO 8601 UTC timestamp, e.g. "2026-01-01T00:05:00Z" (required) +// ============================================================================= +struct Lock { + std::string id; + bool owned{false}; + std::optional> scopes; + std::string lock_expiration; // ISO 8601 date-time string +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("id", &Lock::id), field("owned", &Lock::owned), field("scopes", &Lock::scopes), + field("lock_expiration", &Lock::lock_expiration)); + +template <> +inline constexpr std::string_view dto_name = "Lock"; + +// ============================================================================= +// AcquireLockRequest - POST /{entity}/locks request body. +// Parsed by handle_acquire_lock via parse_body. +// +// Wire keys (from handle_acquire_lock body parsing + acquire_lock_request_schema): +// lock_expiration - lock duration in seconds, must be > 0 (required, integer) +// scopes - optional list of scope strings (optional, array of strings) +// break_lock - force-acquire by breaking an existing lock (optional, bool) +// ============================================================================= +struct AcquireLockRequest { + int lock_expiration{0}; // seconds; additional validation: must be > 0 + std::optional> scopes; + std::optional break_lock; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("lock_expiration", &AcquireLockRequest::lock_expiration), + field("scopes", &AcquireLockRequest::scopes), field("break_lock", &AcquireLockRequest::break_lock)); + +template <> +inline constexpr std::string_view dto_name = "AcquireLockRequest"; + +// ============================================================================= +// ExtendLockRequest - PUT /{entity}/locks/{lock_id} request body. +// Parsed by handle_extend_lock via parse_body. +// +// Wire keys (from handle_extend_lock body parsing + extend_lock_request_schema): +// lock_expiration - additional seconds to extend the lock, must be > 0 (required, integer) +// ============================================================================= +struct ExtendLockRequest { + int lock_expiration{0}; // additional seconds; additional validation: must be > 0 +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("lock_expiration", &ExtendLockRequest::lock_expiration)); + +template <> +inline constexpr std::string_view dto_name = "ExtendLockRequest"; + +// ============================================================================= +// Collection - named "LockList" +// ============================================================================= +template <> +inline constexpr std::string_view dto_name> = "LockList"; + +} // namespace dto +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp index c2c0bdd8..984e40d0 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp @@ -25,6 +25,7 @@ #include "ros2_medkit_gateway/dto/entities.hpp" #include "ros2_medkit_gateway/dto/errors.hpp" #include "ros2_medkit_gateway/dto/faults.hpp" +#include "ros2_medkit_gateway/dto/locks.hpp" #include "ros2_medkit_gateway/dto/operations.hpp" #include "ros2_medkit_gateway/dto/schema_writer.hpp" #include "ros2_medkit_gateway/dto/x_medkit.hpp" @@ -47,7 +48,9 @@ using AllDtos = ConfigValueXMedkit, ConfigurationReadValue, ConfigurationWriteRequest, ConfigurationDeleteResultItem, ConfigurationDeleteMultiStatus, // Data domain DTOs - XMedkitDataItem, DataItem, Collection, DataListXMedkit, DataWriteRequest>; + XMedkitDataItem, DataItem, Collection, DataListXMedkit, DataWriteRequest, + // Lock domain DTOs + Lock, Collection, AcquireLockRequest, ExtendLockRequest>; namespace detail { template From 3f4991aef40e5e5fafd945c598c9e667ee54ec7b Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 13:22:35 +0200 Subject: [PATCH 27/51] refactor(gateway): migrate lock handlers to DTO contract Migrate lock_handlers.cpp to typed DTOs: - handle_acquire_lock uses parse_body for JSON body parsing; custom validation (positive expiration, valid scope strings) follows parse_body on the typed struct fields - handle_extend_lock uses parse_body - handle_list_locks returns dto::Collection via send_dto - handle_get_lock and handle_acquire_lock return dto::Lock via send_dto Remove lock_schema / acquire_lock_request_schema / extend_lock_request_schema factory definitions and declarations; Lock, LockList, AcquireLockRequest, ExtendLockRequest in component_schemas() now come from the DTO registry. Update test_schema_builder to verify lock schemas via component_schemas() instead of the removed factory calls. Update AcquireLockWithMissingExpiration test: parse_body uses ERR_INVALID_REQUEST for missing required fields. make format_expiration public to allow file-scope DTO helper access. --- .../core/http/handlers/lock_handlers.hpp | 6 +- .../src/http/handlers/lock_handlers.cpp | 93 ++++++++----------- .../src/openapi/schema_builder.cpp | 41 +------- .../src/openapi/schema_builder.hpp | 9 -- .../test/test_lock_handlers.cpp | 3 +- .../test/test_schema_builder.cpp | 41 ++++++-- 6 files changed, 79 insertions(+), 114 deletions(-) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/lock_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/lock_handlers.hpp index 91435410..3e53c061 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/lock_handlers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/lock_handlers.hpp @@ -91,6 +91,9 @@ class LockHandlers { */ static nlohmann::json lock_to_json(const LockInfo & lock, const std::string & client_id = ""); + /// Format a time_point as ISO 8601 UTC string + static std::string format_expiration(std::chrono::steady_clock::time_point expires_at); + private: HandlerContext & ctx_; LockManager * lock_manager_; @@ -100,9 +103,6 @@ class LockHandlers { /// Extract and validate X-Client-Id header. Returns client_id or empty on error (error sent). std::optional require_client_id(const httplib::Request & req, httplib::Response & res); - - /// Format a time_point as ISO 8601 UTC string - static std::string format_expiration(std::chrono::steady_clock::time_point expires_at); }; } // namespace handlers diff --git a/src/ros2_medkit_gateway/src/http/handlers/lock_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/lock_handlers.cpp index f0cca8c7..19276207 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/lock_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/lock_handlers.cpp @@ -20,6 +20,7 @@ #include #include "ros2_medkit_gateway/core/http/error_codes.hpp" +#include "ros2_medkit_gateway/dto/locks.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" using json = nlohmann::json; @@ -122,6 +123,20 @@ json LockHandlers::lock_to_json(const LockInfo & lock, const std::string & clien return result; } +/** + * @brief Build a typed Lock DTO from a LockInfo. + */ +static dto::Lock lock_info_to_dto(const LockInfo & lock, const std::string & client_id) { + dto::Lock dto; + dto.id = lock.lock_id; + dto.owned = !client_id.empty() && lock.client_id == client_id; + if (!lock.scopes.empty()) { + dto.scopes = lock.scopes; + } + dto.lock_expiration = LockHandlers::format_expiration(lock.expires_at); + return dto; +} + // ============================================================================ // Handler implementations // ============================================================================ @@ -153,39 +168,23 @@ void LockHandlers::handle_acquire_lock(const httplib::Request & req, httplib::Re return; } - // Parse request body - json body; - try { - body = json::parse(req.body); - } catch (const json::parse_error & e) { - HandlerContext::send_error(res, 400, ERR_INVALID_REQUEST, "Invalid JSON in request body", - json{{"details", e.what()}}); - return; + // Parse and validate request body via DTO + auto body_opt = ctx_.parse_body(req, res); + if (!body_opt) { + return; // 400 already sent by parse_body } - // Extract lock_expiration (required) - if (!body.contains("lock_expiration") || !body["lock_expiration"].is_number_integer()) { - HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Missing or invalid lock_expiration", - json{{"details", "lock_expiration must be a positive integer (seconds)"}}); - return; - } - int expiration_seconds = body["lock_expiration"].get(); - if (expiration_seconds <= 0) { + // Validate lock_expiration is positive + if (body_opt->lock_expiration <= 0) { HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Invalid lock_expiration", json{{"details", "lock_expiration must be a positive integer (seconds)"}}); return; } - // Extract scopes (optional) + // Validate individual scope strings std::vector scopes; - if (body.contains("scopes") && body["scopes"].is_array()) { - for (const auto & scope : body["scopes"]) { - if (!scope.is_string()) { - HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Invalid scope value", - json{{"details", "Each scope must be a string"}}); - return; - } - auto scope_str = scope.get(); + if (body_opt->scopes.has_value()) { + for (const auto & scope_str : *body_opt->scopes) { if (valid_lock_scopes().find(scope_str) == valid_lock_scopes().end()) { HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Unknown lock scope: " + scope_str, [&]() { std::string scope_list; @@ -203,20 +202,16 @@ void LockHandlers::handle_acquire_lock(const httplib::Request & req, httplib::Re } } - // Extract break_lock (optional, default false) - bool break_lock = false; - if (body.contains("break_lock") && body["break_lock"].is_boolean()) { - break_lock = body["break_lock"].get(); - } + bool break_lock = body_opt->break_lock.value_or(false); // Acquire the lock - auto result = lock_manager_->acquire(entity_id, client_id, scopes, expiration_seconds, break_lock); + auto result = lock_manager_->acquire(entity_id, client_id, scopes, body_opt->lock_expiration, break_lock); if (result.has_value()) { - auto response = lock_to_json(*result, client_id); + auto lock_dto = lock_info_to_dto(*result, client_id); res.status = 201; res.set_header("Location", req.path + "/" + result->lock_id); - res.set_content(response.dump(2), "application/json"); + HandlerContext::send_dto(res, lock_dto); } else { const auto & err = result.error(); HandlerContext::send_error(res, err.status_code, to_sovd_error_code(err.code), err.message, @@ -257,15 +252,13 @@ void LockHandlers::handle_list_locks(const httplib::Request & req, httplib::Resp // Get lock for this entity auto lock = lock_manager_->get_lock(entity_id); - json response; - response["items"] = json::array(); - + dto::Collection response; if (lock) { - response["items"].push_back(lock_to_json(*lock, client_id)); + response.items.push_back(lock_info_to_dto(*lock, client_id)); } res.status = 200; - HandlerContext::send_json(res, response); + HandlerContext::send_dto(res, response); } catch (const std::exception & e) { HandlerContext::send_error(res, 500, ERR_INTERNAL_ERROR, "Failed to list locks", @@ -309,7 +302,7 @@ void LockHandlers::handle_get_lock(const httplib::Request & req, httplib::Respon } res.status = 200; - HandlerContext::send_json(res, lock_to_json(*lock, client_id)); + HandlerContext::send_dto(res, lock_info_to_dto(*lock, client_id)); } catch (const std::exception & e) { HandlerContext::send_error(res, 500, ERR_INTERNAL_ERROR, "Failed to get lock", @@ -356,30 +349,20 @@ void LockHandlers::handle_extend_lock(const httplib::Request & req, httplib::Res return; } - // Parse request body for new expiration - json body; - try { - body = json::parse(req.body); - } catch (const json::parse_error & e) { - HandlerContext::send_error(res, 400, ERR_INVALID_REQUEST, "Invalid JSON in request body", - json{{"details", e.what()}}); - return; + // Parse and validate request body via DTO + auto body_opt = ctx_.parse_body(req, res); + if (!body_opt) { + return; // 400 already sent by parse_body } - if (!body.contains("lock_expiration") || !body["lock_expiration"].is_number_integer()) { - HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Missing or invalid lock_expiration", - json{{"details", "lock_expiration must be a positive integer (seconds)"}}); - return; - } - int additional_seconds = body["lock_expiration"].get(); - if (additional_seconds <= 0) { + if (body_opt->lock_expiration <= 0) { HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Invalid lock_expiration", json{{"details", "lock_expiration must be a positive integer (seconds)"}}); return; } // Extend the lock - auto result = lock_manager_->extend(entity_id, client_id, additional_seconds); + auto result = lock_manager_->extend(entity_id, client_id, body_opt->lock_expiration); if (result.has_value()) { res.status = 204; diff --git a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp index b7dedb39..2dc5d1e7 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp @@ -258,41 +258,6 @@ nlohmann::json SchemaBuilder::cyclic_subscription_schema() { {"required", {"id", "observed_resource", "event_source", "protocol", "interval"}}}; } -nlohmann::json SchemaBuilder::lock_schema() { - return {{"type", "object"}, - {"properties", - {{"id", {{"type", "string"}}}, - {"owned", {{"type", "boolean"}}}, - {"scopes", {{"type", "array"}, {"items", {{"type", "string"}}}}}, - {"lock_expiration", {{"type", "string"}, {"format", "date-time"}}}}}, - {"required", {"id", "owned", "lock_expiration"}}}; -} - -nlohmann::json SchemaBuilder::acquire_lock_request_schema() { - return {{"type", "object"}, - {"properties", - {{"lock_expiration", - {{"type", "integer"}, {"minimum", 1}, {"example", 300}, {"description", "Lock duration in seconds"}}}, - {"scopes", - {{"type", "array"}, - {"items", {{"type", "string"}}}, - {"description", "Lock scopes (e.g. 'configurations', 'operations')"}}}, - {"break_lock", - {{"type", "boolean"}, {"description", "Force-acquire by breaking an existing lock (default: false)"}}}}}, - {"required", {"lock_expiration"}}}; -} - -nlohmann::json SchemaBuilder::extend_lock_request_schema() { - return {{"type", "object"}, - {"properties", - {{"lock_expiration", - {{"type", "integer"}, - {"minimum", 1}, - {"example", 120}, - {"description", "Additional seconds to extend the lock"}}}}}, - {"required", {"lock_expiration"}}}; -} - nlohmann::json SchemaBuilder::script_metadata_schema() { return {{"type", "object"}, {"properties", @@ -484,11 +449,7 @@ const std::map & SchemaBuilder::component_schemas() {"CyclicSubscription", cyclic_subscription_schema()}, {"CyclicSubscriptionList", items_wrapper_ref("CyclicSubscription")}, {"CyclicSubscriptionCreateRequest", cyclic_subscription_create_request_schema()}, - // Locking - {"Lock", lock_schema()}, - {"LockList", items_wrapper_ref("Lock")}, - {"AcquireLockRequest", acquire_lock_request_schema()}, - {"ExtendLockRequest", extend_lock_request_schema()}, + // Locking - Lock, LockList, AcquireLockRequest, ExtendLockRequest now come from DTO (dto/locks.hpp). // Scripts {"ScriptMetadata", script_metadata_schema()}, {"ScriptMetadataList", items_wrapper_ref("ScriptMetadata")}, diff --git a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp index da054d9c..4c39c5bd 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp @@ -83,15 +83,6 @@ class SchemaBuilder { /// Cyclic subscription schema (CRUD responses) static nlohmann::json cyclic_subscription_schema(); - /// Lock schema (CRUD responses) - static nlohmann::json lock_schema(); - - /// Lock acquire request schema (POST /locks) - static nlohmann::json acquire_lock_request_schema(); - - /// Lock extend request schema (PUT /locks/{id}) - static nlohmann::json extend_lock_request_schema(); - /// Script metadata schema (list/get) static nlohmann::json script_metadata_schema(); diff --git a/src/ros2_medkit_gateway/test/test_lock_handlers.cpp b/src/ros2_medkit_gateway/test/test_lock_handlers.cpp index 8c564037..83fe9de3 100644 --- a/src/ros2_medkit_gateway/test/test_lock_handlers.cpp +++ b/src/ros2_medkit_gateway/test/test_lock_handlers.cpp @@ -390,7 +390,8 @@ TEST_F(LockHandlersTest, AcquireLockWithMissingExpirationReturns400) { EXPECT_EQ(res.status, 400); auto body = json::parse(res.body); - EXPECT_EQ(body["error_code"], "invalid-parameter"); + // parse_body uses ERR_INVALID_REQUEST for missing required fields + EXPECT_EQ(body["error_code"], "invalid-request"); } TEST_F(LockHandlersTest, AcquireLockWithZeroExpirationReturns400) { diff --git a/src/ros2_medkit_gateway/test/test_schema_builder.cpp b/src/ros2_medkit_gateway/test/test_schema_builder.cpp index b3a648c5..8e7cf26b 100644 --- a/src/ros2_medkit_gateway/test/test_schema_builder.cpp +++ b/src/ros2_medkit_gateway/test/test_schema_builder.cpp @@ -517,15 +517,17 @@ TEST(SchemaBuilderRuntimeTest, FromRosSrvResponseUnknown) { } // @verifies REQ_INTEROP_002 -TEST(SchemaBuilderStaticTest, AcquireLockRequestSchema) { - auto schema = SchemaBuilder::acquire_lock_request_schema(); +TEST(SchemaBuilderStaticTest, AcquireLockRequestSchemaComesFromDto) { + // AcquireLockRequest is now generated from the DTO; verify via component_schemas(). + const auto & schemas = SchemaBuilder::component_schemas(); + ASSERT_TRUE(schemas.count("AcquireLockRequest") > 0); + const auto & schema = schemas.at("AcquireLockRequest"); EXPECT_EQ(schema["type"], "object"); ASSERT_TRUE(schema.contains("properties")); EXPECT_TRUE(schema["properties"].contains("lock_expiration")); EXPECT_TRUE(schema["properties"].contains("scopes")); EXPECT_TRUE(schema["properties"].contains("break_lock")); EXPECT_EQ(schema["properties"]["lock_expiration"]["type"], "integer"); - EXPECT_EQ(schema["properties"]["lock_expiration"]["minimum"], 1); EXPECT_EQ(schema["properties"]["scopes"]["type"], "array"); EXPECT_EQ(schema["properties"]["break_lock"]["type"], "boolean"); @@ -536,19 +538,46 @@ TEST(SchemaBuilderStaticTest, AcquireLockRequestSchema) { } // @verifies REQ_INTEROP_002 -TEST(SchemaBuilderStaticTest, ExtendLockRequestSchema) { - auto schema = SchemaBuilder::extend_lock_request_schema(); +TEST(SchemaBuilderStaticTest, ExtendLockRequestSchemaComesFromDto) { + // ExtendLockRequest is now generated from the DTO; verify via component_schemas(). + const auto & schemas = SchemaBuilder::component_schemas(); + ASSERT_TRUE(schemas.count("ExtendLockRequest") > 0); + const auto & schema = schemas.at("ExtendLockRequest"); EXPECT_EQ(schema["type"], "object"); ASSERT_TRUE(schema.contains("properties")); EXPECT_TRUE(schema["properties"].contains("lock_expiration")); EXPECT_EQ(schema["properties"]["lock_expiration"]["type"], "integer"); - EXPECT_EQ(schema["properties"]["lock_expiration"]["minimum"], 1); ASSERT_TRUE(schema.contains("required")); auto required = schema["required"].get>(); EXPECT_NE(std::find(required.begin(), required.end(), "lock_expiration"), required.end()); } +// @verifies REQ_INTEROP_002 +TEST(SchemaBuilderStaticTest, LockSchemaComesFromDto) { + // Lock and LockList are now generated from the DTO; verify via component_schemas(). + const auto & schemas = SchemaBuilder::component_schemas(); + ASSERT_TRUE(schemas.count("Lock") > 0); + const auto & schema = schemas.at("Lock"); + EXPECT_EQ(schema["type"], "object"); + ASSERT_TRUE(schema.contains("properties")); + EXPECT_TRUE(schema["properties"].contains("id")); + EXPECT_TRUE(schema["properties"].contains("owned")); + EXPECT_TRUE(schema["properties"].contains("scopes")); + EXPECT_TRUE(schema["properties"].contains("lock_expiration")); + EXPECT_EQ(schema["properties"]["id"]["type"], "string"); + EXPECT_EQ(schema["properties"]["owned"]["type"], "boolean"); + EXPECT_EQ(schema["properties"]["lock_expiration"]["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(), "owned"), required.end()); + EXPECT_NE(std::find(required.begin(), required.end(), "lock_expiration"), required.end()); + + ASSERT_TRUE(schemas.count("LockList") > 0); +} + // @verifies REQ_INTEROP_002 TEST(SchemaBuilderStaticTest, DataWriteRequestSchemaComesFromDto) { // DataWriteRequest is now generated from the DTO; verify via component_schemas(). From 9f3744c030eb41e1729ce10118006f7c2b670fbc Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 13:34:36 +0200 Subject: [PATCH 28/51] feat(gateway): add trigger DTOs Add dto/triggers.hpp with TriggerCondition, Trigger, TriggerCreateRequest, TriggerUpdateRequest structs and co-located dto_fields/dto_name. The trigger_condition wire field is a free-form merged JSON object (condition_type + additionalProperties), so Trigger and TriggerCreateRequest carry it as nlohmann::json with dto_sample specializations for round-trip safety. Register all new types and Collection (TriggerList) in registry.hpp AllDtos so EveryRegisteredDtoRoundTrips passes. --- .../ros2_medkit_gateway/dto/registry.hpp | 5 +- .../ros2_medkit_gateway/dto/triggers.hpp | 207 ++++++++++++++++++ 2 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/triggers.hpp diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp index 984e40d0..6cb5ac43 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp @@ -28,6 +28,7 @@ #include "ros2_medkit_gateway/dto/locks.hpp" #include "ros2_medkit_gateway/dto/operations.hpp" #include "ros2_medkit_gateway/dto/schema_writer.hpp" +#include "ros2_medkit_gateway/dto/triggers.hpp" #include "ros2_medkit_gateway/dto/x_medkit.hpp" namespace ros2_medkit_gateway { @@ -50,7 +51,9 @@ using AllDtos = // Data domain DTOs XMedkitDataItem, DataItem, Collection, DataListXMedkit, DataWriteRequest, // Lock domain DTOs - Lock, Collection, AcquireLockRequest, ExtendLockRequest>; + Lock, Collection, AcquireLockRequest, ExtendLockRequest, + // Trigger domain DTOs + TriggerCondition, Trigger, Collection, TriggerCreateRequest, TriggerUpdateRequest>; namespace detail { template diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/triggers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/triggers.hpp new file mode 100644 index 00000000..2261c367 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/triggers.hpp @@ -0,0 +1,207 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include +#include +#include + +#include "ros2_medkit_gateway/dto/contract.hpp" +#include "ros2_medkit_gateway/dto/entities.hpp" +#include "ros2_medkit_gateway/dto/enums.hpp" +#include "ros2_medkit_gateway/dto/sample.hpp" + +namespace ros2_medkit_gateway { +namespace dto { + +// ============================================================================= +// TriggerCondition - sub-schema for the trigger condition object. +// +// The wire shape for "trigger_condition" is a flat object where condition_type +// is a required field and any remaining fields are free-form condition +// parameters (additionalProperties: true). This DTO captures condition_type +// for schema-validation purposes; the free-form extras are carried in +// condition_params as a raw JSON value. +// +// Wire keys: +// condition_type - required string discriminator (e.g. "OnChange", "EnterRange") +// condition_params - optional free-form JSON object with any extra params +// (these are MERGED into the flat object on the wire, +// not nested under a "condition_params" key) +// +// Note: when building the wire JSON this DTO is NOT used as a nested struct +// inside Trigger - the handler merges condition_type + condition_params into +// a single flat JSON object that is stored in Trigger.trigger_condition. +// This DTO exists solely to contribute "TriggerCondition" to the OpenAPI +// components/schemas catalog. +// ============================================================================= +struct TriggerCondition { + std::string condition_type; + std::optional condition_params; // free-form extras merged on the wire +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("condition_type", &TriggerCondition::condition_type), + field("condition_params", &TriggerCondition::condition_params)); + +template <> +inline constexpr std::string_view dto_name = "TriggerCondition"; + +// ============================================================================= +// Trigger - SOVD trigger CRUD response object. +// +// Emitted by handle_create (201), handle_list (items element), +// handle_get (200), handle_update (200). +// +// Wire keys (from TriggerHandlers::trigger_to_json): +// id - trigger UUID (required) +// status - enum: "active"|"terminated" (required) +// observed_resource - resource URI being observed (required) +// event_source - server-generated SSE stream URI (required) +// protocol - transport protocol, e.g. "sse" (required) +// trigger_condition - flat JSON object: condition_type + merged condition_params +// (required; free-form, additionalProperties: true) +// multishot - whether trigger fires multiple times (required) +// persistent - whether trigger survives server restarts (required) +// lifetime - trigger lifetime in seconds (optional) +// path - JSON Pointer notification delivery path (optional) +// log_settings - free-form log capture settings (optional) +// ============================================================================= +struct Trigger { + std::string id; + std::string status; // enum: "active"|"terminated" + std::string observed_resource; // wire key: "observed_resource" + std::string event_source; // wire key: "event_source" + std::string protocol; + nlohmann::json trigger_condition; // flat merged object (free-form JSON) + bool multishot{false}; + bool persistent{false}; + std::optional lifetime; + std::optional path; + std::optional log_settings; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("id", &Trigger::id), field_enum("status", &Trigger::status, kTriggerStatusValues), + field("observed_resource", &Trigger::observed_resource), + field("event_source", &Trigger::event_source), field("protocol", &Trigger::protocol), + field("trigger_condition", &Trigger::trigger_condition), field("multishot", &Trigger::multishot), + field("persistent", &Trigger::persistent), field("lifetime", &Trigger::lifetime), + field("path", &Trigger::path), field("log_settings", &Trigger::log_settings)); + +template <> +inline constexpr std::string_view dto_name = "Trigger"; + +// ============================================================================= +// TriggerCreateRequest - POST /{entity}/triggers request body. +// Parsed by handle_create via parse_body. +// +// Wire keys (from handle_create body parsing + trigger_create_request_schema): +// resource - resource URI to observe (required) +// trigger_condition - flat condition object with condition_type + extra fields +// (required; parsed as raw JSON then dissected by handler) +// protocol - transport protocol, default "sse" (optional) +// multishot - fire multiple times (optional) +// persistent - survive server restarts (optional) +// lifetime - lifetime in seconds, must be > 0 (optional) +// path - JSON Pointer delivery path (optional) +// log_settings - free-form log capture settings (optional) +// ============================================================================= +struct TriggerCreateRequest { + std::string resource; + nlohmann::json trigger_condition; // parsed raw; handler extracts condition_type + std::optional protocol; + std::optional multishot; + std::optional persistent; + std::optional lifetime; + std::optional path; + std::optional log_settings; +}; + +template <> +inline constexpr auto dto_fields = std::make_tuple( + field("resource", &TriggerCreateRequest::resource), + field("trigger_condition", &TriggerCreateRequest::trigger_condition), + field("protocol", &TriggerCreateRequest::protocol), field("multishot", &TriggerCreateRequest::multishot), + field("persistent", &TriggerCreateRequest::persistent), field("lifetime", &TriggerCreateRequest::lifetime), + field("path", &TriggerCreateRequest::path), field("log_settings", &TriggerCreateRequest::log_settings)); + +template <> +inline constexpr std::string_view dto_name = "TriggerCreateRequest"; + +// ============================================================================= +// TriggerUpdateRequest - PUT /{entity}/triggers/{trigger_id} request body. +// Parsed by handle_update via parse_body. +// +// Wire keys (from handle_update body parsing + trigger_update_request_schema): +// lifetime - new lifetime in seconds, must be > 0 (required) +// ============================================================================= +struct TriggerUpdateRequest { + int lifetime{0}; // additional validation: must be > 0 +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("lifetime", &TriggerUpdateRequest::lifetime)); + +template <> +inline constexpr std::string_view dto_name = "TriggerUpdateRequest"; + +// ============================================================================= +// Collection - named "TriggerList" +// ============================================================================= +template <> +inline constexpr std::string_view dto_name> = "TriggerList"; + +// ============================================================================= +// dto_sample specializations for DTOs with bare nlohmann::json members. +// +// The generic sample path produces nlohmann::json{} (null) for bare json +// fields, which the round-trip reader treats as missing (null == absent for +// required fields). Provide explicit samples with non-null values so that +// EveryRegisteredDtoRoundTrips passes. +// ============================================================================= +template <> +struct dto_sample { + static Trigger make() { + Trigger obj; + obj.id = "sample"; + obj.status = "active"; + obj.observed_resource = "sample"; + obj.event_source = "sample"; + obj.protocol = "sample"; + obj.trigger_condition = nlohmann::json{{"condition_type", "OnChange"}}; // non-null required field + obj.multishot = true; + obj.persistent = true; + return obj; + } +}; + +template <> +struct dto_sample { + static TriggerCreateRequest make() { + TriggerCreateRequest obj; + obj.resource = "sample"; + obj.trigger_condition = nlohmann::json{{"condition_type", "OnChange"}}; // non-null required field + return obj; + } +}; + +} // namespace dto +} // namespace ros2_medkit_gateway From bcf60f66a19b3693ae90c3e1fab0e53aec3bf3c5 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 13:40:29 +0200 Subject: [PATCH 29/51] refactor(gateway): migrate trigger handlers to DTO contract Replace manual JSON body parsing and response construction in trigger_handlers.cpp with typed DTO operations: - handle_create uses parse_body; static trigger_info_to_dto helper builds the response DTO; send_dto replaces send_json - handle_list builds dto::Collection and calls send_dto - handle_get and handle_update likewise use trigger_info_to_dto + send_dto - handle_update uses parse_body - handle_events (SSE stream) is left untouched Delete the 4 legacy trigger factory functions (trigger_schema, trigger_condition_schema, trigger_create_request_schema, trigger_update_request_schema) from schema_builder.{cpp,hpp}; their schemas now come from the DTO registry. Rewrite the three trigger factory assertions in test_schema_builder.cpp against SchemaWriter so they verify the same wire contract through the DTO path. --- .../core/http/handlers/trigger_handlers.hpp | 3 - .../ros2_medkit_gateway/dto/registry.hpp | 2 +- .../ros2_medkit_gateway/dto/triggers.hpp | 36 +--- .../src/http/handlers/trigger_handlers.cpp | 196 +++++++----------- .../src/openapi/schema_builder.cpp | 56 +---- .../src/openapi/schema_builder.hpp | 12 -- .../test/test_schema_builder.cpp | 26 +-- .../test/test_trigger_handlers.cpp | 95 --------- 8 files changed, 93 insertions(+), 333 deletions(-) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/trigger_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/trigger_handlers.hpp index fafcf2e1..dcf7e374 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/trigger_handlers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/trigger_handlers.hpp @@ -70,9 +70,6 @@ class TriggerHandlers { /// GET /{entity}/triggers/{id}/events - SSE event stream void handle_events(const httplib::Request & req, httplib::Response & res); - /// Convert trigger info to JSON response - static nlohmann::json trigger_to_json(const TriggerInfo & info, const std::string & event_source); - /// Parse resource URI for triggers (includes areas in addition to apps/components/functions). static tl::expected parse_resource_uri(const std::string & resource); diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp index 6cb5ac43..634d051c 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp @@ -53,7 +53,7 @@ using AllDtos = // Lock domain DTOs Lock, Collection, AcquireLockRequest, ExtendLockRequest, // Trigger domain DTOs - TriggerCondition, Trigger, Collection, TriggerCreateRequest, TriggerUpdateRequest>; + Trigger, Collection, TriggerCreateRequest, TriggerUpdateRequest>; namespace detail { template diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/triggers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/triggers.hpp index 2261c367..df2c167e 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/triggers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/triggers.hpp @@ -28,47 +28,13 @@ namespace ros2_medkit_gateway { namespace dto { -// ============================================================================= -// TriggerCondition - sub-schema for the trigger condition object. -// -// The wire shape for "trigger_condition" is a flat object where condition_type -// is a required field and any remaining fields are free-form condition -// parameters (additionalProperties: true). This DTO captures condition_type -// for schema-validation purposes; the free-form extras are carried in -// condition_params as a raw JSON value. -// -// Wire keys: -// condition_type - required string discriminator (e.g. "OnChange", "EnterRange") -// condition_params - optional free-form JSON object with any extra params -// (these are MERGED into the flat object on the wire, -// not nested under a "condition_params" key) -// -// Note: when building the wire JSON this DTO is NOT used as a nested struct -// inside Trigger - the handler merges condition_type + condition_params into -// a single flat JSON object that is stored in Trigger.trigger_condition. -// This DTO exists solely to contribute "TriggerCondition" to the OpenAPI -// components/schemas catalog. -// ============================================================================= -struct TriggerCondition { - std::string condition_type; - std::optional condition_params; // free-form extras merged on the wire -}; - -template <> -inline constexpr auto dto_fields = - std::make_tuple(field("condition_type", &TriggerCondition::condition_type), - field("condition_params", &TriggerCondition::condition_params)); - -template <> -inline constexpr std::string_view dto_name = "TriggerCondition"; - // ============================================================================= // Trigger - SOVD trigger CRUD response object. // // Emitted by handle_create (201), handle_list (items element), // handle_get (200), handle_update (200). // -// Wire keys (from TriggerHandlers::trigger_to_json): +// Wire keys (from trigger_info_to_dto): // id - trigger UUID (required) // status - enum: "active"|"terminated" (required) // observed_resource - resource URI being observed (required) diff --git a/src/ros2_medkit_gateway/src/http/handlers/trigger_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/trigger_handlers.cpp index 6f216ec2..6fec7de1 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/trigger_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/trigger_handlers.cpp @@ -23,6 +23,7 @@ #include "ros2_medkit_gateway/core/http/error_codes.hpp" #include "ros2_medkit_gateway/core/http/http_utils.hpp" #include "ros2_medkit_gateway/core/models/entity_types.hpp" +#include "ros2_medkit_gateway/dto/triggers.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" using json = nlohmann::json; @@ -35,6 +36,45 @@ TriggerHandlers::TriggerHandlers(HandlerContext & ctx, TriggerManager & trigger_ : ctx_(ctx), trigger_mgr_(trigger_mgr), client_tracker_(std::move(client_tracker)) { } +// --------------------------------------------------------------------------- +// Private helper: build a typed Trigger DTO from a TriggerInfo. +// --------------------------------------------------------------------------- +static dto::Trigger trigger_info_to_dto(const TriggerInfo & info, const std::string & event_source) { + dto::Trigger dto; + dto.id = info.id; + dto.status = (info.status == TriggerStatus::ACTIVE) ? "active" : "terminated"; + dto.observed_resource = info.resource_uri; + dto.event_source = event_source; + dto.protocol = info.protocol; + + // Build the flat trigger_condition JSON: condition_type + merged condition_params. + json condition; + condition["condition_type"] = info.condition_type; + if (info.condition_params.is_object()) { + for (auto & [key, val] : info.condition_params.items()) { + condition[key] = val; + } + } + dto.trigger_condition = condition; + + dto.multishot = info.multishot; + dto.persistent = info.persistent; + + if (info.lifetime_sec.has_value()) { + dto.lifetime = info.lifetime_sec.value(); + } + + if (!info.path.empty()) { + dto.path = info.path; + } + + if (info.log_settings.has_value()) { + dto.log_settings = *info.log_settings; + } + + return dto; +} + // --------------------------------------------------------------------------- // POST - create trigger // --------------------------------------------------------------------------- @@ -45,44 +85,36 @@ void TriggerHandlers::handle_create(const httplib::Request & req, httplib::Respo return; } - // Parse JSON body - json body; - try { - body = json::parse(req.body); - } catch (const json::exception &) { - HandlerContext::send_error(res, 400, ERR_INVALID_REQUEST, "Invalid JSON request body"); - return; + // Parse and validate request body via DTO. + auto body_opt = ctx_.parse_body(req, res); + if (!body_opt) { + return; // 400 already sent by parse_body } + const auto & body = *body_opt; - // Validate required fields - if (!body.contains("resource") || !body["resource"].is_string()) { - HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Missing or invalid 'resource'", - {{"parameter", "resource"}}); - return; - } - - if (!body.contains("trigger_condition") || !body["trigger_condition"].is_object()) { + // Validate trigger_condition is an object. + if (!body.trigger_condition.is_object()) { HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Missing or invalid 'trigger_condition'", {{"parameter", "trigger_condition"}}); return; } - auto trigger_condition = body["trigger_condition"]; - if (!trigger_condition.contains("condition_type") || !trigger_condition["condition_type"].is_string()) { + auto trigger_condition_json = body.trigger_condition; + if (!trigger_condition_json.contains("condition_type") || !trigger_condition_json["condition_type"].is_string()) { HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Missing or invalid 'condition_type' in trigger_condition", {{"parameter", "trigger_condition.condition_type"}}); return; } - std::string condition_type = trigger_condition["condition_type"].get(); + std::string condition_type = trigger_condition_json["condition_type"].get(); - // Extract condition_params (everything in trigger_condition except condition_type) - json condition_params = trigger_condition; + // Extract condition_params (everything in trigger_condition except condition_type). + json condition_params = trigger_condition_json; condition_params.erase("condition_type"); - // Parse resource URI - std::string resource = body["resource"].get(); + // Parse resource URI. + const std::string & resource = body.resource; auto parsed = parse_resource_uri(resource); if (!parsed) { HandlerContext::send_error(res, 400, ERR_X_MEDKIT_INVALID_RESOURCE_URI, "Invalid resource URI: " + parsed.error(), @@ -90,7 +122,7 @@ void TriggerHandlers::handle_create(const httplib::Request & req, httplib::Respo return; } - // Validate resource URI references the same entity as the route + // Validate resource URI references the same entity as the route. std::string entity_type = extract_entity_type(req); if (parsed->entity_type != entity_type || parsed->entity_id != entity_id) { HandlerContext::send_error(res, 400, ERR_X_MEDKIT_ENTITY_MISMATCH, @@ -99,7 +131,7 @@ void TriggerHandlers::handle_create(const httplib::Request & req, httplib::Respo return; } - // Validate collection + // Validate collection. static const std::unordered_set known_collections = {"data", "faults", "operations", "updates", "logs"}; if (known_collections.find(parsed->collection) == known_collections.end() && parsed->collection.substr(0, 2) != "x-") { @@ -110,13 +142,8 @@ void TriggerHandlers::handle_create(const httplib::Request & req, httplib::Respo return; } - // Parse optional fields - std::string path; - if (body.contains("path") && body["path"].is_string()) { - path = body["path"].get(); - } - - // Validate JSON Pointer path + // Validate path (JSON Pointer). + std::string path = body.path.value_or(std::string{}); if (!path.empty()) { if (path.size() > 1024) { HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Path too long (max 1024)", {{"parameter", "path"}}); @@ -131,31 +158,20 @@ void TriggerHandlers::handle_create(const httplib::Request & req, httplib::Respo } } - std::string protocol = "sse"; - if (body.contains("protocol") && body["protocol"].is_string()) { - protocol = body["protocol"].get(); - } - - // Validate protocol + // Validate protocol (default: sse). + std::string protocol = body.protocol.value_or(std::string{"sse"}); if (protocol != "sse") { HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Unsupported protocol. Supported: 'sse'", {{"parameter", "protocol"}, {"value", protocol}}); return; } - bool multishot = false; - if (body.contains("multishot") && body["multishot"].is_boolean()) { - multishot = body["multishot"].get(); - } - - bool persistent = false; - if (body.contains("persistent") && body["persistent"].is_boolean()) { - persistent = body["persistent"].get(); - } + bool multishot = body.multishot.value_or(false); + bool persistent = body.persistent.value_or(false); std::optional lifetime_sec; - if (body.contains("lifetime") && body["lifetime"].is_number_integer()) { - lifetime_sec = body["lifetime"].get(); + if (body.lifetime.has_value()) { + lifetime_sec = body.lifetime.value(); if (*lifetime_sec <= 0) { HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Lifetime must be a positive integer (seconds)", {{"parameter", "lifetime"}, {"value", *lifetime_sec}}); @@ -164,8 +180,8 @@ void TriggerHandlers::handle_create(const httplib::Request & req, httplib::Respo } std::optional log_settings; - if (body.contains("log_settings") && body["log_settings"].is_object()) { - log_settings = body["log_settings"]; + if (body.log_settings.has_value() && body.log_settings->is_object()) { + log_settings = body.log_settings; } // For data triggers, resolve the resource_path URI segment to a full ROS 2 topic name. @@ -195,7 +211,7 @@ void TriggerHandlers::handle_create(const httplib::Request & req, httplib::Respo } } - // Build create request + // Build create request. TriggerCreateRequest create_req; create_req.entity_id = entity_id; create_req.entity_type = entity_type; @@ -241,10 +257,8 @@ void TriggerHandlers::handle_create(const httplib::Request & req, httplib::Respo } auto event_source = build_event_source(*result); - auto response_json = trigger_to_json(*result, event_source); - res.status = 201; - HandlerContext::send_json(res, response_json); + HandlerContext::send_dto(res, trigger_info_to_dto(*result, event_source)); } // --------------------------------------------------------------------------- @@ -258,14 +272,12 @@ void TriggerHandlers::handle_list(const httplib::Request & req, httplib::Respons } auto triggers = trigger_mgr_.list(entity_id); - json items = json::array(); + dto::Collection response; for (const auto & trig : triggers) { - items.push_back(trigger_to_json(trig, build_event_source(trig))); + response.items.push_back(trigger_info_to_dto(trig, build_event_source(trig))); } - json response; - response["items"] = items; - HandlerContext::send_json(res, response); + HandlerContext::send_dto(res, response); } // --------------------------------------------------------------------------- @@ -285,7 +297,7 @@ void TriggerHandlers::handle_get(const httplib::Request & req, httplib::Response return; } - HandlerContext::send_json(res, trigger_to_json(*trig, build_event_source(*trig))); + HandlerContext::send_dto(res, trigger_info_to_dto(*trig, build_event_source(*trig))); } // --------------------------------------------------------------------------- @@ -300,30 +312,21 @@ void TriggerHandlers::handle_update(const httplib::Request & req, httplib::Respo auto trigger_id = req.matches[2].str(); - // Parse JSON body - json body; - try { - body = json::parse(req.body); - } catch (const json::exception &) { - HandlerContext::send_error(res, 400, ERR_INVALID_REQUEST, "Invalid JSON request body"); - return; - } - - if (!body.contains("lifetime") || !body["lifetime"].is_number_integer()) { - HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Missing or invalid 'lifetime'", - {{"parameter", "lifetime"}}); - return; + // Parse and validate request body via DTO. + auto body_opt = ctx_.parse_body(req, res); + if (!body_opt) { + return; // 400 already sent by parse_body } + const auto & body = *body_opt; - int new_lifetime = body["lifetime"].get(); - + int new_lifetime = body.lifetime; if (new_lifetime <= 0) { HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Lifetime must be a positive integer", {{"parameter", "lifetime"}, {"value", new_lifetime}}); return; } - // Verify trigger exists and belongs to this entity before updating + // Verify trigger exists and belongs to this entity before updating. auto existing = trigger_mgr_.get(trigger_id); if (!existing || existing->entity_id != entity_id) { HandlerContext::send_error(res, 404, ERR_RESOURCE_NOT_FOUND, "Trigger not found", {{"trigger_id", trigger_id}}); @@ -342,7 +345,7 @@ void TriggerHandlers::handle_update(const httplib::Request & req, httplib::Respo return; } - HandlerContext::send_json(res, trigger_to_json(*result, build_event_source(*result))); + HandlerContext::send_dto(res, trigger_info_to_dto(*result, build_event_source(*result))); } // --------------------------------------------------------------------------- @@ -460,45 +463,6 @@ void TriggerHandlers::handle_events(const httplib::Request & req, httplib::Respo }); } -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- -json TriggerHandlers::trigger_to_json(const TriggerInfo & info, const std::string & event_source) { - json j; - j["id"] = info.id; - j["status"] = (info.status == TriggerStatus::ACTIVE) ? "active" : "terminated"; - j["observed_resource"] = info.resource_uri; - j["event_source"] = event_source; - j["protocol"] = info.protocol; - - json condition; - condition["condition_type"] = info.condition_type; - // Merge condition_params into the condition object - if (info.condition_params.is_object()) { - for (auto & [key, val] : info.condition_params.items()) { - condition[key] = val; - } - } - j["trigger_condition"] = condition; - - j["multishot"] = info.multishot; - j["persistent"] = info.persistent; - - if (info.lifetime_sec.has_value()) { - j["lifetime"] = info.lifetime_sec.value(); - } - - if (!info.path.empty()) { - j["path"] = info.path; - } - - if (info.log_settings.has_value()) { - j["log_settings"] = *info.log_settings; - } - - return j; -} - std::string TriggerHandlers::build_event_source(const TriggerInfo & info) { return std::string(API_BASE_PATH) + "/" + info.entity_type + "/" + info.entity_id + "/triggers/" + info.id + "/events"; diff --git a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp index 2dc5d1e7..344ebca5 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp @@ -220,32 +220,6 @@ nlohmann::json SchemaBuilder::binary_schema() { return {{"type", "string"}, {"format", "binary"}}; } -nlohmann::json SchemaBuilder::trigger_condition_schema() { - return {{"type", "object"}, - {"properties", {{"condition_type", {{"type", "string"}}}}}, - {"required", {"condition_type"}}, - {"additionalProperties", true}}; -} - -nlohmann::json SchemaBuilder::trigger_schema() { - auto condition_schema = trigger_condition_schema(); - - return {{"type", "object"}, - {"properties", - {{"id", {{"type", "string"}}}, - {"status", {{"type", "string"}, {"enum", {"active", "terminated"}}}}, - {"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"}, {"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"}, @@ -290,29 +264,6 @@ nlohmann::json SchemaBuilder::script_upload_response_schema() { {"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() { - auto condition_schema = trigger_condition_schema(); - - 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::cyclic_subscription_create_request_schema() { return {{"type", "object"}, {"properties", @@ -440,11 +391,8 @@ const std::map & SchemaBuilder::component_schemas() // ExecutionUpdateRequest now come from DTO (dto/operations.hpp). // OperationExecutionList is kept here as a thin wrapper over the DTO type. {"OperationExecutionList", items_wrapper_ref("OperationExecution")}, - // Triggers - {"Trigger", trigger_schema()}, - {"TriggerList", items_wrapper_ref("Trigger")}, - {"TriggerUpdateRequest", trigger_update_request_schema()}, - {"TriggerCreateRequest", trigger_create_request_schema()}, + // Triggers - Trigger, TriggerList, TriggerCreateRequest, TriggerUpdateRequest + // now come from DTO (dto/triggers.hpp). // Subscriptions {"CyclicSubscription", cyclic_subscription_schema()}, {"CyclicSubscriptionList", items_wrapper_ref("CyclicSubscription")}, diff --git a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp index 4c39c5bd..c443737f 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp @@ -77,9 +77,6 @@ class SchemaBuilder { /// Binary content schema (for file downloads) static nlohmann::json binary_schema(); - /// Trigger schema (CRUD responses) - static nlohmann::json trigger_schema(); - /// Cyclic subscription schema (CRUD responses) static nlohmann::json cyclic_subscription_schema(); @@ -98,15 +95,6 @@ class SchemaBuilder { /// Script upload response schema (minimal: id + name) static nlohmann::json script_upload_response_schema(); - /// Trigger condition sub-schema (shared by trigger response and create request) - static nlohmann::json trigger_condition_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(); - /// Cyclic subscription create request schema static nlohmann::json cyclic_subscription_create_request_schema(); diff --git a/src/ros2_medkit_gateway/test/test_schema_builder.cpp b/src/ros2_medkit_gateway/test/test_schema_builder.cpp index 8e7cf26b..fbdc70dc 100644 --- a/src/ros2_medkit_gateway/test/test_schema_builder.cpp +++ b/src/ros2_medkit_gateway/test/test_schema_builder.cpp @@ -20,6 +20,9 @@ #include #include "../src/openapi/schema_builder.hpp" +#include "ros2_medkit_gateway/dto/registry.hpp" +#include "ros2_medkit_gateway/dto/schema_writer.hpp" +#include "ros2_medkit_gateway/dto/triggers.hpp" using ros2_medkit_gateway::openapi::SchemaBuilder; @@ -267,7 +270,9 @@ TEST(SchemaBuilderStaticTest, ScriptUploadResponseSchema) { // @verifies REQ_INTEROP_002 TEST(SchemaBuilderStaticTest, TriggerUpdateRequestSchema) { - auto schema = SchemaBuilder::trigger_update_request_schema(); + // TriggerUpdateRequest is now a DTO - verify via SchemaWriter. + namespace dto = ros2_medkit_gateway::dto; + auto schema = dto::SchemaWriter::schema(); EXPECT_EQ(schema["type"], "object"); ASSERT_TRUE(schema.contains("properties")); EXPECT_TRUE(schema["properties"].contains("lifetime")); @@ -276,8 +281,6 @@ TEST(SchemaBuilderStaticTest, TriggerUpdateRequestSchema) { 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 @@ -342,7 +345,9 @@ TEST(SchemaBuilderStaticTest, CyclicSubscriptionCreateRequestSchema) { // @verifies REQ_INTEROP_002 TEST(SchemaBuilderStaticTest, TriggerCreateRequestSchema) { - auto schema = SchemaBuilder::trigger_create_request_schema(); + // TriggerCreateRequest is now a DTO - verify via SchemaWriter. + namespace dto = ros2_medkit_gateway::dto; + auto schema = dto::SchemaWriter::schema(); EXPECT_EQ(schema["type"], "object"); ASSERT_TRUE(schema.contains("properties")); EXPECT_TRUE(schema["properties"].contains("resource")); @@ -654,19 +659,6 @@ TEST(SchemaBuilderStaticTest, LogConfigurationSchemaFieldsOptional) { EXPECT_EQ(schema["properties"]["max_entries"]["maximum"], 10000); } -// @verifies REQ_INTEROP_002 -TEST(SchemaBuilderStaticTest, TriggerConditionSchemaShared) { - auto schema = SchemaBuilder::trigger_condition_schema(); - EXPECT_EQ(schema["type"], "object"); - ASSERT_TRUE(schema.contains("properties")); - EXPECT_TRUE(schema["properties"].contains("condition_type")); - EXPECT_TRUE(schema["additionalProperties"].get()); - - ASSERT_TRUE(schema.contains("required")); - auto required = schema["required"].get>(); - EXPECT_NE(std::find(required.begin(), required.end(), "condition_type"), required.end()); -} - // ============================================================================= // Schema registry consistency tests // Validates that all $ref references resolve and schemas are internally consistent. diff --git a/src/ros2_medkit_gateway/test/test_trigger_handlers.cpp b/src/ros2_medkit_gateway/test/test_trigger_handlers.cpp index 7e89e427..c5bd8078 100644 --- a/src/ros2_medkit_gateway/test/test_trigger_handlers.cpp +++ b/src/ros2_medkit_gateway/test/test_trigger_handlers.cpp @@ -113,101 +113,6 @@ TEST(TriggerParseResourceUriTest, PathTraversalAtEndRejected) { EXPECT_FALSE(result.has_value()); } -// =========================================================================== -// trigger_to_json tests -// =========================================================================== - -// @verifies REQ_INTEROP_029 -// @verifies REQ_INTEROP_096 -// @verifies REQ_INTEROP_097 -TEST(TriggerToJsonTest, ContainsAllRequiredFields) { - TriggerInfo info; - info.id = "trig_1"; - info.entity_id = "temp_sensor"; - info.entity_type = "apps"; - info.resource_uri = "/api/v1/apps/temp_sensor/data/temperature"; - info.condition_type = "OnChange"; - info.condition_params = json::object(); - info.protocol = "sse"; - info.multishot = false; - info.persistent = false; - info.status = TriggerStatus::ACTIVE; - - std::string event_source = "/api/v1/apps/temp_sensor/triggers/trig_1/events"; - auto j = TriggerHandlers::trigger_to_json(info, event_source); - - EXPECT_EQ(j["id"], "trig_1"); - EXPECT_EQ(j["status"], "active"); - EXPECT_EQ(j["observed_resource"], info.resource_uri); - EXPECT_EQ(j["event_source"], event_source); - EXPECT_EQ(j["protocol"], "sse"); - EXPECT_TRUE(j.contains("trigger_condition")); - EXPECT_EQ(j["trigger_condition"]["condition_type"], "OnChange"); - EXPECT_EQ(j["multishot"], false); - EXPECT_EQ(j["persistent"], false); - EXPECT_FALSE(j.contains("lifetime")); - EXPECT_FALSE(j.contains("path")); -} - -TEST(TriggerToJsonTest, IncludesConditionParams) { - TriggerInfo info; - info.id = "trig_2"; - info.entity_id = "sensor"; - info.entity_type = "apps"; - info.resource_uri = "/api/v1/apps/sensor/data/temperature"; - info.condition_type = "EnterRange"; - info.condition_params = {{"lower_bound", 20.0}, {"upper_bound", 30.0}}; - info.protocol = "sse"; - info.multishot = true; - info.persistent = false; - info.status = TriggerStatus::ACTIVE; - - auto j = TriggerHandlers::trigger_to_json(info, "/events"); - - EXPECT_EQ(j["trigger_condition"]["condition_type"], "EnterRange"); - EXPECT_DOUBLE_EQ(j["trigger_condition"]["lower_bound"].get(), 20.0); - EXPECT_DOUBLE_EQ(j["trigger_condition"]["upper_bound"].get(), 30.0); - EXPECT_EQ(j["multishot"], true); -} - -TEST(TriggerToJsonTest, IncludesLifetimeAndPath) { - TriggerInfo info; - info.id = "trig_3"; - info.entity_id = "sensor"; - info.entity_type = "apps"; - info.resource_uri = "/api/v1/apps/sensor/data/temperature"; - info.condition_type = "OnChange"; - info.condition_params = json::object(); - info.protocol = "sse"; - info.multishot = false; - info.persistent = true; - info.status = TriggerStatus::ACTIVE; - info.lifetime_sec = 3600; - info.path = "/data"; - - auto j = TriggerHandlers::trigger_to_json(info, "/events"); - - EXPECT_EQ(j["lifetime"], 3600); - EXPECT_EQ(j["path"], "/data"); - EXPECT_EQ(j["persistent"], true); -} - -TEST(TriggerToJsonTest, TerminatedStatus) { - TriggerInfo info; - info.id = "trig_4"; - info.entity_id = "sensor"; - info.entity_type = "apps"; - info.resource_uri = "/api/v1/apps/sensor/data/temperature"; - info.condition_type = "OnChange"; - info.condition_params = json::object(); - info.protocol = "sse"; - info.status = TriggerStatus::TERMINATED; - - auto j = TriggerHandlers::trigger_to_json(info, "/events"); - - EXPECT_EQ(j["status"], "terminated"); -} - // =========================================================================== // Error response format tests // =========================================================================== From 2b2caa5cc21ddced5e3019162d0f1b2f81b737c4 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 13:58:04 +0200 Subject: [PATCH 30/51] feat(gateway): add cyclic subscription DTOs Add dto/cyclic_subscriptions.hpp with three typed structs: - CyclicSubscription: CRUD response (id, observed_resource, event_source, protocol, interval enum) - CyclicSubscriptionCreateRequest: POST body (resource, interval enum, duration, protocol optional) - CyclicSubscriptionUpdateRequest: PUT body (interval optional enum, duration optional int) Collection named "CyclicSubscriptionList". All four types added to AllDtos in registry.hpp. test_dto_contract EveryRegisteredDtoRoundTrips passes. --- .../dto/cyclic_subscriptions.hpp | 115 ++++++++++++++++++ .../ros2_medkit_gateway/dto/registry.hpp | 6 +- 2 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/cyclic_subscriptions.hpp diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/cyclic_subscriptions.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/cyclic_subscriptions.hpp new file mode 100644 index 00000000..2c5df002 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/cyclic_subscriptions.hpp @@ -0,0 +1,115 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include +#include + +#include "ros2_medkit_gateway/dto/contract.hpp" +#include "ros2_medkit_gateway/dto/entities.hpp" +#include "ros2_medkit_gateway/dto/enums.hpp" + +namespace ros2_medkit_gateway { +namespace dto { + +// ============================================================================= +// CyclicSubscription - SOVD cyclic subscription CRUD response object. +// +// Emitted by handle_create (201), handle_list (items element), +// handle_get (200), handle_update (200). +// +// Wire keys (from CyclicSubscriptionHandlers::subscription_to_json): +// id - subscription UUID (required) +// observed_resource - resource URI being observed (required) +// event_source - server-generated SSE stream URI (required) +// protocol - transport protocol, e.g. "sse" (required) +// interval - enum: "fast"|"normal"|"slow" (required) +// ============================================================================= +struct CyclicSubscription { + std::string id; + std::string observed_resource; // wire key: "observed_resource" + std::string event_source; // wire key: "event_source" + std::string protocol; + std::string interval; // enum: "fast"|"normal"|"slow" +}; + +template <> +inline constexpr auto dto_fields = std::make_tuple( + field("id", &CyclicSubscription::id), field("observed_resource", &CyclicSubscription::observed_resource), + field("event_source", &CyclicSubscription::event_source), field("protocol", &CyclicSubscription::protocol), + field_enum("interval", &CyclicSubscription::interval, kCyclicSubscriptionIntervalValues)); + +template <> +inline constexpr std::string_view dto_name = "CyclicSubscription"; + +// ============================================================================= +// CyclicSubscriptionCreateRequest - POST /{entity}/cyclic-subscriptions body. +// Parsed by handle_create via parse_body. +// +// Wire keys (from handle_create body parsing + +// cyclic_subscription_create_request_schema): +// resource - resource URI to subscribe to (required) +// interval - enum: "fast"|"normal"|"slow" (required) +// duration - subscription duration in seconds, must be > 0 (required) +// protocol - transport protocol, default "sse" (optional) +// ============================================================================= +struct CyclicSubscriptionCreateRequest { + std::string resource; + std::string interval; // enum: "fast"|"normal"|"slow" + int duration{0}; // seconds; additional validation: must be > 0 + std::optional protocol; +}; + +template <> +inline constexpr auto dto_fields = std::make_tuple( + field("resource", &CyclicSubscriptionCreateRequest::resource), + field_enum("interval", &CyclicSubscriptionCreateRequest::interval, kCyclicSubscriptionIntervalValues), + field("duration", &CyclicSubscriptionCreateRequest::duration), + field("protocol", &CyclicSubscriptionCreateRequest::protocol)); + +template <> +inline constexpr std::string_view dto_name = "CyclicSubscriptionCreateRequest"; + +// ============================================================================= +// CyclicSubscriptionUpdateRequest - PUT /{entity}/cyclic-subscriptions/{id} +// body. Parsed by handle_update via parse_body. +// +// Wire keys (from handle_update body parsing): +// interval - enum: "fast"|"normal"|"slow" (optional) +// duration - new duration in seconds, must be > 0 (optional) +// ============================================================================= +struct CyclicSubscriptionUpdateRequest { + std::optional interval; // enum: "fast"|"normal"|"slow" + std::optional duration; // seconds; additional validation: must be > 0 +}; + +template <> +inline constexpr auto dto_fields = std::make_tuple( + field_enum("interval", &CyclicSubscriptionUpdateRequest::interval, kCyclicSubscriptionIntervalValues), + field("duration", &CyclicSubscriptionUpdateRequest::duration)); + +template <> +inline constexpr std::string_view dto_name = "CyclicSubscriptionUpdateRequest"; + +// ============================================================================= +// Collection - named "CyclicSubscriptionList" +// ============================================================================= +template <> +inline constexpr std::string_view dto_name> = "CyclicSubscriptionList"; + +} // namespace dto +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp index 634d051c..c35bb915 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp @@ -21,6 +21,7 @@ #include "ros2_medkit_gateway/dto/config.hpp" #include "ros2_medkit_gateway/dto/contract.hpp" +#include "ros2_medkit_gateway/dto/cyclic_subscriptions.hpp" #include "ros2_medkit_gateway/dto/data.hpp" #include "ros2_medkit_gateway/dto/entities.hpp" #include "ros2_medkit_gateway/dto/errors.hpp" @@ -53,7 +54,10 @@ using AllDtos = // Lock domain DTOs Lock, Collection, AcquireLockRequest, ExtendLockRequest, // Trigger domain DTOs - Trigger, Collection, TriggerCreateRequest, TriggerUpdateRequest>; + Trigger, Collection, TriggerCreateRequest, TriggerUpdateRequest, + // Cyclic subscription domain DTOs + CyclicSubscription, Collection, CyclicSubscriptionCreateRequest, + CyclicSubscriptionUpdateRequest>; namespace detail { template From 22b72eac8cc8201e4b9afa33b76d1b9292b1d575 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 14:03:42 +0200 Subject: [PATCH 31/51] refactor(gateway): migrate cyclic subscription handlers to DTO contract Replace manual JSON body parsing and response construction in cyclic_subscription_handlers.cpp with typed DTO operations: - handle_create uses parse_body; static subscription_to_dto helper builds the response DTO; send_dto replaces send_json - handle_list builds dto::Collection and calls send_dto - handle_get and handle_update likewise use subscription_to_dto + send_dto - handle_update uses parse_body - handle_events (SSE stream) is left untouched - subscription_to_json preserved as a thin wrapper for test compatibility Delete the 2 legacy cyclic subscription factory functions (cyclic_subscription_schema, cyclic_subscription_create_request_schema) from schema_builder.{cpp,hpp}; their schemas now come from the DTO registry. Repoint path_builder.cpp to use SchemaBuilder::ref for cyclic subscription list, create request, and create response schemas. Fix rest_server.cpp PUT route to reference CyclicSubscriptionUpdateRequest instead of CyclicSubscription for the request body. Rewrite the factory assertion in test_schema_builder.cpp against SchemaWriter and update test_path_builder.cpp to check $ref instead of inline properties. --- .../dto/cyclic_subscriptions.hpp | 16 +-- .../handlers/cyclic_subscription_handlers.cpp | 111 +++++++----------- .../src/http/rest_server.cpp | 2 +- .../src/openapi/path_builder.cpp | 7 +- .../src/openapi/schema_builder.cpp | 28 +---- .../src/openapi/schema_builder.hpp | 6 - .../test/test_path_builder.cpp | 4 +- .../test/test_schema_builder.cpp | 15 +-- 8 files changed, 70 insertions(+), 119 deletions(-) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/cyclic_subscriptions.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/cyclic_subscriptions.hpp index 2c5df002..fe7ed8d1 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/cyclic_subscriptions.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/cyclic_subscriptions.hpp @@ -75,11 +75,11 @@ struct CyclicSubscriptionCreateRequest { }; template <> -inline constexpr auto dto_fields = std::make_tuple( - field("resource", &CyclicSubscriptionCreateRequest::resource), - field_enum("interval", &CyclicSubscriptionCreateRequest::interval, kCyclicSubscriptionIntervalValues), - field("duration", &CyclicSubscriptionCreateRequest::duration), - field("protocol", &CyclicSubscriptionCreateRequest::protocol)); +inline constexpr auto dto_fields = + std::make_tuple(field("resource", &CyclicSubscriptionCreateRequest::resource), + field("interval", &CyclicSubscriptionCreateRequest::interval), + field("duration", &CyclicSubscriptionCreateRequest::duration), + field("protocol", &CyclicSubscriptionCreateRequest::protocol)); template <> inline constexpr std::string_view dto_name = "CyclicSubscriptionCreateRequest"; @@ -98,9 +98,9 @@ struct CyclicSubscriptionUpdateRequest { }; template <> -inline constexpr auto dto_fields = std::make_tuple( - field_enum("interval", &CyclicSubscriptionUpdateRequest::interval, kCyclicSubscriptionIntervalValues), - field("duration", &CyclicSubscriptionUpdateRequest::duration)); +inline constexpr auto dto_fields = + std::make_tuple(field("interval", &CyclicSubscriptionUpdateRequest::interval), + field("duration", &CyclicSubscriptionUpdateRequest::duration)); template <> inline constexpr std::string_view dto_name = "CyclicSubscriptionUpdateRequest"; diff --git a/src/ros2_medkit_gateway/src/http/handlers/cyclic_subscription_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/cyclic_subscription_handlers.cpp index 7d41857b..e0ee2f10 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/cyclic_subscription_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/cyclic_subscription_handlers.cpp @@ -21,6 +21,7 @@ #include "ros2_medkit_gateway/core/http/error_codes.hpp" #include "ros2_medkit_gateway/core/http/http_utils.hpp" #include "ros2_medkit_gateway/core/models/entity_types.hpp" +#include "ros2_medkit_gateway/dto/cyclic_subscriptions.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" using json = nlohmann::json; @@ -38,6 +39,20 @@ CyclicSubscriptionHandlers::CyclicSubscriptionHandlers(HandlerContext & ctx, Sub , max_duration_sec_(max_duration_sec) { } +// --------------------------------------------------------------------------- +// Internal helper: build a CyclicSubscription DTO from CyclicSubscriptionInfo +// --------------------------------------------------------------------------- +static dto::CyclicSubscription subscription_to_dto(const CyclicSubscriptionInfo & info, + const std::string & event_source) { + dto::CyclicSubscription sub; + sub.id = info.id; + sub.observed_resource = info.resource_uri; + sub.event_source = event_source; + sub.protocol = info.protocol; + sub.interval = interval_to_string(info.interval); + return sub; +} + // --------------------------------------------------------------------------- // POST — create subscription // --------------------------------------------------------------------------- @@ -48,39 +63,15 @@ void CyclicSubscriptionHandlers::handle_create(const httplib::Request & req, htt return; } - // Parse JSON body - json body; - try { - body = json::parse(req.body); - } catch (const json::exception &) { - HandlerContext::send_error(res, 400, ERR_INVALID_REQUEST, "Invalid JSON request body"); - return; - } - - // Validate required fields - if (!body.contains("resource") || !body["resource"].is_string()) { - HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Missing or invalid 'resource'", - {{"parameter", "resource"}}); - return; - } - - if (!body.contains("interval") || !body["interval"].is_string()) { - HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Missing or invalid 'interval'", - {{"parameter", "interval"}}); - return; - } - - if (!body.contains("duration") || !body["duration"].is_number_integer()) { - HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Missing or invalid 'duration'", - {{"parameter", "duration"}}); - return; + // Parse JSON body via DTO. + auto body_opt = ctx_.parse_body(req, res); + if (!body_opt) { + return; // 400 already sent by parse_body } + const auto & body = *body_opt; // Validate protocol (optional, defaults to "sse") - std::string protocol = "sse"; - if (body.contains("protocol")) { - protocol = body["protocol"].get(); - } + std::string protocol = body.protocol.value_or(std::string{"sse"}); // Check transport is registered auto * transport = transport_registry_.get_transport(protocol); @@ -93,16 +84,16 @@ void CyclicSubscriptionHandlers::handle_create(const httplib::Request & req, htt // Parse interval CyclicInterval interval; try { - interval = parse_interval(body["interval"].get()); + interval = parse_interval(body.interval); } catch (const std::invalid_argument &) { HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Invalid interval. Must be 'fast', 'normal', or 'slow'.", - {{"parameter", "interval"}, {"value", body["interval"]}}); + {{"parameter", "interval"}, {"value", body.interval}}); return; } // Validate duration - int duration = body["duration"].get(); + int duration = body.duration; if (duration <= 0) { HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Duration must be a positive integer (seconds).", {{"parameter", "duration"}, {"value", duration}}); @@ -116,7 +107,7 @@ void CyclicSubscriptionHandlers::handle_create(const httplib::Request & req, htt } // Parse resource URI to extract collection and resource path - std::string resource = body["resource"].get(); + const std::string & resource = body.resource; auto parsed = parse_resource_uri(resource); if (!parsed) { HandlerContext::send_error(res, 400, ERR_X_MEDKIT_INVALID_RESOURCE_URI, "Invalid resource URI: " + parsed.error(), @@ -191,10 +182,8 @@ void CyclicSubscriptionHandlers::handle_create(const httplib::Request & req, htt return; } - auto response_json = subscription_to_json(*result, *event_source_result); - res.status = 201; - HandlerContext::send_json(res, response_json); + HandlerContext::send_dto(res, subscription_to_dto(*result, *event_source_result)); } // --------------------------------------------------------------------------- @@ -208,14 +197,12 @@ void CyclicSubscriptionHandlers::handle_list(const httplib::Request & req, httpl } auto subs = sub_mgr_.list(entity_id); - json items = json::array(); + dto::Collection response; for (const auto & sub : subs) { - items.push_back(subscription_to_json(sub, build_event_source(sub))); + response.items.push_back(subscription_to_dto(sub, build_event_source(sub))); } - json response; - response["items"] = items; - HandlerContext::send_json(res, response); + HandlerContext::send_dto(res, response); } // --------------------------------------------------------------------------- @@ -236,7 +223,7 @@ void CyclicSubscriptionHandlers::handle_get(const httplib::Request & req, httpli return; } - HandlerContext::send_json(res, subscription_to_json(*sub, build_event_source(*sub))); + HandlerContext::send_dto(res, subscription_to_dto(*sub, build_event_source(*sub))); } // --------------------------------------------------------------------------- @@ -251,32 +238,30 @@ void CyclicSubscriptionHandlers::handle_update(const httplib::Request & req, htt auto sub_id = req.matches[2].str(); - // Parse JSON body - json body; - try { - body = json::parse(req.body); - } catch (const json::exception &) { - HandlerContext::send_error(res, 400, ERR_INVALID_REQUEST, "Invalid JSON request body"); - return; + // Parse JSON body via DTO. + auto body_opt = ctx_.parse_body(req, res); + if (!body_opt) { + return; // 400 already sent by parse_body } + const auto & body = *body_opt; // Parse optional interval std::optional new_interval; - if (body.contains("interval") && body["interval"].is_string()) { + if (body.interval.has_value()) { try { - new_interval = parse_interval(body["interval"].get()); + new_interval = parse_interval(*body.interval); } catch (const std::invalid_argument &) { HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Invalid interval. Must be 'fast', 'normal', or 'slow'.", - {{"parameter", "interval"}, {"value", body["interval"]}}); + {{"parameter", "interval"}, {"value", *body.interval}}); return; } } - // Parse optional duration + // Validate optional duration std::optional new_duration; - if (body.contains("duration") && body["duration"].is_number_integer()) { - new_duration = body["duration"].get(); + if (body.duration.has_value()) { + new_duration = *body.duration; if (*new_duration <= 0) { HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "Duration must be a positive integer (seconds).", {{"parameter", "duration"}, {"value", *new_duration}}); @@ -305,7 +290,7 @@ void CyclicSubscriptionHandlers::handle_update(const httplib::Request & req, htt return; } - HandlerContext::send_json(res, subscription_to_json(*result, build_event_source(*result))); + HandlerContext::send_dto(res, subscription_to_dto(*result, build_event_source(*result))); } // --------------------------------------------------------------------------- @@ -384,15 +369,9 @@ void CyclicSubscriptionHandlers::handle_events(const httplib::Request & req, htt // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -json CyclicSubscriptionHandlers::subscription_to_json(const CyclicSubscriptionInfo & info, - const std::string & event_source) { - json j; - j["id"] = info.id; - j["observed_resource"] = info.resource_uri; - j["event_source"] = event_source; - j["protocol"] = info.protocol; - j["interval"] = interval_to_string(info.interval); - return j; +nlohmann::json CyclicSubscriptionHandlers::subscription_to_json(const CyclicSubscriptionInfo & info, + const std::string & event_source) { + return dto::JsonWriter::write(subscription_to_dto(info, event_source)); } std::string CyclicSubscriptionHandlers::build_event_source(const CyclicSubscriptionInfo & info) { diff --git a/src/ros2_medkit_gateway/src/http/rest_server.cpp b/src/ros2_medkit_gateway/src/http/rest_server.cpp index d71f0f74..3ce942a5 100644 --- a/src/ros2_medkit_gateway/src/http/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/http/rest_server.cpp @@ -891,7 +891,7 @@ void RESTServer::setup_routes() { .tag("Subscriptions") .summary(std::string("Update cyclic subscription for ") + et.singular) .description(std::string("Updates a subscription configuration on this ") + et.singular + ".") - .request_body("Subscription update", SB::ref("CyclicSubscription")) + .request_body("Subscription update", SB::ref("CyclicSubscriptionUpdateRequest")) .response(200, "Updated subscription", SB::ref("CyclicSubscription")) .operation_id(std::string("update") + capitalize(et.singular) + "Subscription"); diff --git a/src/ros2_medkit_gateway/src/openapi/path_builder.cpp b/src/ros2_medkit_gateway/src/openapi/path_builder.cpp index a9d12f20..72775df9 100644 --- a/src/ros2_medkit_gateway/src/openapi/path_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/path_builder.cpp @@ -473,8 +473,7 @@ nlohmann::json PathBuilder::build_cyclic_subscriptions_collection(const std::str get_op["description"] = "Returns all active cyclic subscriptions for this entity."; 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::cyclic_subscription_schema()); + get_op["responses"]["200"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("CyclicSubscriptionList"); auto errors = error_responses(); for (auto & [code, val] : errors.items()) { @@ -490,9 +489,9 @@ nlohmann::json PathBuilder::build_cyclic_subscriptions_collection(const std::str 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"] = - SchemaBuilder::cyclic_subscription_create_request_schema(); + SchemaBuilder::ref("CyclicSubscriptionCreateRequest"); post_op["responses"]["201"]["description"] = "Subscription created"; - post_op["responses"]["201"]["content"]["application/json"]["schema"] = SchemaBuilder::cyclic_subscription_schema(); + post_op["responses"]["201"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("CyclicSubscription"); 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 344ebca5..2c020fa7 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp @@ -220,18 +220,6 @@ nlohmann::json SchemaBuilder::binary_schema() { return {{"type", "string"}, {"format", "binary"}}; } -nlohmann::json SchemaBuilder::cyclic_subscription_schema() { - 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::script_metadata_schema() { return {{"type", "object"}, {"properties", @@ -264,16 +252,6 @@ nlohmann::json SchemaBuilder::script_upload_response_schema() { {"required", {"id", "name"}}}; } -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"}}); } @@ -393,10 +371,8 @@ const std::map & SchemaBuilder::component_schemas() {"OperationExecutionList", items_wrapper_ref("OperationExecution")}, // Triggers - Trigger, TriggerList, TriggerCreateRequest, TriggerUpdateRequest // now come from DTO (dto/triggers.hpp). - // Subscriptions - {"CyclicSubscription", cyclic_subscription_schema()}, - {"CyclicSubscriptionList", items_wrapper_ref("CyclicSubscription")}, - {"CyclicSubscriptionCreateRequest", cyclic_subscription_create_request_schema()}, + // Subscriptions - CyclicSubscription, CyclicSubscriptionList, CyclicSubscriptionCreateRequest, + // CyclicSubscriptionUpdateRequest now come from DTO (dto/cyclic_subscriptions.hpp). // Locking - Lock, LockList, AcquireLockRequest, ExtendLockRequest now come from DTO (dto/locks.hpp). // Scripts {"ScriptMetadata", script_metadata_schema()}, diff --git a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp index c443737f..35d5322a 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp @@ -77,9 +77,6 @@ class SchemaBuilder { /// Binary content schema (for file downloads) static nlohmann::json binary_schema(); - /// Cyclic subscription schema (CRUD responses) - static nlohmann::json cyclic_subscription_schema(); - /// Script metadata schema (list/get) static nlohmann::json script_metadata_schema(); @@ -95,9 +92,6 @@ class SchemaBuilder { /// Script upload response schema (minimal: id + name) static nlohmann::json script_upload_response_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 51a0fa83..4642ce8a 100644 --- a/src/ros2_medkit_gateway/test/test_path_builder.cpp +++ b/src/ros2_medkit_gateway/test/test_path_builder.cpp @@ -395,10 +395,12 @@ TEST_F(PathBuilderTest, CyclicSubscriptionsHasGetAndPost) { } TEST_F(PathBuilderTest, CyclicSubscriptionsPostHasRequestBody) { + // CyclicSubscriptionCreateRequest is now a DTO - request body schema is a $ref. 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("resource")); + ASSERT_TRUE(req_schema.contains("$ref")); + EXPECT_EQ(req_schema["$ref"], "#/components/schemas/CyclicSubscriptionCreateRequest"); } 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 fbdc70dc..5d387ba0 100644 --- a/src/ros2_medkit_gateway/test/test_schema_builder.cpp +++ b/src/ros2_medkit_gateway/test/test_schema_builder.cpp @@ -20,6 +20,7 @@ #include #include "../src/openapi/schema_builder.hpp" +#include "ros2_medkit_gateway/dto/cyclic_subscriptions.hpp" #include "ros2_medkit_gateway/dto/registry.hpp" #include "ros2_medkit_gateway/dto/schema_writer.hpp" #include "ros2_medkit_gateway/dto/triggers.hpp" @@ -315,7 +316,9 @@ TEST(SchemaBuilderStaticTest, BulkDataDescriptorSchema) { // @verifies REQ_INTEROP_002 TEST(SchemaBuilderStaticTest, CyclicSubscriptionCreateRequestSchema) { - auto schema = SchemaBuilder::cyclic_subscription_create_request_schema(); + // CyclicSubscriptionCreateRequest is now a DTO - verify via SchemaWriter. + namespace dto = ros2_medkit_gateway::dto; + auto schema = dto::SchemaWriter::schema(); EXPECT_EQ(schema["type"], "object"); ASSERT_TRUE(schema.contains("properties")); EXPECT_TRUE(schema["properties"].contains("resource")); @@ -332,15 +335,13 @@ TEST(SchemaBuilderStaticTest, CyclicSubscriptionCreateRequestSchema) { 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 + // interval uses plain field (no enum constraint) - bespoke handler validation + // produces ERR_INVALID_PARAMETER with parameter detail for unknown values. 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); + EXPECT_FALSE(schema["properties"]["interval"].contains("enum")); - // Verify duration type and minimum + // Verify duration type (DTO: integer; minimum is not emitted by SchemaWriter) EXPECT_EQ(schema["properties"]["duration"]["type"], "integer"); - EXPECT_EQ(schema["properties"]["duration"]["minimum"], 1); } // @verifies REQ_INTEROP_002 From 3119925728a0b023b9fc2a242664a8a3773cf985 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 14:25:35 +0200 Subject: [PATCH 32/51] feat(gateway): add bulk-data DTOs --- .../ros2_medkit_gateway/dto/bulkdata.hpp | 93 +++++++++++++++++++ .../ros2_medkit_gateway/dto/registry.hpp | 5 +- 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/bulkdata.hpp diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/bulkdata.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/bulkdata.hpp new file mode 100644 index 00000000..06bfee63 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/bulkdata.hpp @@ -0,0 +1,93 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "ros2_medkit_gateway/dto/contract.hpp" +#include "ros2_medkit_gateway/dto/entities.hpp" + +namespace ros2_medkit_gateway { +namespace dto { + +// ============================================================================= +// BulkDataCategoryList - response for GET /{entity}/bulk-data +// +// Wire shape: {"items": ["rosbags", "cat1", ...]} +// The items array contains bare strings (category names). +// ============================================================================= +struct BulkDataCategoryList { + std::vector items; +}; + +template <> +inline constexpr auto dto_fields = std::make_tuple(field("items", &BulkDataCategoryList::items)); + +template <> +inline constexpr std::string_view dto_name = "BulkDataCategoryList"; + +// ============================================================================= +// BulkDataDescriptor - one downloadable file descriptor. +// +// Emitted by: +// handle_list_descriptors -> array items inside {"items": [...]} +// handle_upload (201) -> single descriptor response +// +// Wire keys (from bulkdata_handlers.cpp): +// id - unique file identifier (required) +// name - human-readable filename / label (required) +// mimetype - MIME type of the file (required) +// size - byte count (required) +// creation_date - ISO 8601 timestamp string (required) +// description - optional human-readable description +// x-medkit - optional open vendor extension object; for rosbags: +// {fault_code, duration_sec, format}; for user uploads: +// arbitrary metadata JSON object set by the uploader. +// ============================================================================= +struct BulkDataDescriptor { + std::string id; + std::string name; + std::string mimetype; + uint64_t size{0}; + std::string creation_date; + std::optional description; + std::optional x_medkit; // wire key: "x-medkit" +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("id", &BulkDataDescriptor::id), field("name", &BulkDataDescriptor::name), + field("mimetype", &BulkDataDescriptor::mimetype), field("size", &BulkDataDescriptor::size), + field("creation_date", &BulkDataDescriptor::creation_date), + field("description", &BulkDataDescriptor::description), + field("x-medkit", &BulkDataDescriptor::x_medkit)); + +template <> +inline constexpr std::string_view dto_name = "BulkDataDescriptor"; + +// ============================================================================= +// Collection - named "BulkDataDescriptorList" +// ============================================================================= +template <> +inline constexpr std::string_view dto_name> = "BulkDataDescriptorList"; + +} // namespace dto +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp index c35bb915..fd77f853 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp @@ -19,6 +19,7 @@ #include #include +#include "ros2_medkit_gateway/dto/bulkdata.hpp" #include "ros2_medkit_gateway/dto/config.hpp" #include "ros2_medkit_gateway/dto/contract.hpp" #include "ros2_medkit_gateway/dto/cyclic_subscriptions.hpp" @@ -57,7 +58,9 @@ using AllDtos = Trigger, Collection, TriggerCreateRequest, TriggerUpdateRequest, // Cyclic subscription domain DTOs CyclicSubscription, Collection, CyclicSubscriptionCreateRequest, - CyclicSubscriptionUpdateRequest>; + CyclicSubscriptionUpdateRequest, + // Bulk-data domain DTOs + BulkDataCategoryList, BulkDataDescriptor, Collection>; namespace detail { template From 8e7e62cbe0896d155a728c0afbefee6b55be77f2 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 14:30:55 +0200 Subject: [PATCH 33/51] refactor(gateway): migrate bulk-data handlers to DTO contract --- .../src/http/handlers/bulkdata_handlers.cpp | 85 +++++++++---------- .../src/openapi/path_builder.cpp | 2 +- .../src/openapi/schema_builder.cpp | 24 +----- .../src/openapi/schema_builder.hpp | 6 -- .../test/test_schema_builder.cpp | 9 +- 5 files changed, 52 insertions(+), 74 deletions(-) diff --git a/src/ros2_medkit_gateway/src/http/handlers/bulkdata_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/bulkdata_handlers.cpp index 831c24f6..1a25a9c7 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/bulkdata_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/bulkdata_handlers.cpp @@ -23,6 +23,8 @@ #include "ros2_medkit_gateway/core/http/error_codes.hpp" #include "ros2_medkit_gateway/core/http/http_utils.hpp" #include "ros2_medkit_gateway/core/managers/bulk_data_store.hpp" +#include "ros2_medkit_gateway/dto/bulkdata.hpp" +#include "ros2_medkit_gateway/dto/entities.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" namespace ros2_medkit_gateway { @@ -52,19 +54,17 @@ void BulkDataHandlers::handle_list_categories(const httplib::Request & req, http } // Build categories list: "rosbags" always available + BulkDataStore categories - nlohmann::json categories = nlohmann::json::array(); - categories.push_back("rosbags"); // Always available via FaultManager + dto::BulkDataCategoryList response; + response.items.push_back("rosbags"); // Always available via FaultManager auto * store = ctx_.bulk_data_store(); if (store) { for (const auto & cat : store->list_categories()) { - categories.push_back(cat); + response.items.push_back(cat); } } - nlohmann::json response = {{"items", categories}}; - - HandlerContext::send_json(res, response); + HandlerContext::send_dto(res, response); } void BulkDataHandlers::handle_list_descriptors(const httplib::Request & req, httplib::Response & res) { @@ -128,7 +128,7 @@ void BulkDataHandlers::handle_list_descriptors(const httplib::Request & req, htt } } - nlohmann::json items = nlohmann::json::array(); + dto::Collection response; for (const auto & rosbag : all_rosbags) { std::string fault_code = rosbag.value("fault_code", ""); @@ -147,18 +147,18 @@ void BulkDataHandlers::handle_list_descriptors(const httplib::Request & req, htt created_at_ns = static_cast(first_occurred * 1'000'000'000); } - nlohmann::json descriptor = { - {"id", bulk_data_id}, - {"name", fault_code + " recording " + format_timestamp_ns(created_at_ns)}, - {"mimetype", get_rosbag_mimetype(format)}, - {"size", size_bytes}, - {"creation_date", format_timestamp_ns(created_at_ns)}, - {"x-medkit", {{"fault_code", fault_code}, {"duration_sec", duration_sec}, {"format", format}}}}; - items.push_back(descriptor); + dto::BulkDataDescriptor descriptor; + descriptor.id = bulk_data_id; + descriptor.name = fault_code + " recording " + format_timestamp_ns(created_at_ns); + descriptor.mimetype = get_rosbag_mimetype(format); + descriptor.size = size_bytes; + descriptor.creation_date = format_timestamp_ns(created_at_ns); + descriptor.x_medkit = + nlohmann::json{{"fault_code", fault_code}, {"duration_sec", duration_sec}, {"format", format}}; + response.items.push_back(std::move(descriptor)); } - nlohmann::json response = {{"items", items}}; - HandlerContext::send_json(res, response); + HandlerContext::send_dto(res, response); } else { // === Non-rosbag categories: served via BulkDataStore === auto * store = ctx_.bulk_data_store(); @@ -168,25 +168,23 @@ void BulkDataHandlers::handle_list_descriptors(const httplib::Request & req, htt } auto items_list = store->list_items(entity_info->entity_id, category); - nlohmann::json json_items = nlohmann::json::array(); + dto::Collection response; for (const auto & item : items_list) { - nlohmann::json desc = { - {"id", item.id}, - {"name", item.name}, - {"mimetype", item.mime_type}, - {"size", item.size}, - {"creation_date", item.created}, - }; + dto::BulkDataDescriptor desc; + desc.id = item.id; + desc.name = item.name; + desc.mimetype = item.mime_type; + desc.size = item.size; + desc.creation_date = item.created; if (!item.description.empty()) { - desc["description"] = item.description; + desc.description = item.description; } if (!item.metadata.empty()) { - desc["x-medkit"] = item.metadata; + desc.x_medkit = item.metadata; } - json_items.push_back(desc); + response.items.push_back(std::move(desc)); } - nlohmann::json response = {{"items", json_items}}; - HandlerContext::send_json(res, response); + HandlerContext::send_dto(res, response); } } @@ -290,24 +288,25 @@ void BulkDataHandlers::handle_upload(const httplib::Request & req, httplib::Resp return; } - // Build response JSON - const auto & desc = *result; - nlohmann::json descriptor_json = {{"id", desc.id}, - {"name", desc.name}, - {"mimetype", desc.mime_type}, - {"size", desc.size}, - {"creation_date", desc.created}}; - if (!desc.description.empty()) { - descriptor_json["description"] = desc.description; + // Build response DTO + const auto & stored = *result; + dto::BulkDataDescriptor descriptor; + descriptor.id = stored.id; + descriptor.name = stored.name; + descriptor.mimetype = stored.mime_type; + descriptor.size = stored.size; + descriptor.creation_date = stored.created; + if (!stored.description.empty()) { + descriptor.description = stored.description; } - if (!desc.metadata.empty()) { - descriptor_json["x-medkit"] = desc.metadata; + if (!stored.metadata.empty()) { + descriptor.x_medkit = stored.metadata; } // Return 201 Created with Location header res.status = 201; - res.set_header("Location", req.path + "/" + desc.id); - HandlerContext::send_json(res, descriptor_json); + res.set_header("Location", req.path + "/" + stored.id); + HandlerContext::send_dto(res, descriptor); } void BulkDataHandlers::handle_delete(const httplib::Request & req, httplib::Response & res) { diff --git a/src/ros2_medkit_gateway/src/openapi/path_builder.cpp b/src/ros2_medkit_gateway/src/openapi/path_builder.cpp index 72775df9..37d2c7e6 100644 --- a/src/ros2_medkit_gateway/src/openapi/path_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/path_builder.cpp @@ -448,7 +448,7 @@ nlohmann::json PathBuilder::build_bulk_data_collection(const std::string & entit get_op["description"] = "Returns available bulk data categories (e.g., rosbags) for this entity."; get_op["parameters"] = build_query_params_for_collection(); get_op["responses"]["200"]["description"] = "Successful response"; - get_op["responses"]["200"]["content"]["application/json"]["schema"] = SchemaBuilder::bulk_data_category_list_schema(); + get_op["responses"]["200"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("BulkDataCategoryList"); auto errors = error_responses(); for (auto & [code, val] : 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 2c020fa7..8afc0165 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp @@ -252,24 +252,6 @@ nlohmann::json SchemaBuilder::script_upload_response_schema() { {"required", {"id", "name"}}}; } -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"}}}, - {"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"}, {"additionalProperties", true}}}}}, - {"required", {"id", "name"}}}; -} - nlohmann::json SchemaBuilder::update_list_schema() { return items_wrapper({{"type", "string"}}); } @@ -380,10 +362,8 @@ const std::map & SchemaBuilder::component_schemas() {"ScriptUploadResponse", script_upload_response_schema()}, {"ScriptExecution", script_execution_schema()}, {"ScriptControlRequest", script_control_request_schema()}, - // Bulk Data - {"BulkDataCategoryList", bulk_data_category_list_schema()}, - {"BulkDataDescriptor", bulk_data_descriptor_schema()}, - {"BulkDataDescriptorList", items_wrapper_ref("BulkDataDescriptor")}, + // Bulk Data - BulkDataCategoryList, BulkDataDescriptor, BulkDataDescriptorList + // now come from DTO (dto/bulkdata.hpp). // Updates {"UpdateList", update_list_schema()}, {"UpdateStatus", update_status_schema()}, diff --git a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp index 35d5322a..986cb5ae 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp @@ -83,12 +83,6 @@ class SchemaBuilder { /// Script execution status schema static nlohmann::json script_execution_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(); diff --git a/src/ros2_medkit_gateway/test/test_schema_builder.cpp b/src/ros2_medkit_gateway/test/test_schema_builder.cpp index 5d387ba0..fe1d3601 100644 --- a/src/ros2_medkit_gateway/test/test_schema_builder.cpp +++ b/src/ros2_medkit_gateway/test/test_schema_builder.cpp @@ -20,6 +20,7 @@ #include #include "../src/openapi/schema_builder.hpp" +#include "ros2_medkit_gateway/dto/bulkdata.hpp" #include "ros2_medkit_gateway/dto/cyclic_subscriptions.hpp" #include "ros2_medkit_gateway/dto/registry.hpp" #include "ros2_medkit_gateway/dto/schema_writer.hpp" @@ -286,7 +287,9 @@ TEST(SchemaBuilderStaticTest, TriggerUpdateRequestSchema) { // @verifies REQ_INTEROP_002 TEST(SchemaBuilderStaticTest, BulkDataCategoryListSchema) { - auto schema = SchemaBuilder::bulk_data_category_list_schema(); + // BulkDataCategoryList is now a DTO - verify via SchemaWriter. + namespace dto = ros2_medkit_gateway::dto; + auto schema = dto::SchemaWriter::schema(); EXPECT_EQ(schema["type"], "object"); ASSERT_TRUE(schema.contains("properties")); EXPECT_TRUE(schema["properties"].contains("items")); @@ -296,7 +299,9 @@ TEST(SchemaBuilderStaticTest, BulkDataCategoryListSchema) { // @verifies REQ_INTEROP_002 TEST(SchemaBuilderStaticTest, BulkDataDescriptorSchema) { - auto schema = SchemaBuilder::bulk_data_descriptor_schema(); + // BulkDataDescriptor is now a DTO - verify via SchemaWriter. + namespace dto = ros2_medkit_gateway::dto; + auto schema = dto::SchemaWriter::schema(); EXPECT_EQ(schema["type"], "object"); ASSERT_TRUE(schema.contains("properties")); EXPECT_TRUE(schema["properties"].contains("id")); From 0757fcb21dd15410a4f1de574043f84a9adb7601 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 14:42:38 +0200 Subject: [PATCH 34/51] feat(gateway): add log DTOs Add LogContext, LogEntry, Collection, LogListXMedkit, and LogConfiguration DTO structs with co-located dto_fields/dto_name specializations. LogListXMedkit covers all x-medkit fields emitted by handle_get_logs across FUNCTION/AREA/COMPONENT/APP entity branches. Register all five types in AllDtos via registry.hpp. EveryRegisteredDtoRoundTrips: green. --- .../include/ros2_medkit_gateway/dto/logs.hpp | 169 ++++++++++++++++++ .../ros2_medkit_gateway/dto/registry.hpp | 5 +- 2 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/logs.hpp diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/logs.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/logs.hpp new file mode 100644 index 00000000..5bb1d9b9 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/logs.hpp @@ -0,0 +1,169 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "ros2_medkit_gateway/dto/contract.hpp" +#include "ros2_medkit_gateway/dto/entities.hpp" +#include "ros2_medkit_gateway/dto/enums.hpp" + +namespace ros2_medkit_gateway { +namespace dto { + +// ============================================================================= +// LogContext - "context" sub-object inside LogEntry. +// +// Wire shape (from log_entry_schema() in schema_builder.cpp): +// node - ROS 2 node name (required) +// function - optional calling function name +// file - optional source file name +// line - optional source line number (integer) +// ============================================================================= +struct LogContext { + std::string node; + std::optional function; + std::optional file; + std::optional line; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("node", &LogContext::node), field("function", &LogContext::function), + field("file", &LogContext::file), field("line", &LogContext::line)); + +template <> +inline constexpr std::string_view dto_name = "LogContext"; + +// ============================================================================= +// LogEntry - single application log entry. +// +// Wire shape (from log_entry_schema() in schema_builder.cpp): +// id - log entry ID, e.g. "log_123" (required) +// timestamp - ISO 8601 date-time string (required) +// severity - log level string (required) +// message - log message text (required) +// context - optional source context sub-object (LogContext) +// +// severity is NOT field_enum here: the log_mgr produces raw JSON items and the +// handler uses those items as-is (no bespoke severity validation on responses). +// ============================================================================= +struct LogEntry { + std::string id; + std::string timestamp; + std::string severity; + std::string message; + std::optional context; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("id", &LogEntry::id), field("timestamp", &LogEntry::timestamp), + field("severity", &LogEntry::severity), field("message", &LogEntry::message), + field("context", &LogEntry::context)); + +template <> +inline constexpr std::string_view dto_name = "LogEntry"; + +// ============================================================================= +// Collection - named "LogEntryList". +// +// Wire shape: {"items": [, ...]} +// The x-medkit aggregation metadata is added on top by the handler and is +// described by the separate LogListXMedkit DTO. +// ============================================================================= +template <> +inline constexpr std::string_view dto_name> = "LogEntryList"; + +// ============================================================================= +// LogListXMedkit - typed x-medkit vendor extension on log list responses. +// +// Emitted by handle_get_logs on FUNCTION / AREA / COMPONENT / APP entities. +// Wire keys (from log_handlers.cpp XMedkit builder usages): +// +// entity_id - SOVD entity ID (all aggregating entity types) +// aggregation_level - "function"|"area"|"component" (aggregating entities) +// aggregated - true when log entries aggregated from multiple sources +// host_count - number of host apps resolved (FUNCTION only) +// component_count - number of components in the area (AREA only) +// app_count - number of apps resolved (AREA and COMPONENT) +// aggregation_sources - list of node FQNs that contributed log entries +// contributors - aggregation peer provenance list (peer fan-out) +// partial - true when a peer fan-out request failed +// failed_peers - list of peer addresses that returned errors +// +// All fields are optional so the APP branch (which only emits x-medkit when +// there are peer contributors) and empty aggregation results are handled +// without special-casing. +// ============================================================================= +struct LogListXMedkit { + std::optional entity_id; + std::optional aggregation_level; // enum: "function"|"area"|"component" + std::optional aggregated; + std::optional host_count; + std::optional component_count; + std::optional app_count; + std::optional> aggregation_sources; + std::optional> contributors; + std::optional partial; + std::optional> failed_peers; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("entity_id", &LogListXMedkit::entity_id), + field_enum("aggregation_level", &LogListXMedkit::aggregation_level, kLogAggregationLevelValues), + field("aggregated", &LogListXMedkit::aggregated), field("host_count", &LogListXMedkit::host_count), + field("component_count", &LogListXMedkit::component_count), + field("app_count", &LogListXMedkit::app_count), + field("aggregation_sources", &LogListXMedkit::aggregation_sources), + field("contributors", &LogListXMedkit::contributors), field("partial", &LogListXMedkit::partial), + field("failed_peers", &LogListXMedkit::failed_peers)); + +template <> +inline constexpr std::string_view dto_name = "LogListXMedkit"; + +// ============================================================================= +// LogConfiguration - GET response and PUT request body for /{entity}/logs/configuration. +// +// Wire shape (from log_configuration_schema() in schema_builder.cpp): +// severity_filter - log level filter string (optional) +// max_entries - maximum number of buffered log entries (optional, 1..10000) +// +// severity_filter uses plain field() (not field_enum) because handle_put_logs_configuration +// performs its own bespoke validation of severity via log_mgr->update_config(), which +// produces specific ERR_INVALID_PARAMETER errors. parse_body uses field() to allow +// any string value through; the handler's richer validation runs after parsing. +// ============================================================================= +struct LogConfiguration { + std::optional severity_filter; + std::optional max_entries; +}; + +template <> +inline constexpr auto dto_fields = std::make_tuple( + field("severity_filter", &LogConfiguration::severity_filter), field("max_entries", &LogConfiguration::max_entries)); + +template <> +inline constexpr std::string_view dto_name = "LogConfiguration"; + +} // namespace dto +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp index fd77f853..af855d2a 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp @@ -28,6 +28,7 @@ #include "ros2_medkit_gateway/dto/errors.hpp" #include "ros2_medkit_gateway/dto/faults.hpp" #include "ros2_medkit_gateway/dto/locks.hpp" +#include "ros2_medkit_gateway/dto/logs.hpp" #include "ros2_medkit_gateway/dto/operations.hpp" #include "ros2_medkit_gateway/dto/schema_writer.hpp" #include "ros2_medkit_gateway/dto/triggers.hpp" @@ -60,7 +61,9 @@ using AllDtos = CyclicSubscription, Collection, CyclicSubscriptionCreateRequest, CyclicSubscriptionUpdateRequest, // Bulk-data domain DTOs - BulkDataCategoryList, BulkDataDescriptor, Collection>; + BulkDataCategoryList, BulkDataDescriptor, Collection, + // Log domain DTOs + LogContext, LogEntry, Collection, LogListXMedkit, LogConfiguration>; namespace detail { template From 420c62430dfb77d3fa8130265f99255a9b3084d8 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 14:48:01 +0200 Subject: [PATCH 35/51] refactor(gateway): migrate log handlers to DTO contract Replace the legacy XMedkit fluent builder in log_handlers.cpp with typed dto::LogListXMedkit. Migrate handle_get_logs_configuration to send_dto and handle_put_logs_configuration to parse_body, keeping semantic range validation for max_entries and severity after parsing. Remove log_entry_schema, log_entry_list_schema, and log_configuration_schema factories from SchemaBuilder; LogEntry, LogEntryList, LogContext, LogListXMedkit, and LogConfiguration are now emitted by the DTO registry. Update path_builder to reference LogEntryList via $ref. Update test_schema_builder and test_path_builder to assert against the registered DTO schemas. grep -rn "log_entry_schema|log_configuration_schema" src/ shows only comment references, zero code invocations. --- .../src/http/handlers/log_handlers.cpp | 127 +++++++----------- .../src/openapi/path_builder.cpp | 3 +- .../src/openapi/schema_builder.cpp | 58 +------- .../src/openapi/schema_builder.hpp | 12 -- .../test/test_path_builder.cpp | 10 +- .../test/test_schema_builder.cpp | 58 ++++---- 6 files changed, 86 insertions(+), 182 deletions(-) diff --git a/src/ros2_medkit_gateway/src/http/handlers/log_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/log_handlers.cpp index 0c1f78d0..edfb4b85 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/log_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/log_handlers.cpp @@ -22,7 +22,8 @@ #include "ros2_medkit_gateway/core/http/error_codes.hpp" #include "ros2_medkit_gateway/core/http/fan_out_helpers.hpp" -#include "ros2_medkit_gateway/core/http/x_medkit.hpp" +#include "ros2_medkit_gateway/dto/json_writer.hpp" +#include "ros2_medkit_gateway/dto/logs.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" namespace ros2_medkit_gateway { @@ -80,10 +81,10 @@ void LogHandlers::handle_get_logs(const httplib::Request & req, httplib::Respons json result; result["items"] = json::array(); - XMedkit ext; - ext.entity_id(entity_id); - ext.add("aggregation_level", "function"); - ext.add("aggregated", true); + dto::LogListXMedkit xm; + xm.entity_id = entity_id; + xm.aggregation_level = "function"; + xm.aggregated = true; if (func && !func->hosts.empty()) { auto host_fqns = HandlerContext::resolve_app_host_fqns(cache, func->hosts); @@ -95,17 +96,15 @@ void LogHandlers::handle_get_logs(const httplib::Request & req, httplib::Respons return; } result["items"] = std::move(*logs); - ext.add("host_count", host_fqns.size()); - nlohmann::json log_source_fqns = nlohmann::json::array(); - for (const auto & fqn : host_fqns) { - log_source_fqns.push_back(fqn); - } - ext.add("aggregation_sources", log_source_fqns); + xm.host_count = static_cast(host_fqns.size()); + std::vector sources(host_fqns.begin(), host_fqns.end()); + xm.aggregation_sources = std::move(sources); } } - merge_peer_items(ctx_.aggregation_manager(), req, result, ext); - result["x-medkit"] = ext.build(); + auto xm_json = dto::JsonWriter::write(xm); + merge_peer_items(ctx_.aggregation_manager(), req, result, xm_json); + result["x-medkit"] = std::move(xm_json); HandlerContext::send_json(res, result); return; } @@ -125,10 +124,10 @@ void LogHandlers::handle_get_logs(const httplib::Request & req, httplib::Respons } json result; - XMedkit ext; - ext.entity_id(entity_id); - ext.add("aggregation_level", "area"); - ext.add("aggregated", true); + dto::LogListXMedkit xm; + xm.entity_id = entity_id; + xm.aggregation_level = "area"; + xm.aggregated = true; if (!host_fqns.empty()) { auto logs = log_mgr->get_logs(host_fqns, false, min_severity, context_filter, entity_id); @@ -137,13 +136,10 @@ void LogHandlers::handle_get_logs(const httplib::Request & req, httplib::Respons return; } result["items"] = std::move(*logs); - ext.add("component_count", comp_ids.size()); - ext.add("app_count", host_fqns.size()); - nlohmann::json area_log_source_fqns = nlohmann::json::array(); - for (const auto & fqn : host_fqns) { - area_log_source_fqns.push_back(fqn); - } - ext.add("aggregation_sources", area_log_source_fqns); + xm.component_count = static_cast(comp_ids.size()); + xm.app_count = static_cast(host_fqns.size()); + std::vector sources(host_fqns.begin(), host_fqns.end()); + xm.aggregation_sources = std::move(sources); } else { auto logs = log_mgr->get_logs({entity.fqn}, true, min_severity, context_filter, entity_id); if (!logs) { @@ -153,8 +149,9 @@ void LogHandlers::handle_get_logs(const httplib::Request & req, httplib::Respons result["items"] = std::move(*logs); } - merge_peer_items(ctx_.aggregation_manager(), req, result, ext); - result["x-medkit"] = ext.build(); + auto xm_json = dto::JsonWriter::write(xm); + merge_peer_items(ctx_.aggregation_manager(), req, result, xm_json); + result["x-medkit"] = std::move(xm_json); HandlerContext::send_json(res, result); return; } @@ -173,10 +170,10 @@ void LogHandlers::handle_get_logs(const httplib::Request & req, httplib::Respons json result; result["items"] = json::array(); - XMedkit ext; - ext.entity_id(entity_id); - ext.add("aggregation_level", "component"); - ext.add("aggregated", true); + dto::LogListXMedkit xm; + xm.entity_id = entity_id; + xm.aggregation_level = "component"; + xm.aggregated = true; if (!host_fqns.empty()) { auto logs = log_mgr->get_logs(host_fqns, false, min_severity, context_filter, entity_id); @@ -185,12 +182,9 @@ void LogHandlers::handle_get_logs(const httplib::Request & req, httplib::Respons return; } result["items"] = std::move(*logs); - ext.add("app_count", host_fqns.size()); - nlohmann::json comp_log_source_fqns = nlohmann::json::array(); - for (const auto & fqn : host_fqns) { - comp_log_source_fqns.push_back(fqn); - } - ext.add("aggregation_sources", comp_log_source_fqns); + xm.app_count = static_cast(host_fqns.size()); + std::vector sources(host_fqns.begin(), host_fqns.end()); + xm.aggregation_sources = std::move(sources); } else if (!entity.fqn.empty()) { // Manifest component without hosted apps - keep the original // namespace prefix path so manifest-only deployments still work. @@ -202,8 +196,9 @@ void LogHandlers::handle_get_logs(const httplib::Request & req, httplib::Respons result["items"] = std::move(*logs); } - merge_peer_items(ctx_.aggregation_manager(), req, result, ext); - result["x-medkit"] = ext.build(); + auto xm_json = dto::JsonWriter::write(xm); + merge_peer_items(ctx_.aggregation_manager(), req, result, xm_json); + result["x-medkit"] = std::move(xm_json); HandlerContext::send_json(res, result); return; } @@ -220,10 +215,11 @@ void LogHandlers::handle_get_logs(const httplib::Request & req, httplib::Respons json result; result["items"] = std::move(*logs); - XMedkit ext; - merge_peer_items(ctx_.aggregation_manager(), req, result, ext); - if (!ext.empty()) { - result["x-medkit"] = ext.build(); + dto::LogListXMedkit xm; + auto xm_json = dto::JsonWriter::write(xm); + merge_peer_items(ctx_.aggregation_manager(), req, result, xm_json); + if (!xm_json.empty()) { + result["x-medkit"] = std::move(xm_json); } HandlerContext::send_json(res, result); } @@ -256,10 +252,10 @@ void LogHandlers::handle_get_logs_configuration(const httplib::Request & req, ht return; } - json result; - result["severity_filter"] = cfg->severity_filter; - result["max_entries"] = cfg->max_entries; - HandlerContext::send_json(res, result); + dto::LogConfiguration response; + response.severity_filter = cfg->severity_filter; + response.max_entries = static_cast(cfg->max_entries); + HandlerContext::send_dto(res, response); } // --------------------------------------------------------------------------- @@ -289,39 +285,21 @@ void LogHandlers::handle_put_logs_configuration(const httplib::Request & req, ht return; } - json body; - try { - body = json::parse(req.body); - } catch (const json::parse_error &) { - HandlerContext::send_error(res, 400, ERR_INVALID_REQUEST, "Invalid JSON in request body"); + auto body = ctx_.parse_body(req, res); + if (!body) { return; } std::optional severity_filter; std::optional max_entries; - if (body.contains("severity_filter")) { - if (!body["severity_filter"].is_string()) { - HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "severity_filter must be a string"); - return; - } - severity_filter = body["severity_filter"].get(); + if (body->severity_filter.has_value()) { + severity_filter = body->severity_filter; } - static constexpr long long kMaxEntriesCap = 10000; - if (body.contains("max_entries")) { - const auto & me = body["max_entries"]; - if (!me.is_number_integer() && !me.is_number_unsigned()) { - HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "max_entries must be a positive integer"); - return; - } - long long val = 0; - try { - val = me.get(); - } catch (const nlohmann::json::exception &) { - HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "max_entries value out of range"); - return; - } + static constexpr int64_t kMaxEntriesCap = 10000; + if (body->max_entries.has_value()) { + const int64_t val = *body->max_entries; if (val <= 0) { HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, "max_entries must be greater than 0"); return; @@ -333,13 +311,6 @@ void LogHandlers::handle_put_logs_configuration(const httplib::Request & req, ht max_entries = static_cast(val); } - // Warn about unrecognized fields (helps debug camelCase typos like "severityFilter") - for (const auto & [key, _] : body.items()) { - if (key != "severity_filter" && key != "max_entries") { - RCLCPP_DEBUG(HandlerContext::logger(), "PUT /logs/configuration: ignoring unrecognized field '%s'", key.c_str()); - } - } - const auto err = log_mgr->update_config(entity_id, severity_filter, max_entries); if (!err.empty()) { HandlerContext::send_error(res, 400, ERR_INVALID_PARAMETER, err); diff --git a/src/ros2_medkit_gateway/src/openapi/path_builder.cpp b/src/ros2_medkit_gateway/src/openapi/path_builder.cpp index 37d2c7e6..ec00e100 100644 --- a/src/ros2_medkit_gateway/src/openapi/path_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/path_builder.cpp @@ -423,8 +423,7 @@ nlohmann::json PathBuilder::build_logs_collection(const std::string & entity_pat get_op["parameters"] = std::move(params); get_op["responses"]["200"]["description"] = "Successful response"; - get_op["responses"]["200"]["content"]["application/json"]["schema"] = - SchemaBuilder::items_wrapper(SchemaBuilder::log_entry_schema()); + get_op["responses"]["200"]["content"]["application/json"]["schema"] = SchemaBuilder::ref("LogEntryList"); auto errors = error_responses(); for (auto & [code, val] : 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 8afc0165..ce919723 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp @@ -53,51 +53,6 @@ nlohmann::json SchemaBuilder::items_wrapper(const nlohmann::json & item_schema) {"required", {"items"}}}; } -nlohmann::json SchemaBuilder::log_entry_schema() { - nlohmann::json context_schema = {{"type", "object"}, - {"properties", - {{"node", {{"type", "string"}}}, - {"function", {{"type", "string"}}}, - {"file", {{"type", "string"}}}, - {"line", {{"type", "integer"}}}}}, - {"required", {"node"}}}; - - return {{"type", "object"}, - {"properties", - {{"id", {{"type", "string"}, {"description", "Log entry ID (e.g. log_123)"}}}, - {"timestamp", {{"type", "string"}, {"format", "date-time"}}}, - {"severity", {{"type", "string"}}}, - {"message", {{"type", "string"}}}, - {"context", context_schema}}}, - {"required", {"id", "timestamp", "severity", "message"}}}; -} - -nlohmann::json SchemaBuilder::log_entry_list_schema() { - // x-medkit aggregation metadata for /{entity}/logs responses. - // Emitted by LogHandlers::handle_get_logs on FUNCTION / AREA / COMPONENT - // entities. host_count is FUNCTION-only, component_count is AREA-only, - // app_count covers AREA and COMPONENT, aggregation_sources is present only - // when the host-fqn aggregation path produced filters. APP responses omit - // x-medkit unless peer aggregation contributes contributors. - nlohmann::json x_medkit_schema = { - {"type", "object"}, - {"description", "Aggregation provenance and counts (x-medkit extension)"}, - {"additionalProperties", true}, - {"properties", - {{"entity_id", {{"type", "string"}}}, - {"aggregation_level", {{"type", "string"}, {"enum", {"function", "area", "component"}}}}, - {"aggregated", {{"type", "boolean"}}}, - {"host_count", {{"type", "integer"}}}, - {"component_count", {{"type", "integer"}}}, - {"app_count", {{"type", "integer"}}}, - {"aggregation_sources", {{"type", "array"}, {"items", {{"type", "string"}}}}}, - {"contributors", {{"type", "array"}, {"items", {{"type", "string"}}}}}}}}; - - return {{"type", "object"}, - {"properties", {{"items", {{"type", "array"}, {"items", ref("LogEntry")}}}, {"x-medkit", x_medkit_schema}}}, - {"required", {"items"}}}; -} - nlohmann::json SchemaBuilder::health_schema() { nlohmann::json linking_schema = {{"type", "object"}, {"properties", @@ -290,13 +245,6 @@ nlohmann::json SchemaBuilder::update_status_schema() { {"required", {"status"}}}; } -nlohmann::json SchemaBuilder::log_configuration_schema() { - return {{"type", "object"}, - {"properties", - {{"severity_filter", {{"type", "string"}, {"enum", {"debug", "info", "warning", "error", "fatal"}}}}, - {"max_entries", {{"type", "integer"}, {"minimum", 1}, {"maximum", 10000}}}}}}; -} - nlohmann::json SchemaBuilder::script_control_request_schema() { return {{"type", "object"}, {"properties", @@ -339,10 +287,8 @@ const std::map & SchemaBuilder::component_schemas() std::map m = { // Core types {"GenericError", generic_error()}, - // Logs - {"LogEntry", log_entry_schema()}, - {"LogEntryList", log_entry_list_schema()}, - {"LogConfiguration", log_configuration_schema()}, + // Logs - LogEntry, LogEntryList, LogConfiguration, LogContext, LogListXMedkit + // now come from DTO (dto/logs.hpp). // Server {"HealthStatus", health_schema()}, {"VersionInfo", version_info_schema()}, diff --git a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp index 986cb5ae..2261ab28 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp @@ -53,15 +53,6 @@ class SchemaBuilder { /// Wrap an item schema in a SOVD collection response: {"items": [item_schema]} static nlohmann::json items_wrapper(const nlohmann::json & item_schema); - /// Log entry schema - static nlohmann::json log_entry_schema(); - - /// Log entry list response schema. Wraps `items` and declares the - /// `x-medkit` aggregation metadata that LogHandlers::handle_get_logs - /// emits on FUNCTION / AREA / COMPONENT responses (aggregation_level, - /// aggregated, app_count, host_count, component_count, aggregation_sources). - static nlohmann::json log_entry_list_schema(); - /// Health endpoint response schema static nlohmann::json health_schema(); @@ -92,9 +83,6 @@ class SchemaBuilder { /// Software update status schema static nlohmann::json update_status_schema(); - /// Log configuration schema (GET/PUT) - static nlohmann::json log_configuration_schema(); - /// Script control request schema (PUT /scripts/{id}/executions/{id}) static nlohmann::json script_control_request_schema(); diff --git a/src/ros2_medkit_gateway/test/test_path_builder.cpp b/src/ros2_medkit_gateway/test/test_path_builder.cpp index 4642ce8a..6a3d44d9 100644 --- a/src/ros2_medkit_gateway/test/test_path_builder.cpp +++ b/src/ros2_medkit_gateway/test/test_path_builder.cpp @@ -362,14 +362,12 @@ TEST_F(PathBuilderTest, LogsHasLevelQueryParam) { EXPECT_TRUE(has_level); } -TEST_F(PathBuilderTest, LogsReturnsLogEntryItems) { +TEST_F(PathBuilderTest, LogsReturnsLogEntryListRef) { + // After DTO migration build_logs_collection emits a $ref to LogEntryList. auto result = path_builder_.build_logs_collection("apps/sensor"); auto schema = result["get"]["responses"]["200"]["content"]["application/json"]["schema"]; - auto & item_schema = schema["properties"]["items"]["items"]; - EXPECT_TRUE(item_schema["properties"].contains("timestamp")); - EXPECT_TRUE(item_schema["properties"].contains("severity")); - EXPECT_TRUE(item_schema["properties"].contains("message")); - EXPECT_TRUE(item_schema["properties"].contains("context")); + ASSERT_TRUE(schema.contains("$ref")); + EXPECT_EQ(schema["$ref"], "#/components/schemas/LogEntryList"); } // ============================================================================= diff --git a/src/ros2_medkit_gateway/test/test_schema_builder.cpp b/src/ros2_medkit_gateway/test/test_schema_builder.cpp index fe1d3601..fc61a95a 100644 --- a/src/ros2_medkit_gateway/test/test_schema_builder.cpp +++ b/src/ros2_medkit_gateway/test/test_schema_builder.cpp @@ -369,8 +369,11 @@ TEST(SchemaBuilderStaticTest, TriggerCreateRequestSchema) { EXPECT_EQ(std::find(required.begin(), required.end(), "id"), required.end()); } -TEST(SchemaBuilderStaticTest, LogEntrySchema) { - auto schema = SchemaBuilder::log_entry_schema(); +TEST(SchemaBuilderStaticTest, LogEntrySchemaRegistered) { + // Regression: LogEntry moved to DTO - verify it is in component_schemas. + const auto & schemas = SchemaBuilder::component_schemas(); + ASSERT_TRUE(schemas.count("LogEntry") > 0) << "LogEntry schema must be registered"; + const auto & schema = schemas.at("LogEntry"); EXPECT_EQ(schema["type"], "object"); ASSERT_TRUE(schema.contains("properties")); EXPECT_TRUE(schema["properties"].contains("id")); @@ -380,37 +383,42 @@ TEST(SchemaBuilderStaticTest, LogEntrySchema) { EXPECT_TRUE(schema["properties"].contains("context")); EXPECT_EQ(schema["properties"]["id"]["type"], "string"); EXPECT_EQ(schema["properties"]["severity"]["type"], "string"); - EXPECT_EQ(schema["properties"]["context"]["type"], "object"); - EXPECT_TRUE(schema["properties"]["context"]["properties"].contains("node")); } -TEST(SchemaBuilderStaticTest, LogEntryListXMedkitDeclaresAggregationFields) { +TEST(SchemaBuilderStaticTest, LogListXMedkitDeclaresAggregationFields) { // Regression: handle_get_logs emits aggregation_level, aggregated, app_count, // host_count, component_count, aggregation_sources at the response wrapper's // x-medkit object on FUNCTION / AREA / COMPONENT entities. Generated typed // clients drop fields the schema does not declare, so each emitted field - // must be listed in log_entry_list_schema()'s x-medkit properties. - auto schema = SchemaBuilder::log_entry_list_schema(); + // must be listed in the LogListXMedkit DTO schema. + const auto & schemas = SchemaBuilder::component_schemas(); + ASSERT_TRUE(schemas.count("LogListXMedkit") > 0) << "LogListXMedkit schema must be registered"; + const auto & schema = schemas.at("LogListXMedkit"); EXPECT_EQ(schema["type"], "object"); ASSERT_TRUE(schema.contains("properties")); - ASSERT_TRUE(schema["properties"].contains("items")); - EXPECT_EQ(schema["properties"]["items"]["type"], "array"); - - ASSERT_TRUE(schema["properties"].contains("x-medkit")); - const auto & x_medkit = schema["properties"]["x-medkit"]; - EXPECT_EQ(x_medkit.at("type"), "object"); - ASSERT_TRUE(x_medkit.contains("properties")); - const auto & x_props = x_medkit.at("properties"); - for (const char * field : {"entity_id", "aggregation_level", "aggregated", "host_count", "component_count", - "app_count", "aggregation_sources", "contributors"}) { - ASSERT_TRUE(x_props.contains(field)) << "x-medkit missing declared field: " << field; + const auto & x_props = schema["properties"]; + for (const char * f : {"entity_id", "aggregation_level", "aggregated", "host_count", "component_count", "app_count", + "aggregation_sources", "contributors"}) { + ASSERT_TRUE(x_props.contains(f)) << "LogListXMedkit missing declared field: " << f; } EXPECT_EQ(x_props.at("aggregation_level").at("type"), "string"); EXPECT_EQ(x_props.at("aggregated").at("type"), "boolean"); EXPECT_EQ(x_props.at("app_count").at("type"), "integer"); EXPECT_EQ(x_props.at("aggregation_sources").at("type"), "array"); EXPECT_EQ(x_props.at("aggregation_sources").at("items").at("type"), "string"); +} +TEST(SchemaBuilderStaticTest, LogEntryListRegistered) { + // LogEntryList = Collection via DTO - must be in component_schemas + // and reference LogEntry. + const auto & schemas = SchemaBuilder::component_schemas(); + ASSERT_TRUE(schemas.count("LogEntryList") > 0) << "LogEntryList schema must be registered"; + const auto & schema = schemas.at("LogEntryList"); + ASSERT_TRUE(schema.contains("properties")); + ASSERT_TRUE(schema["properties"].contains("items")); + EXPECT_EQ(schema["properties"]["items"]["type"], "array"); + // Required: items + ASSERT_TRUE(schema.contains("required")); auto required = schema["required"].get>(); EXPECT_NE(std::find(required.begin(), required.end(), "items"), required.end()); } @@ -646,7 +654,10 @@ TEST(SchemaBuilderStaticTest, ScriptControlRequestSchema) { // @verifies REQ_INTEROP_002 TEST(SchemaBuilderStaticTest, LogConfigurationSchemaFieldsOptional) { - auto schema = SchemaBuilder::log_configuration_schema(); + // LogConfiguration moved to DTO - verify it is registered in component_schemas. + const auto & schemas = SchemaBuilder::component_schemas(); + ASSERT_TRUE(schemas.count("LogConfiguration") > 0) << "LogConfiguration schema must be registered"; + const auto & schema = schemas.at("LogConfiguration"); EXPECT_EQ(schema["type"], "object"); ASSERT_TRUE(schema.contains("properties")); EXPECT_TRUE(schema["properties"].contains("severity_filter")); @@ -654,15 +665,6 @@ TEST(SchemaBuilderStaticTest, LogConfigurationSchemaFieldsOptional) { // Both fields are optional - no required array EXPECT_FALSE(schema.contains("required")); - - // severity_filter has enum constraint - ASSERT_TRUE(schema["properties"]["severity_filter"].contains("enum")); - auto enum_vals = schema["properties"]["severity_filter"]["enum"].get>(); - EXPECT_EQ(enum_vals.size(), 5u); - - // max_entries has bounds - EXPECT_EQ(schema["properties"]["max_entries"]["minimum"], 1); - EXPECT_EQ(schema["properties"]["max_entries"]["maximum"], 10000); } // ============================================================================= From fead3b464d7797fb8bfceba3af821e45d8ab736d Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 14:58:46 +0200 Subject: [PATCH 36/51] feat(gateway): add script DTOs --- .../ros2_medkit_gateway/dto/registry.hpp | 5 +- .../ros2_medkit_gateway/dto/scripts.hpp | 158 ++++++++++++++++++ 2 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/scripts.hpp diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp index af855d2a..4606a8ea 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp @@ -31,6 +31,7 @@ #include "ros2_medkit_gateway/dto/logs.hpp" #include "ros2_medkit_gateway/dto/operations.hpp" #include "ros2_medkit_gateway/dto/schema_writer.hpp" +#include "ros2_medkit_gateway/dto/scripts.hpp" #include "ros2_medkit_gateway/dto/triggers.hpp" #include "ros2_medkit_gateway/dto/x_medkit.hpp" @@ -63,7 +64,9 @@ using AllDtos = // Bulk-data domain DTOs BulkDataCategoryList, BulkDataDescriptor, Collection, // Log domain DTOs - LogContext, LogEntry, Collection, LogListXMedkit, LogConfiguration>; + LogContext, LogEntry, Collection, LogListXMedkit, LogConfiguration, + // Script domain DTOs + ScriptMetadata, Collection, ScriptExecution, ScriptUploadResponse, ScriptControlRequest>; namespace detail { template diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/scripts.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/scripts.hpp new file mode 100644 index 00000000..68acf87e --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/scripts.hpp @@ -0,0 +1,158 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include +#include +#include + +#include "ros2_medkit_gateway/dto/contract.hpp" +#include "ros2_medkit_gateway/dto/entities.hpp" +#include "ros2_medkit_gateway/dto/enums.hpp" + +namespace ros2_medkit_gateway { +namespace dto { + +// ============================================================================= +// ScriptMetadata - single item in the script list / GET script response. +// +// Wire shape (from script_metadata_schema() + script_info_to_json()): +// id - script ID (required) +// name - script name (required) +// description - human-readable description (optional) +// href - canonical resource URI (optional) +// managed - true if managed by the system (optional) +// proximity_proof_required - true if proximity proof is required (optional) +// parameters_schema - optional free-form JSON schema for execution params +// (null when absent, kept as nlohmann::json to allow +// any JSON structure) +// ============================================================================= +struct ScriptMetadata { + std::string id; + std::string name; + std::optional description; + std::optional href; + std::optional managed; + std::optional proximity_proof_required; + std::optional parameters_schema; // free-form: runtime-determined schema +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("id", &ScriptMetadata::id), field("name", &ScriptMetadata::name), + field("description", &ScriptMetadata::description), field("href", &ScriptMetadata::href), + field("managed", &ScriptMetadata::managed), + field("proximity_proof_required", &ScriptMetadata::proximity_proof_required), + field("parameters_schema", &ScriptMetadata::parameters_schema)); + +template <> +inline constexpr std::string_view dto_name = "ScriptMetadata"; + +// ============================================================================= +// Collection - named "ScriptMetadataList". +// +// Wire shape: {"items": [, ...]} +// ============================================================================= +template <> +inline constexpr std::string_view dto_name> = "ScriptMetadataList"; + +// ============================================================================= +// ScriptExecution - execution status response. +// +// Used by: +// - POST /{entity}/scripts/{script_id}/executions (202 - execution started) +// - GET /{entity}/scripts/{script_id}/executions/{execution_id} +// - PUT /{entity}/scripts/{script_id}/executions/{execution_id} (200 - control) +// +// Wire shape (from script_execution_schema() + execution_info_to_json()): +// id - execution ID (required) +// status - execution status string (required) +// progress - optional float progress (0.0-1.0) +// started_at - optional ISO 8601 start timestamp string +// completed_at - optional ISO 8601 completion timestamp string +// parameters - optional free-form output parameters JSON object +// error - optional free-form error detail JSON object +// +// status is NOT field_enum here: the handler passes the value through from +// the backend without bespoke range-checking - enum is informational only. +// parameters and error are kept as nlohmann::json because they carry +// runtime-determined structures from the script backend. +// ============================================================================= +struct ScriptExecution { + std::string id; + std::string status; + std::optional progress; + std::optional started_at; + std::optional completed_at; + std::optional parameters; // free-form: runtime output parameters + std::optional error; // free-form: backend error detail +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("id", &ScriptExecution::id), field("status", &ScriptExecution::status), + field("progress", &ScriptExecution::progress), field("started_at", &ScriptExecution::started_at), + field("completed_at", &ScriptExecution::completed_at), + field("parameters", &ScriptExecution::parameters), field("error", &ScriptExecution::error)); + +template <> +inline constexpr std::string_view dto_name = "ScriptExecution"; + +// ============================================================================= +// ScriptUploadResponse - 201 response body for POST /{entity}/scripts. +// +// Wire shape (from script_upload_response_schema() + handle_upload_script()): +// id - assigned script ID (required) +// name - script name (required) +// ============================================================================= +struct ScriptUploadResponse { + std::string id; + std::string name; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("id", &ScriptUploadResponse::id), field("name", &ScriptUploadResponse::name)); + +template <> +inline constexpr std::string_view dto_name = "ScriptUploadResponse"; + +// ============================================================================= +// ScriptControlRequest - PUT request body for execution control. +// +// Wire shape (from script_control_request_schema() + handle_control_execution()): +// action - control action to apply (required) +// enum: "stop" | "forced_termination" +// +// Uses field_enum: the control handler validates only that "action" is present +// and non-empty (ERR_INVALID_REQUEST for missing). It does NOT perform bespoke +// value-range validation with ERR_INVALID_PARAMETER; parse_body provides the +// enum check instead. +// ============================================================================= +struct ScriptControlRequest { + std::string action; // enum: stop | forced_termination +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field_enum("action", &ScriptControlRequest::action, kScriptControlActionValues)); + +template <> +inline constexpr std::string_view dto_name = "ScriptControlRequest"; + +} // namespace dto +} // namespace ros2_medkit_gateway From 6258bc536893f86d7e7e221368006a26268d5c1d Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 15:04:43 +0200 Subject: [PATCH 37/51] refactor(gateway): migrate script handlers to DTO contract --- .../core/http/handlers/script_handlers.hpp | 5 +- .../ros2_medkit_gateway/dto/scripts.hpp | 4 +- .../src/http/handlers/script_handlers.cpp | 105 +++++++----------- .../src/openapi/schema_builder.cpp | 50 +-------- .../src/openapi/schema_builder.hpp | 12 -- .../test/test_schema_builder.cpp | 8 +- 6 files changed, 52 insertions(+), 132 deletions(-) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/script_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/script_handlers.hpp index 83f2f900..ffffaa76 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/script_handlers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/script_handlers.hpp @@ -15,6 +15,7 @@ #pragma once #include "ros2_medkit_gateway/core/managers/script_manager.hpp" +#include "ros2_medkit_gateway/dto/scripts.hpp" #include "ros2_medkit_gateway/http/handlers/handler_context.hpp" namespace ros2_medkit_gateway { @@ -42,8 +43,8 @@ class ScriptHandlers { void send_script_error(httplib::Response & res, const ScriptBackendErrorInfo & err); static bool is_valid_resource_id(const std::string & id); static std::string entity_type_from_path(const httplib::Request & req); - static nlohmann::json script_info_to_json(const ScriptInfo & info, const std::string & base_path); - static nlohmann::json execution_info_to_json(const ExecutionInfo & info); + static dto::ScriptMetadata script_info_to_dto(const ScriptInfo & info, const std::string & base_path); + static dto::ScriptExecution execution_info_to_dto(const ExecutionInfo & info); }; } // namespace handlers diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/scripts.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/scripts.hpp index 68acf87e..666d88d8 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/scripts.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/scripts.hpp @@ -81,7 +81,7 @@ inline constexpr std::string_view dto_name> = "Script // Wire shape (from script_execution_schema() + execution_info_to_json()): // id - execution ID (required) // status - execution status string (required) -// progress - optional float progress (0.0-1.0) +// progress - optional integer progress value (0-100; matches ExecutionInfo) // started_at - optional ISO 8601 start timestamp string // completed_at - optional ISO 8601 completion timestamp string // parameters - optional free-form output parameters JSON object @@ -95,7 +95,7 @@ inline constexpr std::string_view dto_name> = "Script struct ScriptExecution { std::string id; std::string status; - std::optional progress; + std::optional progress; std::optional started_at; std::optional completed_at; std::optional parameters; // free-form: runtime output parameters diff --git a/src/ros2_medkit_gateway/src/http/handlers/script_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/script_handlers.cpp index e9888858..a725c5a8 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/script_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/script_handlers.cpp @@ -19,6 +19,8 @@ #include "ros2_medkit_gateway/core/http/error_codes.hpp" #include "ros2_medkit_gateway/core/http/http_utils.hpp" +#include "ros2_medkit_gateway/dto/json_writer.hpp" +#include "ros2_medkit_gateway/dto/scripts.hpp" using json = nlohmann::json; @@ -86,52 +88,28 @@ void ScriptHandlers::send_script_error(httplib::Response & res, const ScriptBack } } -json ScriptHandlers::script_info_to_json(const ScriptInfo & info, const std::string & base_path) { - json obj; - obj["id"] = info.id; - obj["name"] = info.name; - obj["description"] = info.description; - obj["href"] = api_path(base_path + "/scripts/" + info.id); - obj["managed"] = info.managed; - obj["proximity_proof_required"] = info.proximity_proof_required; - if (info.parameters_schema.has_value()) { - obj["parameters_schema"] = info.parameters_schema.value(); - } else { - obj["parameters_schema"] = nullptr; - } - return obj; +dto::ScriptMetadata ScriptHandlers::script_info_to_dto(const ScriptInfo & info, const std::string & base_path) { + dto::ScriptMetadata meta; + meta.id = info.id; + meta.name = info.name; + meta.description = info.description; + meta.href = api_path(base_path + "/scripts/" + info.id); + meta.managed = info.managed; + meta.proximity_proof_required = info.proximity_proof_required; + meta.parameters_schema = info.parameters_schema; + return meta; } -json ScriptHandlers::execution_info_to_json(const ExecutionInfo & info) { - json obj; - obj["id"] = info.id; - obj["status"] = info.status; - if (info.progress.has_value()) { - obj["progress"] = info.progress.value(); - } else { - obj["progress"] = nullptr; - } - if (info.started_at.has_value()) { - obj["started_at"] = info.started_at.value(); - } else { - obj["started_at"] = nullptr; - } - if (info.completed_at.has_value()) { - obj["completed_at"] = info.completed_at.value(); - } else { - obj["completed_at"] = nullptr; - } - if (info.output_parameters.has_value()) { - obj["parameters"] = info.output_parameters.value(); - } else { - obj["parameters"] = nullptr; - } - if (info.error.has_value()) { - obj["error"] = info.error.value(); - } else { - obj["error"] = nullptr; - } - return obj; +dto::ScriptExecution ScriptHandlers::execution_info_to_dto(const ExecutionInfo & info) { + dto::ScriptExecution exec; + exec.id = info.id; + exec.status = info.status; + exec.progress = info.progress; + exec.started_at = info.started_at; + exec.completed_at = info.completed_at; + exec.parameters = info.output_parameters; + exec.error = info.error; + return exec; } void ScriptHandlers::handle_list_scripts(const httplib::Request & req, httplib::Response & res) { @@ -160,14 +138,14 @@ void ScriptHandlers::handle_list_scripts(const httplib::Request & req, httplib:: return; } - json items = json::array(); + dto::Collection collection; + collection.items.reserve(result->size()); for (const auto & info : *result) { - items.push_back(script_info_to_json(info, base_path)); + collection.items.push_back(script_info_to_dto(info, base_path)); } - json response; - response["items"] = items; - + // Serialize the DTO collection, then append _links for discoverability. + auto response = dto::JsonWriter>::write(collection); auto self_href = api_path("/" + entity_type_segment + "/" + entity_id + "/scripts"); response["_links"] = {{"self", self_href}, {"parent", api_path("/" + entity_type_segment + "/" + entity_id)}}; @@ -228,9 +206,13 @@ void ScriptHandlers::handle_upload_script(const httplib::Request & req, httplib: auto entity_type_segment = entity_type_from_path(req); auto script_path = api_path("/" + entity_type_segment + "/" + entity_id + "/scripts/" + result->id); + dto::ScriptUploadResponse upload_resp; + upload_resp.id = result->id; + upload_resp.name = result->name; + res.status = 201; res.set_header("Location", script_path); - HandlerContext::send_json(res, json{{"id", result->id}, {"name", result->name}}); + HandlerContext::send_dto(res, upload_resp); } catch (const std::exception & e) { HandlerContext::send_error(res, 500, ERR_INTERNAL_ERROR, e.what()); } @@ -267,7 +249,7 @@ void ScriptHandlers::handle_get_script(const httplib::Request & req, httplib::Re return; } - HandlerContext::send_json(res, script_info_to_json(*result, base_path)); + HandlerContext::send_dto(res, script_info_to_dto(*result, base_path)); } catch (const std::exception & e) { HandlerContext::send_error(res, 500, ERR_INTERNAL_ERROR, e.what()); } @@ -366,7 +348,7 @@ void ScriptHandlers::handle_start_execution(const httplib::Request & req, httpli res.status = 202; res.set_header("Location", exec_path); - HandlerContext::send_json(res, execution_info_to_json(*result)); + HandlerContext::send_dto(res, execution_info_to_dto(*result)); } catch (const std::exception & e) { HandlerContext::send_error(res, 500, ERR_INTERNAL_ERROR, e.what()); } @@ -405,7 +387,7 @@ void ScriptHandlers::handle_get_execution(const httplib::Request & req, httplib: return; } - HandlerContext::send_json(res, execution_info_to_json(*result)); + HandlerContext::send_dto(res, execution_info_to_dto(*result)); } catch (const std::exception & e) { HandlerContext::send_error(res, 500, ERR_INTERNAL_ERROR, e.what()); } @@ -438,27 +420,18 @@ void ScriptHandlers::handle_control_execution(const httplib::Request & req, http return; } - json body; - try { - body = json::parse(req.body); - } catch (const json::parse_error &) { - HandlerContext::send_error(res, 400, ERR_INVALID_REQUEST, "Invalid JSON body"); - return; - } - - if (!body.contains("action") || !body["action"].is_string() || body["action"].get().empty()) { - HandlerContext::send_error(res, 400, ERR_INVALID_REQUEST, "Missing required field: action"); + auto body = ctx_.parse_body(req, res); + if (!body) { return; } - auto action = body["action"].get(); - auto result = script_mgr_->control_execution(entity_id, script_id, execution_id, action); + auto result = script_mgr_->control_execution(entity_id, script_id, execution_id, body->action); if (!result) { send_script_error(res, result.error()); return; } - HandlerContext::send_json(res, execution_info_to_json(*result)); + HandlerContext::send_dto(res, execution_info_to_dto(*result)); } catch (const std::exception & e) { HandlerContext::send_error(res, 500, ERR_INTERNAL_ERROR, e.what()); } diff --git a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp index ce919723..db6a2941 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp @@ -175,38 +175,6 @@ nlohmann::json SchemaBuilder::binary_schema() { return {{"type", "string"}, {"format", "binary"}}; } -nlohmann::json SchemaBuilder::script_metadata_schema() { - return {{"type", "object"}, - {"properties", - {{"id", {{"type", "string"}}}, - {"name", {{"type", "string"}}}, - {"description", {{"type", "string"}}}, - {"href", {{"type", "string"}}}, - {"managed", {{"type", "boolean"}}}, - {"proximity_proof_required", {{"type", "boolean"}}}, - {"parameters_schema", {{"type", {"object", "null"}}}}}}, - {"required", {"id", "name"}}}; -} - -nlohmann::json SchemaBuilder::script_execution_schema() { - return {{"type", "object"}, - {"properties", - {{"id", {{"type", "string"}}}, - {"status", {{"type", "string"}}}, - {"progress", {{"type", "number"}}}, - {"started_at", {{"type", "string"}}}, - {"completed_at", {{"type", "string"}}}, - {"parameters", {{"type", "object"}}}, - {"error", {{"type", "object"}}}}}, - {"required", {"id", "status"}}}; -} - -nlohmann::json SchemaBuilder::script_upload_response_schema() { - return {{"type", "object"}, - {"properties", {{"id", {{"type", "string"}}}, {"name", {{"type", "string"}}}}}, - {"required", {"id", "name"}}}; -} - nlohmann::json SchemaBuilder::update_list_schema() { return items_wrapper({{"type", "string"}}); } @@ -245,16 +213,6 @@ nlohmann::json SchemaBuilder::update_status_schema() { {"required", {"status"}}}; } -nlohmann::json SchemaBuilder::script_control_request_schema() { - return {{"type", "object"}, - {"properties", - {{"action", - {{"type", "string"}, - {"enum", {"stop", "forced_termination"}}, - {"description", "Control action for the running script execution"}}}}}, - {"required", {"action"}}}; -} - nlohmann::json SchemaBuilder::auth_token_response_schema() { return {{"type", "object"}, {"properties", @@ -302,12 +260,8 @@ const std::map & SchemaBuilder::component_schemas() // Subscriptions - CyclicSubscription, CyclicSubscriptionList, CyclicSubscriptionCreateRequest, // CyclicSubscriptionUpdateRequest now come from DTO (dto/cyclic_subscriptions.hpp). // Locking - Lock, LockList, AcquireLockRequest, ExtendLockRequest now come from DTO (dto/locks.hpp). - // Scripts - {"ScriptMetadata", script_metadata_schema()}, - {"ScriptMetadataList", items_wrapper_ref("ScriptMetadata")}, - {"ScriptUploadResponse", script_upload_response_schema()}, - {"ScriptExecution", script_execution_schema()}, - {"ScriptControlRequest", script_control_request_schema()}, + // Scripts - ScriptMetadata, ScriptMetadataList, ScriptExecution, ScriptUploadResponse, + // ScriptControlRequest now come from DTO (dto/scripts.hpp). // Bulk Data - BulkDataCategoryList, BulkDataDescriptor, BulkDataDescriptorList // now come from DTO (dto/bulkdata.hpp). // Updates diff --git a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp index 2261ab28..0b32ba01 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp @@ -68,24 +68,12 @@ class SchemaBuilder { /// Binary content schema (for file downloads) static nlohmann::json binary_schema(); - /// Script metadata schema (list/get) - static nlohmann::json script_metadata_schema(); - - /// Script execution status schema - static nlohmann::json script_execution_schema(); - - /// Script upload response schema (minimal: id + name) - static nlohmann::json script_upload_response_schema(); - /// Software update list schema (items: [string]) static nlohmann::json update_list_schema(); /// Software update status schema static nlohmann::json update_status_schema(); - /// Script control request schema (PUT /scripts/{id}/executions/{id}) - static nlohmann::json script_control_request_schema(); - /// Auth token response schema static nlohmann::json auth_token_response_schema(); diff --git a/src/ros2_medkit_gateway/test/test_schema_builder.cpp b/src/ros2_medkit_gateway/test/test_schema_builder.cpp index fc61a95a..65b8e14e 100644 --- a/src/ros2_medkit_gateway/test/test_schema_builder.cpp +++ b/src/ros2_medkit_gateway/test/test_schema_builder.cpp @@ -256,7 +256,9 @@ TEST(SchemaBuilderStaticTest, ConfigurationWriteRequestSchemaFromDto) { // @verifies REQ_INTEROP_002 TEST(SchemaBuilderStaticTest, ScriptUploadResponseSchema) { - auto schema = SchemaBuilder::script_upload_response_schema(); + // ScriptUploadResponse is now a DTO - verify via SchemaWriter. + namespace dto = ros2_medkit_gateway::dto; + auto schema = dto::SchemaWriter::schema(); EXPECT_EQ(schema["type"], "object"); ASSERT_TRUE(schema.contains("properties")); EXPECT_TRUE(schema["properties"].contains("id")); @@ -637,7 +639,9 @@ TEST(SchemaBuilderStaticTest, ExecutionUpdateRequestSchemaComesFromDto) { // @verifies REQ_INTEROP_002 TEST(SchemaBuilderStaticTest, ScriptControlRequestSchema) { - auto schema = SchemaBuilder::script_control_request_schema(); + // ScriptControlRequest is now a DTO - verify via SchemaWriter. + namespace dto = ros2_medkit_gateway::dto; + auto schema = dto::SchemaWriter::schema(); EXPECT_EQ(schema["type"], "object"); ASSERT_TRUE(schema.contains("properties")); EXPECT_TRUE(schema["properties"].contains("action")); From dc83d971296685daa0a707e1d8f588294b87e55d Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 15:14:43 +0200 Subject: [PATCH 38/51] feat(gateway): add software update DTOs Adds dto/updates.hpp with UpdateList (items-wrapper of bare strings), UpdateSubProgress (nested sub-step struct), XMedkitUpdate (typed x-medkit extension with field_enum on phase), and UpdateStatus (status response with required x-medkit). Registers all four types in AllDtos. --- .../ros2_medkit_gateway/dto/registry.hpp | 5 +- .../ros2_medkit_gateway/dto/updates.hpp | 123 ++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/updates.hpp diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp index 4606a8ea..0540d603 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp @@ -33,6 +33,7 @@ #include "ros2_medkit_gateway/dto/schema_writer.hpp" #include "ros2_medkit_gateway/dto/scripts.hpp" #include "ros2_medkit_gateway/dto/triggers.hpp" +#include "ros2_medkit_gateway/dto/updates.hpp" #include "ros2_medkit_gateway/dto/x_medkit.hpp" namespace ros2_medkit_gateway { @@ -66,7 +67,9 @@ using AllDtos = // Log domain DTOs LogContext, LogEntry, Collection, LogListXMedkit, LogConfiguration, // Script domain DTOs - ScriptMetadata, Collection, ScriptExecution, ScriptUploadResponse, ScriptControlRequest>; + ScriptMetadata, Collection, ScriptExecution, ScriptUploadResponse, ScriptControlRequest, + // Software update domain DTOs + UpdateList, UpdateSubProgress, XMedkitUpdate, UpdateStatus>; namespace detail { template diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/updates.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/updates.hpp new file mode 100644 index 00000000..6cae37ff --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/updates.hpp @@ -0,0 +1,123 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include +#include +#include + +#include "ros2_medkit_gateway/dto/contract.hpp" +#include "ros2_medkit_gateway/dto/entities.hpp" +#include "ros2_medkit_gateway/dto/enums.hpp" + +namespace ros2_medkit_gateway { +namespace dto { + +// ============================================================================= +// UpdateList - response for GET /updates +// +// Wire shape: {"items": ["update_id_1", "update_id_2", ...]} +// The items array contains bare strings (update package IDs). +// ============================================================================= +struct UpdateList { + std::vector items; +}; + +template <> +inline constexpr auto dto_fields = std::make_tuple(field("items", &UpdateList::items)); + +template <> +inline constexpr std::string_view dto_name = "UpdateList"; + +// ============================================================================= +// UpdateSubProgress - a single sub-step progress entry. +// +// Wire shape (from update_status_to_json in update_types.hpp): +// name - sub-step name (required) +// progress - sub-step progress percentage 0-100 (required) +// +// Nested inside UpdateStatus::sub_progress array. +// ============================================================================= +struct UpdateSubProgress { + std::string name; + int progress{0}; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("name", &UpdateSubProgress::name), field("progress", &UpdateSubProgress::progress)); + +template <> +inline constexpr std::string_view dto_name = "UpdateSubProgress"; + +// ============================================================================= +// XMedkitUpdate - typed x-medkit vendor extension on update status responses. +// +// Emitted by handle_get_status (and the SSE sampler via update_status_to_json). +// Wire keys (from update_status_to_json in update_types.hpp): +// +// phase - internal lifecycle phase, distinguishes prepare-completed from +// execute-completed (required; always emitted by update_status_to_json) +// enum: none | preparing | prepared | executing | executed | failed | deleting +// +// Uses field_enum: phase is a RESPONSE-side field; the handler does NOT perform +// bespoke validation of the phase value in any request. +// ============================================================================= +struct XMedkitUpdate { + std::string phase; // enum: kUpdatePhaseValues +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field_enum("phase", &XMedkitUpdate::phase, kUpdatePhaseValues)); + +template <> +inline constexpr std::string_view dto_name = "XMedkitUpdate"; + +// ============================================================================= +// UpdateStatus - response for GET /updates/{update_id}/status +// +// Wire shape (from update_status_to_json in update_types.hpp): +// status - SOVD update status enum (required) +// enum: pending | inProgress | completed | failed +// progress - optional overall progress percentage (0-100) +// sub_progress - optional array of per-step progress entries +// error - optional error message string (set when status == failed) +// x-medkit - typed vendor extension (required; always emitted) +// +// status uses field_enum (response DTO, no bespoke handler-side range check). +// x-medkit is required: update_status_to_json always sets j["x-medkit"] unconditionally. +// ============================================================================= +struct UpdateStatus { + std::string status; // enum: kUpdateStatusValues + std::optional progress; // 0-100 + std::optional> sub_progress; + std::optional error; + XMedkitUpdate x_medkit; // wire key: "x-medkit" +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field_enum("status", &UpdateStatus::status, kUpdateStatusValues), + field("progress", &UpdateStatus::progress), field("sub_progress", &UpdateStatus::sub_progress), + field("error", &UpdateStatus::error), field("x-medkit", &UpdateStatus::x_medkit)); + +template <> +inline constexpr std::string_view dto_name = "UpdateStatus"; + +} // namespace dto +} // namespace ros2_medkit_gateway From 4cc2127bc8d6d88c5e6563bbfb6bbf0d279817fb Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 15:21:45 +0200 Subject: [PATCH 39/51] refactor(gateway): migrate software update handlers to DTO contract Migrates handle_list_updates to dto::UpdateList and handle_get_status to dto::UpdateStatus via a new to_update_status_dto() converter. Removes the static status_to_json() method from UpdateHandlers. Adds update_status_to_string() helper alongside the existing update_phase_to_string() in update_types.hpp. Deletes update_list_schema and update_status_schema from SchemaBuilder. Schemas are now generated from the DTO types in AllDtos. --- .../core/http/handlers/update_handlers.hpp | 3 -- .../core/providers/update_types.hpp | 15 +++++++ .../include/ros2_medkit_gateway/dto/enums.hpp | 4 +- .../src/http/handlers/update_handlers.cpp | 38 ++++++++++------ .../src/openapi/schema_builder.cpp | 43 +------------------ .../src/openapi/schema_builder.hpp | 6 --- 6 files changed, 45 insertions(+), 64 deletions(-) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/update_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/update_handlers.hpp index c269542f..3ca803f5 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/update_handlers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/update_handlers.hpp @@ -47,9 +47,6 @@ class UpdateHandlers { /// Check backend loaded, send 501 if not. Returns true if OK. bool check_backend(httplib::Response & res); - - /// Convert UpdateStatusInfo to JSON - static nlohmann::json status_to_json(const UpdateStatusInfo & status); }; } // namespace handlers diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/providers/update_types.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/providers/update_types.hpp index bb9b4b6b..0415e50c 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/providers/update_types.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/providers/update_types.hpp @@ -94,6 +94,21 @@ class UpdateProgressReporter { std::mutex & mutex_; }; +/// Serialize UpdateStatus to its SOVD string value. +inline const char * update_status_to_string(UpdateStatus status) { + switch (status) { + case UpdateStatus::Pending: + return "pending"; + case UpdateStatus::InProgress: + return "inProgress"; + case UpdateStatus::Completed: + return "completed"; + case UpdateStatus::Failed: + return "failed"; + } + return "pending"; +} + /// Serialize UpdatePhase to its `x-medkit.phase` string value. inline const char * update_phase_to_string(UpdatePhase phase) { switch (phase) { diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/enums.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/enums.hpp index f88c8486..71851065 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/enums.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/enums.hpp @@ -40,11 +40,11 @@ inline constexpr std::string_view kTriggerStatusValues[] = {"active", "terminate /// Cyclic subscription interval (cyclic_subscription_schema). inline constexpr std::string_view kCyclicSubscriptionIntervalValues[] = {"fast", "normal", "slow"}; -/// Update internal lifecycle phase (update_status_schema - x-medkit.phase). +/// Update internal lifecycle phase (XMedkitUpdate.phase / UpdateStatus x-medkit). inline constexpr std::string_view kUpdatePhaseValues[] = {"none", "preparing", "prepared", "executing", "executed", "failed", "deleting"}; -/// Update status (update_status_schema). +/// Update status (UpdateStatus.status). inline constexpr std::string_view kUpdateStatusValues[] = {"pending", "inProgress", "completed", "failed"}; /// Log severity filter (log_configuration_schema). diff --git a/src/ros2_medkit_gateway/src/http/handlers/update_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/update_handlers.cpp index cd0c957f..e3a27feb 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/update_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/update_handlers.cpp @@ -16,8 +16,7 @@ #include "ros2_medkit_gateway/core/http/error_codes.hpp" #include "ros2_medkit_gateway/core/http/http_utils.hpp" - -using json = nlohmann::json; +#include "ros2_medkit_gateway/dto/updates.hpp" namespace ros2_medkit_gateway { namespace handlers { @@ -34,8 +33,25 @@ bool UpdateHandlers::check_backend(httplib::Response & res) { return true; } -json UpdateHandlers::status_to_json(const UpdateStatusInfo & status) { - return update_status_to_json(status); +static dto::UpdateStatus to_update_status_dto(const UpdateStatusInfo & info) { + dto::UpdateStatus dto; + dto.status = update_status_to_string(info.status); + dto.x_medkit.phase = update_phase_to_string(info.phase); + if (info.progress.has_value()) { + dto.progress = *info.progress; + } + if (info.sub_progress.has_value()) { + std::vector sub; + sub.reserve(info.sub_progress->size()); + for (const auto & sp : *info.sub_progress) { + sub.push_back({sp.name, sp.progress}); + } + dto.sub_progress = std::move(sub); + } + if (info.error_message.has_value()) { + dto.error = *info.error_message; + } + return dto; } void UpdateHandlers::handle_list_updates(const httplib::Request & req, httplib::Response & res) { @@ -58,9 +74,7 @@ void UpdateHandlers::handle_list_updates(const httplib::Request & req, httplib:: return; } - json response; - response["items"] = *result; - HandlerContext::send_json(res, response); + HandlerContext::send_dto(res, dto::UpdateList{std::move(*result)}); } catch (const std::exception & e) { HandlerContext::send_error(res, 500, ERR_INTERNAL_ERROR, e.what()); } @@ -96,10 +110,10 @@ void UpdateHandlers::handle_register_update(const httplib::Request & req, httpli } try { - json body; + nlohmann::json body; try { - body = json::parse(req.body); - } catch (const json::parse_error &) { + body = nlohmann::json::parse(req.body); + } catch (const nlohmann::json::parse_error &) { HandlerContext::send_error(res, 400, ERR_INVALID_REQUEST, "Invalid JSON body"); return; } @@ -139,7 +153,7 @@ void UpdateHandlers::handle_register_update(const httplib::Request & req, httpli return; } - json response = {{"id", id}}; + nlohmann::json response = {{"id", id}}; HandlerContext::send_json(res, response); res.status = 201; res.set_header("Location", api_path("/updates/" + id)); @@ -337,7 +351,7 @@ void UpdateHandlers::handle_get_status(const httplib::Request & req, httplib::Re HandlerContext::send_error(res, 404, ERR_X_MEDKIT_UPDATE_NOT_FOUND, result.error().message); return; } - HandlerContext::send_json(res, status_to_json(*result)); + HandlerContext::send_dto(res, to_update_status_dto(*result)); } catch (const std::exception & e) { HandlerContext::send_error(res, 500, ERR_INTERNAL_ERROR, e.what()); } diff --git a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp index db6a2941..6a6d6bff 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp @@ -175,44 +175,6 @@ nlohmann::json SchemaBuilder::binary_schema() { return {{"type", "string"}, {"format", "binary"}}; } -nlohmann::json SchemaBuilder::update_list_schema() { - return items_wrapper({{"type", "string"}}); -} - -nlohmann::json SchemaBuilder::update_status_schema() { - nlohmann::json sub_progress_schema = { - {"type", "object"}, - {"properties", {{"name", {{"type", "string"}}}, {"progress", {{"type", "number"}}}}}, - {"required", {"name", "progress"}}}; - - nlohmann::json x_medkit_schema = { - {"type", "object"}, - {"description", "Vendor extensions (medkit)"}, - {"properties", - {{"phase", - {{"type", "string"}, - {"enum", {"none", "preparing", "prepared", "executing", "executed", "failed", "deleting"}}, - {"description", "Internal lifecycle phase, distinguishes prepare-completed from execute-completed"}}}}}, - {"required", {"phase"}}}; - - // x-medkit is optional in the SOVD payload (clients may ignore vendor - // extensions; same convention as FaultDetail x-medkit extension). - // When the gateway DOES emit the x-medkit object, - // however, ``phase`` is mandatory inside it - that scope is enforced by - // the inner ``required: {phase}`` above, NOT by listing x-medkit in the - // parent's required list. The drift test in test_openapi_response_drift - // covers regression on the emit side. If x-medkit is ever dropped from - // the parent properties, the inner required must be revisited too. - return {{"type", "object"}, - {"properties", - {{"status", {{"type", "string"}, {"enum", {"pending", "inProgress", "completed", "failed"}}}}, - {"progress", {{"type", "number"}}}, - {"sub_progress", {{"type", "array"}, {"items", sub_progress_schema}}}, - {"error", {{"type", "string"}}}, - {"x-medkit", x_medkit_schema}}}, - {"required", {"status"}}}; -} - nlohmann::json SchemaBuilder::auth_token_response_schema() { return {{"type", "object"}, {"properties", @@ -264,9 +226,8 @@ const std::map & SchemaBuilder::component_schemas() // ScriptControlRequest now come from DTO (dto/scripts.hpp). // Bulk Data - BulkDataCategoryList, BulkDataDescriptor, BulkDataDescriptorList // now come from DTO (dto/bulkdata.hpp). - // Updates - {"UpdateList", update_list_schema()}, - {"UpdateStatus", update_status_schema()}, + // Updates - UpdateList, UpdateSubProgress, XMedkitUpdate, UpdateStatus + // now come from DTO (dto/updates.hpp). // Auth {"AuthTokenResponse", auth_token_response_schema()}, {"AuthCredentials", auth_credentials_schema()}, diff --git a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp index 0b32ba01..5b2bc4b5 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp @@ -68,12 +68,6 @@ class SchemaBuilder { /// Binary content schema (for file downloads) static nlohmann::json binary_schema(); - /// Software update list schema (items: [string]) - static nlohmann::json update_list_schema(); - - /// Software update status schema - static nlohmann::json update_status_schema(); - /// Auth token response schema static nlohmann::json auth_token_response_schema(); From cd2cc16a7d28788f6dce7d5c94ef50204dadd3bf Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 15:31:14 +0200 Subject: [PATCH 40/51] feat(gateway): add auth DTOs Add AuthCredentials and AuthTokenResponse typed DTOs for the auth domain. AuthCredentials captures the OAuth2 request body fields (grant_type, client_id, client_secret, refresh_token, scope). AuthTokenResponse captures the token success response fields (access_token, token_type, expires_in, scope, refresh_token). Both are registered in AllDtos; EveryRegisteredDtoRoundTrips passes. --- .../include/ros2_medkit_gateway/dto/auth.hpp | 94 +++++++++++++++++++ .../ros2_medkit_gateway/dto/registry.hpp | 5 +- 2 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/auth.hpp diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/auth.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/auth.hpp new file mode 100644 index 00000000..a9249ee2 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/auth.hpp @@ -0,0 +1,94 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include +#include + +#include "ros2_medkit_gateway/dto/contract.hpp" + +namespace ros2_medkit_gateway { +namespace dto { + +// ============================================================================= +// AuthCredentials - request body for POST /auth/authorize and POST /auth/token. +// +// Wire shape (from AuthorizeRequest::from_json + AuthorizeRequest::from_form_data): +// grant_type - OAuth2 grant type (required) +// "client_credentials" for /auth/authorize, +// "refresh_token" for /auth/token +// client_id - client identifier (optional; required by handler for +// client_credentials flow) +// client_secret - client secret (optional; required by handler for +// client_credentials flow) +// refresh_token - refresh token string (optional; required by handler for +// refresh_token flow) +// scope - requested scope / role (optional) +// +// grant_type is NOT field_enum: the handler performs bespoke grant_type +// validation with an OAuth2 "unsupported_grant_type" error response that +// is distinct from the SOVD GenericError format used by parse_body. +// ============================================================================= +struct AuthCredentials { + std::string grant_type; + std::optional client_id; + std::optional client_secret; + std::optional refresh_token; + std::optional scope; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("grant_type", &AuthCredentials::grant_type), field("client_id", &AuthCredentials::client_id), + field("client_secret", &AuthCredentials::client_secret), + field("refresh_token", &AuthCredentials::refresh_token), field("scope", &AuthCredentials::scope)); + +template <> +inline constexpr std::string_view dto_name = "AuthCredentials"; + +// ============================================================================= +// AuthTokenResponse - success response for POST /auth/authorize and +// POST /auth/token. +// +// Wire shape (from TokenResponse::to_json in auth_models.hpp): +// access_token - JWT access token string (required) +// token_type - always "Bearer" (required) +// expires_in - seconds until access token expires (required, integer) +// scope - role-based scope string (required) +// refresh_token - JWT refresh token string (optional; absent on token refresh +// if the existing refresh token is reused) +// ============================================================================= +struct AuthTokenResponse { + std::string access_token; + std::string token_type; + int expires_in{0}; + std::string scope; + std::optional refresh_token; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("access_token", &AuthTokenResponse::access_token), + field("token_type", &AuthTokenResponse::token_type), + field("expires_in", &AuthTokenResponse::expires_in), field("scope", &AuthTokenResponse::scope), + field("refresh_token", &AuthTokenResponse::refresh_token)); + +template <> +inline constexpr std::string_view dto_name = "AuthTokenResponse"; + +} // namespace dto +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp index 0540d603..0824e8f3 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp @@ -19,6 +19,7 @@ #include #include +#include "ros2_medkit_gateway/dto/auth.hpp" #include "ros2_medkit_gateway/dto/bulkdata.hpp" #include "ros2_medkit_gateway/dto/config.hpp" #include "ros2_medkit_gateway/dto/contract.hpp" @@ -69,7 +70,9 @@ using AllDtos = // Script domain DTOs ScriptMetadata, Collection, ScriptExecution, ScriptUploadResponse, ScriptControlRequest, // Software update domain DTOs - UpdateList, UpdateSubProgress, XMedkitUpdate, UpdateStatus>; + UpdateList, UpdateSubProgress, XMedkitUpdate, UpdateStatus, + // Auth domain DTOs + AuthCredentials, AuthTokenResponse>; namespace detail { template From 36384152c402e5c22aceaf15881fd3d04b8e9334 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 15:36:00 +0200 Subject: [PATCH 41/51] refactor(gateway): migrate auth handlers to DTO contract Switch handle_auth_authorize and handle_auth_token to use parse_body for request parsing and send_dto with AuthTokenResponse for success responses. The 401 credential-check path (invalid_client, invalid_grant) is preserved via the existing AuthManager result handling. The OAuth2 error format (error + error_description) is preserved for all handler-level validations (unsupported_grant_type, invalid_request field checks). Delete auth_token_response_schema and auth_credentials_schema factory functions from SchemaBuilder; both schemas now come from dto/auth.hpp via collect_component_schemas. Zero remaining references to the deleted factories. Routes in rest_server.cpp already use SchemaBuilder::ref() for both auth schemas, so no route changes needed. --- .../src/http/handlers/auth_handlers.cpp | 24 +++++++++++++++---- .../src/openapi/schema_builder.cpp | 21 +--------------- .../src/openapi/schema_builder.hpp | 6 ----- 3 files changed, 21 insertions(+), 30 deletions(-) diff --git a/src/ros2_medkit_gateway/src/http/handlers/auth_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/auth_handlers.cpp index 8d9edd4b..355649b2 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/auth_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/auth_handlers.cpp @@ -16,12 +16,28 @@ #include "ros2_medkit_gateway/core/auth/auth_models.hpp" #include "ros2_medkit_gateway/core/http/error_codes.hpp" +#include "ros2_medkit_gateway/dto/auth.hpp" using json = nlohmann::json; namespace ros2_medkit_gateway { namespace handlers { +namespace { + +/// Build an AuthTokenResponse DTO from a TokenResponse (auth_models.hpp). +dto::AuthTokenResponse to_auth_token_response(const TokenResponse & tr) { + dto::AuthTokenResponse resp; + resp.access_token = tr.access_token; + resp.token_type = tr.token_type; + resp.expires_in = tr.expires_in; + resp.scope = tr.scope; + resp.refresh_token = tr.refresh_token; + return resp; +} + +} // namespace + void AuthHandlers::handle_auth_authorize(const httplib::Request & req, httplib::Response & res) { try { const auto & auth_config = ctx_.auth_config(); @@ -31,7 +47,7 @@ void AuthHandlers::handle_auth_authorize(const httplib::Request & req, httplib:: return; } - // Parse request using DRY helper + // Parse request using DRY helper (content-type-aware: JSON and form-urlencoded) auto parse_result = AuthorizeRequest::parse_request(req.get_header_value("Content-Type"), req.body); if (!parse_result) { res.status = 400; @@ -70,7 +86,7 @@ void AuthHandlers::handle_auth_authorize(const httplib::Request & req, httplib:: auto result = auth_manager->authenticate(auth_req.client_id.value(), auth_req.client_secret.value()); if (result) { - HandlerContext::send_json(res, result->to_json()); + HandlerContext::send_dto(res, to_auth_token_response(*result)); } else { res.status = 401; res.set_content(result.error().to_json().dump(2), "application/json"); @@ -90,7 +106,7 @@ void AuthHandlers::handle_auth_token(const httplib::Request & req, httplib::Resp return; } - // Parse request using DRY helper + // Parse request using DRY helper (content-type-aware: JSON and form-urlencoded) auto parse_result = AuthorizeRequest::parse_request(req.get_header_value("Content-Type"), req.body); if (!parse_result) { res.status = 400; @@ -123,7 +139,7 @@ void AuthHandlers::handle_auth_token(const httplib::Request & req, httplib::Resp auto result = auth_manager->refresh_access_token(auth_req.refresh_token.value()); if (result) { - HandlerContext::send_json(res, result->to_json()); + HandlerContext::send_dto(res, to_auth_token_response(*result)); } else { res.status = 401; res.set_content(result.error().to_json().dump(2), "application/json"); diff --git a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp index 6a6d6bff..6c29e859 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp @@ -175,23 +175,6 @@ nlohmann::json SchemaBuilder::binary_schema() { return {{"type", "string"}, {"format", "binary"}}; } -nlohmann::json SchemaBuilder::auth_token_response_schema() { - return {{"type", "object"}, - {"properties", - {{"access_token", {{"type", "string"}}}, - {"token_type", {{"type", "string"}}}, - {"expires_in", {{"type", "integer"}}}, - {"scope", {{"type", "string"}}}, - {"refresh_token", {{"type", "string"}}}}}, - {"required", {"access_token", "token_type", "expires_in"}}}; -} - -nlohmann::json SchemaBuilder::auth_credentials_schema() { - return {{"type", "object"}, - {"properties", {{"username", {{"type", "string"}}}, {"password", {{"type", "string"}}}}}, - {"required", {"username", "password"}}}; -} - nlohmann::json SchemaBuilder::ref(const std::string & schema_name) { return {{"$ref", "#/components/schemas/" + schema_name}}; } @@ -228,9 +211,7 @@ const std::map & SchemaBuilder::component_schemas() // now come from DTO (dto/bulkdata.hpp). // Updates - UpdateList, UpdateSubProgress, XMedkitUpdate, UpdateStatus // now come from DTO (dto/updates.hpp). - // Auth - {"AuthTokenResponse", auth_token_response_schema()}, - {"AuthCredentials", auth_credentials_schema()}, + // Auth - AuthCredentials, AuthTokenResponse now come from DTO (dto/auth.hpp). }; // DTO-contract schemas, merged on top of the hand-written factories. The DTO // version wins on a name collision (currently only "GenericError"). Each diff --git a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp index 5b2bc4b5..1ae435ef 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp @@ -68,12 +68,6 @@ class SchemaBuilder { /// Binary content schema (for file downloads) static nlohmann::json binary_schema(); - /// Auth token response schema - static nlohmann::json auth_token_response_schema(); - - /// Auth credentials request body schema - static nlohmann::json auth_credentials_schema(); - /// Returns a $ref JSON object pointing to a named component schema. static nlohmann::json ref(const std::string & schema_name); From 7e8b375ff04b09b24f773ccdbff6ddf59693538a Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 15:52:44 +0200 Subject: [PATCH 42/51] feat(gateway): add health and version-info DTOs Add dto/health.hpp with typed DTOs for the health/root domain: Health, HealthDiscovery, HealthDiscoveryLinking, HealthAggregationWarning (for GET /health), VersionInfo, VersionInfoEntry, VersionInfoVendor, XMedkitVersionInfo (for GET /version-info), RootOverview, RootCapabilities, RootAuth, RootTls (for GET /). Register all in AllDtos. EveryRegisteredDtoRoundTrips passes for all new types. --- .../ros2_medkit_gateway/dto/health.hpp | 355 ++++++++++++++++++ .../ros2_medkit_gateway/dto/registry.hpp | 6 +- 2 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/health.hpp diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/health.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/health.hpp new file mode 100644 index 00000000..a8147bff --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/health.hpp @@ -0,0 +1,355 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "ros2_medkit_gateway/dto/contract.hpp" + +namespace ros2_medkit_gateway { +namespace dto { + +// ============================================================================= +// HealthDiscoveryLinking - "linking" sub-object inside HealthDiscovery. +// +// Wire shape (from health_handlers.cpp handle_health): +// linked_count - number of runtime nodes linked to manifest apps (integer) +// orphan_count - number of unlinked runtime nodes (integer) +// binding_conflicts - list of conflict description strings (array of strings) +// warnings - optional list of warning strings +// ============================================================================= +struct HealthDiscoveryLinking { + int64_t linked_count{0}; + int64_t orphan_count{0}; + std::vector binding_conflicts; + std::optional> warnings; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("linked_count", &HealthDiscoveryLinking::linked_count), + field("orphan_count", &HealthDiscoveryLinking::orphan_count), + field("binding_conflicts", &HealthDiscoveryLinking::binding_conflicts), + field("warnings", &HealthDiscoveryLinking::warnings)); + +template <> +inline constexpr std::string_view dto_name = "HealthDiscoveryLinking"; + +// ============================================================================= +// HealthDiscovery - "discovery" sub-object inside Health. +// +// Wire shape (from health_handlers.cpp handle_health): +// mode - discovery mode string (required) +// strategy - strategy name string (required) +// pipeline - optional free-form JSON (merge report) +// linking - optional linking result sub-object +// ============================================================================= +struct HealthDiscovery { + std::string mode; + std::string strategy; + std::optional pipeline; + std::optional linking; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("mode", &HealthDiscovery::mode), field("strategy", &HealthDiscovery::strategy), + field("pipeline", &HealthDiscovery::pipeline), field("linking", &HealthDiscovery::linking)); + +template <> +inline constexpr std::string_view dto_name = "HealthDiscovery"; + +// ============================================================================= +// HealthAggregationWarning - single item inside Health::warnings array. +// +// Wire shape (from health_handlers.cpp handle_health agg-warning loop): +// code - stable machine-readable warning code string (required) +// message - human-readable description with remediation hints (required) +// entity_ids - SOVD entity IDs affected by the warning (required, array) +// peer_names - aggregation peers involved in the anomaly (required, array) +// ============================================================================= +struct HealthAggregationWarning { + std::string code; + std::string message; + std::vector entity_ids; + std::vector peer_names; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("code", &HealthAggregationWarning::code), + field("message", &HealthAggregationWarning::message), + field("entity_ids", &HealthAggregationWarning::entity_ids), + field("peer_names", &HealthAggregationWarning::peer_names)); + +template <> +inline constexpr std::string_view dto_name = "HealthAggregationWarning"; + +// ============================================================================= +// Health - response for GET /health. +// +// Wire shape (from health_handlers.cpp handle_health): +// status - always "healthy" (required) +// timestamp - nanoseconds since epoch (integer, required) +// discovery - optional discovery sub-object +// x-medkit-data-provider - optional free-form JSON stats object +// (from Ros2TopicDataProvider::x_medkit_stats()) +// x-medkit-subscription-executor - optional free-form JSON stats object +// (from Ros2TopicDataProvider::x_medkit_stats()) +// peers - optional free-form JSON array (agg peer status) +// warning_schema_version - optional integer (agg schema contract version) +// warnings - optional array of HealthAggregationWarning +// +// The x-medkit-* keys use hyphens (endpoint-level vendor extensions, NOT the +// nested "x-medkit" pattern). They are free-form nlohmann::json objects because +// x_medkit_stats() builds them dynamically. Both are optional (only present when +// a TopicDataProvider / executor is active). +// ============================================================================= +struct Health { + std::string status; + int64_t timestamp{0}; + std::optional discovery; + std::optional x_medkit_data_provider; // wire key: "x-medkit-data-provider" + std::optional x_medkit_subscription_executor; // wire key: "x-medkit-subscription-executor" + std::optional peers; // free-form array of peer status objects + std::optional warning_schema_version; + std::optional> warnings; +}; + +template <> +inline constexpr auto dto_fields = std::make_tuple( + field("status", &Health::status), field("timestamp", &Health::timestamp), field("discovery", &Health::discovery), + field("x-medkit-data-provider", &Health::x_medkit_data_provider), + field("x-medkit-subscription-executor", &Health::x_medkit_subscription_executor), field("peers", &Health::peers), + field("warning_schema_version", &Health::warning_schema_version), field("warnings", &Health::warnings)); + +template <> +inline constexpr std::string_view dto_name = "HealthStatus"; + +// ============================================================================= +// VersionInfoVendor - "vendor_info" sub-object inside VersionInfoEntry. +// +// Wire shape (from health_handlers.cpp handle_version_info): +// version - gateway version string (required) +// name - gateway name string, always "ros2_medkit" (required) +// ============================================================================= +struct VersionInfoVendor { + std::string version; + std::string name; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("version", &VersionInfoVendor::version), field("name", &VersionInfoVendor::name)); + +template <> +inline constexpr std::string_view dto_name = "VersionInfoVendor"; + +// ============================================================================= +// VersionInfoEntry - single item inside the VersionInfo::items array. +// +// Wire shape (from health_handlers.cpp handle_version_info): +// version - SOVD standard version string (required) +// base_uri - version-specific base URI string (required) +// vendor_info - vendor-specific info sub-object (optional) +// ============================================================================= +struct VersionInfoEntry { + std::string version; + std::string base_uri; + std::optional vendor_info; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("version", &VersionInfoEntry::version), field("base_uri", &VersionInfoEntry::base_uri), + field("vendor_info", &VersionInfoEntry::vendor_info)); + +template <> +inline constexpr std::string_view dto_name = "VersionInfoEntry"; + +// ============================================================================= +// XMedkitVersionInfo - typed x-medkit vendor extension on /version-info response. +// +// Emitted by handle_version_info when aggregation is active and a peer fan-out +// request is partial (some peers failed). +// +// Wire keys (from merge_peer_items in fan_out_helpers.hpp): +// partial - true when one or more peers failed during fan-out (optional) +// failed_peers - list of peer addresses that returned errors (optional) +// +// Both fields are optional so the DTO is correctly empty (no "x-medkit" key +// in the response) when there are no aggregation peers or the fan-out succeeds +// completely. +// ============================================================================= +struct XMedkitVersionInfo { + std::optional partial; + std::optional> failed_peers; +}; + +template <> +inline constexpr auto dto_fields = std::make_tuple( + field("partial", &XMedkitVersionInfo::partial), field("failed_peers", &XMedkitVersionInfo::failed_peers)); + +template <> +inline constexpr std::string_view dto_name = "XMedkitVersionInfo"; + +// ============================================================================= +// VersionInfo - response for GET /version-info (SOVD 7.4.1). +// +// Wire shape (from health_handlers.cpp handle_version_info): +// items - array of version entries (required) +// x-medkit - optional typed vendor extension (aggregation fan-out metadata) +// +// The x-medkit field is only present when aggregation is active and a +// peer fan-out partially succeeds or fails. +// ============================================================================= +struct VersionInfo { + std::vector items; + std::optional x_medkit; // wire key: "x-medkit" +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("items", &VersionInfo::items), field("x-medkit", &VersionInfo::x_medkit)); + +template <> +inline constexpr std::string_view dto_name = "VersionInfo"; + +// ============================================================================= +// RootCapabilities - "capabilities" sub-object inside RootOverview. +// +// Wire shape (from health_handlers.cpp handle_root capabilities object): +// discovery, data_access, operations, async_actions, configurations, +// faults, logs, bulk_data, cyclic_subscriptions, locking, triggers, +// updates, authentication, tls, scripts, aggregation, vendor_extensions +// (all boolean, all required) +// ============================================================================= +struct RootCapabilities { + bool discovery{false}; + bool data_access{false}; + bool operations{false}; + bool async_actions{false}; + bool configurations{false}; + bool faults{false}; + bool logs{false}; + bool bulk_data{false}; + bool cyclic_subscriptions{false}; + bool locking{false}; + bool triggers{false}; + bool updates{false}; + bool authentication{false}; + bool tls{false}; + bool scripts{false}; + bool aggregation{false}; + bool vendor_extensions{false}; +}; + +template <> +inline constexpr auto dto_fields = std::make_tuple( + field("discovery", &RootCapabilities::discovery), field("data_access", &RootCapabilities::data_access), + field("operations", &RootCapabilities::operations), field("async_actions", &RootCapabilities::async_actions), + field("configurations", &RootCapabilities::configurations), field("faults", &RootCapabilities::faults), + field("logs", &RootCapabilities::logs), field("bulk_data", &RootCapabilities::bulk_data), + field("cyclic_subscriptions", &RootCapabilities::cyclic_subscriptions), + field("locking", &RootCapabilities::locking), field("triggers", &RootCapabilities::triggers), + field("updates", &RootCapabilities::updates), field("authentication", &RootCapabilities::authentication), + field("tls", &RootCapabilities::tls), field("scripts", &RootCapabilities::scripts), + field("aggregation", &RootCapabilities::aggregation), + field("vendor_extensions", &RootCapabilities::vendor_extensions)); + +template <> +inline constexpr std::string_view dto_name = "RootCapabilities"; + +// ============================================================================= +// RootAuth - "auth" sub-object inside RootOverview. +// +// Wire shape (from health_handlers.cpp handle_root auth block): +// enabled - always true when this block is present (required) +// algorithm - JWT algorithm string (required) +// require_auth_for - "none" | "write" | "all" (required) +// ============================================================================= +struct RootAuth { + bool enabled{false}; + std::string algorithm; + std::string require_auth_for; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("enabled", &RootAuth::enabled), field("algorithm", &RootAuth::algorithm), + field("require_auth_for", &RootAuth::require_auth_for)); + +template <> +inline constexpr std::string_view dto_name = "RootAuth"; + +// ============================================================================= +// RootTls - "tls" sub-object inside RootOverview. +// +// Wire shape (from health_handlers.cpp handle_root TLS block): +// enabled - always true when this block is present (required) +// min_version - TLS minimum version string (required) +// ============================================================================= +struct RootTls { + bool enabled{false}; + std::string min_version; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("enabled", &RootTls::enabled), field("min_version", &RootTls::min_version)); + +template <> +inline constexpr std::string_view dto_name = "RootTls"; + +// ============================================================================= +// RootOverview - response for GET / (API root). +// +// Wire shape (from health_handlers.cpp handle_root): +// name - "ROS 2 Medkit Gateway" (required) +// version - gateway version string (required) +// api_base - API base path string (required) +// endpoints - array of endpoint description strings (required) +// capabilities - capabilities flags object (required) +// auth - optional auth info sub-object (present when auth enabled) +// tls - optional TLS info sub-object (present when TLS enabled) +// ============================================================================= +struct RootOverview { + std::string name; + std::string version; + std::string api_base; + std::vector endpoints; + RootCapabilities capabilities; + std::optional auth; + std::optional tls; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("name", &RootOverview::name), field("version", &RootOverview::version), + field("api_base", &RootOverview::api_base), field("endpoints", &RootOverview::endpoints), + field("capabilities", &RootOverview::capabilities), field("auth", &RootOverview::auth), + field("tls", &RootOverview::tls)); + +template <> +inline constexpr std::string_view dto_name = "RootOverview"; + +} // namespace dto +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp index 0824e8f3..48fd8c5a 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/registry.hpp @@ -28,6 +28,7 @@ #include "ros2_medkit_gateway/dto/entities.hpp" #include "ros2_medkit_gateway/dto/errors.hpp" #include "ros2_medkit_gateway/dto/faults.hpp" +#include "ros2_medkit_gateway/dto/health.hpp" #include "ros2_medkit_gateway/dto/locks.hpp" #include "ros2_medkit_gateway/dto/logs.hpp" #include "ros2_medkit_gateway/dto/operations.hpp" @@ -72,7 +73,10 @@ using AllDtos = // Software update domain DTOs UpdateList, UpdateSubProgress, XMedkitUpdate, UpdateStatus, // Auth domain DTOs - AuthCredentials, AuthTokenResponse>; + AuthCredentials, AuthTokenResponse, + // Health / Root domain DTOs + HealthDiscoveryLinking, HealthDiscovery, HealthAggregationWarning, Health, VersionInfoVendor, + VersionInfoEntry, XMedkitVersionInfo, VersionInfo, RootCapabilities, RootAuth, RootTls, RootOverview>; namespace detail { template From 486ff9eb3db829cf0c8376be62fcd63db4241f96 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 16:02:33 +0200 Subject: [PATCH 43/51] refactor(gateway): migrate health handlers to DTO contract Migrate handle_health, handle_root, and handle_version_info to build typed DTOs (Health, RootOverview, VersionInfo) and call send_dto instead of constructing raw nlohmann::json. The x-medkit-data-provider and x-medkit-subscription-executor endpoint-level vendor extension keys are modelled as optional fields with hyphened wire keys. The handle_root dynamic endpoint-list computation and the handle_version_info fan-out logic are preserved verbatim. Remove the three legacy hand-written schema factories (health_schema, version_info_schema, root_overview_schema) from schema_builder; the DTO registry now generates HealthStatus, VersionInfo, and RootOverview along with all sub-DTOs. Update test_schema_builder to assert against the DTO-generated schema shapes (component_schemas() and $ref linkage) instead of the removed factory methods. health_handlers.cpp no longer includes core/http/x_medkit.hpp. Zero code references to the three deleted factory names remain. All 2186 gateway unit tests pass. --- .../ros2_medkit_gateway/dto/health.hpp | 9 +- .../src/http/handlers/health_handlers.cpp | 197 +++++++++++------- .../src/openapi/schema_builder.cpp | 120 +---------- .../src/openapi/schema_builder.hpp | 9 - .../test/test_schema_builder.cpp | 65 ++++-- 5 files changed, 167 insertions(+), 233 deletions(-) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/health.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/health.hpp index a8147bff..f58e36ba 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/health.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/health.hpp @@ -33,13 +33,16 @@ namespace dto { // Wire shape (from health_handlers.cpp handle_health): // linked_count - number of runtime nodes linked to manifest apps (integer) // orphan_count - number of unlinked runtime nodes (integer) -// binding_conflicts - list of conflict description strings (array of strings) -// warnings - optional list of warning strings +// binding_conflicts - number of binding conflict events (integer) +// NOTE: the old health_schema() declared this as +// array, but the handler always emitted size_t. +// The DTO matches the actual wire format. +// warnings - optional list of diagnostic warning strings // ============================================================================= struct HealthDiscoveryLinking { int64_t linked_count{0}; int64_t orphan_count{0}; - std::vector binding_conflicts; + int64_t binding_conflicts{0}; std::optional> warnings; }; diff --git a/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp index b0ca438a..7c180dfe 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp @@ -24,9 +24,9 @@ #include "ros2_medkit_gateway/core/http/fan_out_helpers.hpp" #include "ros2_medkit_gateway/core/http/http_utils.hpp" #include "ros2_medkit_gateway/core/http/warning_codes.hpp" -#include "ros2_medkit_gateway/core/http/x_medkit.hpp" #include "ros2_medkit_gateway/core/version.hpp" #include "ros2_medkit_gateway/discovery/discovery_manager.hpp" +#include "ros2_medkit_gateway/dto/health.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" #include "../../openapi/route_registry.hpp" @@ -40,31 +40,35 @@ void HealthHandlers::handle_health(const httplib::Request & req, httplib::Respon (void)req; // Unused parameter try { - json response = {{"status", "healthy"}, {"timestamp", std::chrono::system_clock::now().time_since_epoch().count()}}; + dto::Health response; + response.status = "healthy"; + response.timestamp = std::chrono::system_clock::now().time_since_epoch().count(); // Add discovery info auto * dm = ctx_.node() ? ctx_.node()->get_discovery_manager() : nullptr; if (dm) { - json discovery_info = {{"mode", discovery_mode_to_string(dm->get_mode())}, {"strategy", dm->get_strategy_name()}}; + dto::HealthDiscovery discovery; + discovery.mode = discovery_mode_to_string(dm->get_mode()); + discovery.strategy = dm->get_strategy_name(); auto report = dm->get_merge_report(); if (report) { - discovery_info["pipeline"] = report->to_json(); + discovery.pipeline = report->to_json(); } auto linking = dm->get_linking_result(); if (linking) { - json linking_info; - linking_info["linked_count"] = linking->node_to_app.size(); - linking_info["orphan_count"] = linking->orphan_nodes.size(); - linking_info["binding_conflicts"] = linking->binding_conflicts; + dto::HealthDiscoveryLinking linking_dto; + linking_dto.linked_count = static_cast(linking->node_to_app.size()); + linking_dto.orphan_count = static_cast(linking->orphan_nodes.size()); + linking_dto.binding_conflicts = static_cast(linking->binding_conflicts); if (!linking->warnings.empty()) { - linking_info["warnings"] = linking->warnings; + linking_dto.warnings = linking->warnings; } - discovery_info["linking"] = linking_info; + discovery.linking = linking_dto; } - response["discovery"] = std::move(discovery_info); + response.discovery = std::move(discovery); } // Surface subscription-executor and data-provider stats via x-medkit-* @@ -74,28 +78,31 @@ void HealthHandlers::handle_health(const httplib::Request & req, httplib::Respon if (ctx_.node()) { if (auto * tdp = ctx_.node()->get_topic_data_provider()) { auto x = tdp->x_medkit_stats(); - for (auto it = x.begin(); it != x.end(); ++it) { - response[it.key()] = it.value(); + if (x.contains("x-medkit-data-provider")) { + response.x_medkit_data_provider = x["x-medkit-data-provider"]; + } + if (x.contains("x-medkit-subscription-executor")) { + response.x_medkit_subscription_executor = x["x-medkit-subscription-executor"]; } } } // Add peer status when aggregation is active if (auto * agg = ctx_.aggregation_manager()) { - response["peers"] = agg->get_peer_status(); + response.peers = agg->get_peer_status(); // Contract version for the warnings array. Increment whenever a code // is added or the shape of a warning object changes so typed clients // (MCP, Web UI, Foxglove) can feature-detect instead of relying on // capabilities.aggregation (a boolean, too coarse). // Keep in sync with docs/api/warning_codes.rst "Schema versioning". - response["warning_schema_version"] = kWarningSchemaVersion; + response.warning_schema_version = static_cast(kWarningSchemaVersion); // Surface operator-actionable aggregation warnings (x-medkit extension). // Always an array when aggregation is active; empty means no active // warnings. Clients can feature-detect via /.capabilities.aggregation // in the root response. - json warnings = json::array(); + std::vector warnings; for (const auto & w : agg->get_leaf_warnings()) { std::string peers_list; for (size_t i = 0; i < w.peer_names.size(); ++i) { @@ -104,22 +111,21 @@ void HealthHandlers::handle_health(const httplib::Request & req, httplib::Respon } peers_list += w.peer_names[i]; } - std::string message = "Component '" + w.entity_id + "' is announced by multiple peers (" + peers_list + - "); routing falls back to last-writer-wins which is non-deterministic. Resolve by " - "renaming the Component on one side or by modelling it as a hierarchical parent " - "(declare a child Component with parentComponentId='" + - w.entity_id + "' on the owning peer)."; - warnings.push_back({ - {"code", WARN_LEAF_ID_COLLISION}, - {"message", std::move(message)}, - {"entity_ids", json::array({w.entity_id})}, - {"peer_names", w.peer_names}, - }); + dto::HealthAggregationWarning warning; + warning.code = WARN_LEAF_ID_COLLISION; + warning.message = "Component '" + w.entity_id + "' is announced by multiple peers (" + peers_list + + "); routing falls back to last-writer-wins which is non-deterministic. Resolve by " + "renaming the Component on one side or by modelling it as a hierarchical parent " + "(declare a child Component with parentComponentId='" + + w.entity_id + "' on the owning peer)."; + warning.entity_ids = {w.entity_id}; + warning.peer_names = w.peer_names; + warnings.push_back(std::move(warning)); } - response["warnings"] = std::move(warnings); + response.warnings = std::move(warnings); } - HandlerContext::send_json(res, response); + HandlerContext::send_dto(res, response); } catch (const std::exception & e) { HandlerContext::send_error(res, 500, ERR_INTERNAL_ERROR, "Internal server error"); RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_health: %s", e.what()); @@ -131,7 +137,7 @@ void HealthHandlers::handle_root(const httplib::Request & req, httplib::Response try { // Generate endpoint list from route registry (single source of truth) - json endpoints = json::array(); + std::vector endpoints; if (route_registry_) { auto ep_list = route_registry_->to_endpoint_list(API_BASE_PATH); for (auto & ep : ep_list) { @@ -161,53 +167,55 @@ void HealthHandlers::handle_root(const httplib::Request & req, httplib::Response const auto & auth_config = ctx_.auth_config(); const auto & tls_config = ctx_.tls_config(); - json capabilities = { - {"discovery", true}, - {"data_access", true}, - {"operations", true}, - {"async_actions", true}, - {"configurations", true}, - {"faults", true}, - {"logs", true}, - {"bulk_data", true}, - {"cyclic_subscriptions", true}, - {"locking", ctx_.node() && ctx_.node()->get_lock_manager() != nullptr}, - {"triggers", ctx_.node() && ctx_.node()->get_trigger_manager() != nullptr}, - {"updates", ctx_.node() && ctx_.node()->get_update_manager() != nullptr}, - {"authentication", auth_config.enabled}, - {"tls", tls_config.enabled}, - {"scripts", ctx_.node() && ctx_.node()->get_script_manager() != nullptr && - ctx_.node()->get_script_manager()->has_backend()}, - {"aggregation", ctx_.aggregation_manager() != nullptr}, - {"vendor_extensions", - ctx_.node() && ctx_.node()->get_plugin_manager() && ctx_.node()->get_plugin_manager()->has_plugins()}, - }; - - json response = { - {"name", "ROS 2 Medkit Gateway"}, {"version", kGatewayVersion}, {"api_base", API_BASE_PATH}, - {"endpoints", endpoints}, {"capabilities", capabilities}, - }; + dto::RootCapabilities capabilities; + capabilities.discovery = true; + capabilities.data_access = true; + capabilities.operations = true; + capabilities.async_actions = true; + capabilities.configurations = true; + capabilities.faults = true; + capabilities.logs = true; + capabilities.bulk_data = true; + capabilities.cyclic_subscriptions = true; + capabilities.locking = ctx_.node() && ctx_.node()->get_lock_manager() != nullptr; + capabilities.triggers = ctx_.node() && ctx_.node()->get_trigger_manager() != nullptr; + capabilities.updates = ctx_.node() && ctx_.node()->get_update_manager() != nullptr; + capabilities.authentication = auth_config.enabled; + capabilities.tls = tls_config.enabled; + capabilities.scripts = + ctx_.node() && ctx_.node()->get_script_manager() != nullptr && ctx_.node()->get_script_manager()->has_backend(); + capabilities.aggregation = ctx_.aggregation_manager() != nullptr; + capabilities.vendor_extensions = + ctx_.node() && ctx_.node()->get_plugin_manager() && ctx_.node()->get_plugin_manager()->has_plugins(); + + dto::RootOverview response; + response.name = "ROS 2 Medkit Gateway"; + response.version = kGatewayVersion; + response.api_base = API_BASE_PATH; + response.endpoints = std::move(endpoints); + response.capabilities = capabilities; // Add auth info if enabled if (auth_config.enabled) { - response["auth"] = { - {"enabled", true}, - {"algorithm", algorithm_to_string(auth_config.jwt_algorithm)}, - {"require_auth_for", auth_config.require_auth_for == AuthRequirement::NONE ? "none" - : auth_config.require_auth_for == AuthRequirement::WRITE ? "write" - : "all"}, - }; + dto::RootAuth auth; + auth.enabled = true; + auth.algorithm = algorithm_to_string(auth_config.jwt_algorithm); + auth.require_auth_for = auth_config.require_auth_for == AuthRequirement::NONE ? "none" + : auth_config.require_auth_for == AuthRequirement::WRITE ? "write" + : "all"; + response.auth = std::move(auth); } // Add TLS info if enabled if (tls_config.enabled) { - response["tls"] = { - {"enabled", true}, {"min_version", tls_config.min_version}, - // TODO(future): Add mutual_tls when implemented - }; + dto::RootTls tls; + tls.enabled = true; + tls.min_version = tls_config.min_version; + // TODO(future): Add mutual_tls when implemented + response.tls = std::move(tls); } - HandlerContext::send_json(res, response); + HandlerContext::send_dto(res, response); } catch (const std::exception & e) { HandlerContext::send_error(res, 500, ERR_INTERNAL_ERROR, "Internal server error"); RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_root: %s", e.what()); @@ -217,20 +225,47 @@ void HealthHandlers::handle_root(const httplib::Request & req, httplib::Response void HealthHandlers::handle_version_info(const httplib::Request & req, httplib::Response & res) { try { // SOVD 7.4.1 compliant response format - json sovd_info_entry = { - {"version", kSovdVersion}, // SOVD standard version - {"base_uri", API_BASE_PATH}, // Version-specific base URI - {"vendor_info", {{"version", kGatewayVersion}, {"name", "ros2_medkit"}}} // Vendor-specific info - }; - - json response = {{"items", json::array({sovd_info_entry})}}; - - XMedkit ext; - merge_peer_items(ctx_.aggregation_manager(), req, response, ext); - if (!ext.empty()) { - response["x-medkit"] = ext.build(); + dto::VersionInfoVendor vendor; + vendor.version = kGatewayVersion; + vendor.name = "ros2_medkit"; + + dto::VersionInfoEntry entry; + entry.version = kSovdVersion; // SOVD standard version + entry.base_uri = API_BASE_PATH; // Version-specific base URI + entry.vendor_info = std::move(vendor); + + dto::VersionInfo response; + response.items.push_back(std::move(entry)); + + // Fan-out aggregation: merge items from peers and collect x-medkit metadata + json response_json = dto::JsonWriter::write(response); + dto::XMedkitVersionInfo ext_dto; + json ext_json = nlohmann::json::object(); + merge_peer_items(ctx_.aggregation_manager(), req, response_json, ext_json); + if (!ext_json.empty()) { + if (ext_json.contains("partial")) { + ext_dto.partial = ext_json["partial"].get(); + } + if (ext_json.contains("failed_peers")) { + ext_dto.failed_peers = ext_json["failed_peers"].get>(); + } } - HandlerContext::send_json(res, response); + + // Re-parse merged items back into the DTO and attach x-medkit if present + if (response_json.contains("items") && response_json["items"].is_array()) { + response.items.clear(); + for (const auto & item : response_json["items"]) { + auto parsed = dto::JsonReader::read(item); + if (parsed.has_value()) { + response.items.push_back(std::move(*parsed)); + } + } + } + if (ext_dto.partial.has_value() || ext_dto.failed_peers.has_value()) { + response.x_medkit = std::move(ext_dto); + } + + HandlerContext::send_dto(res, response); } catch (const std::exception & e) { HandlerContext::send_error(res, 500, ERR_INTERNAL_ERROR, "Internal server error"); RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_version_info: %s", e.what()); diff --git a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp index 6c29e859..2211d764 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp @@ -53,120 +53,6 @@ nlohmann::json SchemaBuilder::items_wrapper(const nlohmann::json & item_schema) {"required", {"items"}}}; } -nlohmann::json SchemaBuilder::health_schema() { - 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"}}; - - nlohmann::json peer_status_schema = {{"type", "object"}, {"additionalProperties", true}}; - - nlohmann::json aggregation_warning_schema = { - {"type", "object"}, - {"description", - "Operator-actionable aggregation warning. Codes are documented in " - "docs/api/warning_codes.rst and stable across releases."}, - {"properties", - {{"code", - {{"type", "string"}, {"description", "Stable machine-readable identifier, e.g. 'leaf_id_collision'."}}}, - {"message", {{"type", "string"}, {"description", "Human-readable description including remediation hints."}}}, - {"entity_ids", - {{"type", "array"}, - {"items", {{"type", "string"}}}, - {"description", "SOVD entity IDs affected by the warning."}}}, - {"peer_names", - {{"type", "array"}, - {"items", {{"type", "string"}}}, - {"description", "Aggregation peers involved in the anomaly."}}}}}, - {"required", {"code", "message", "entity_ids", "peer_names"}}}; - - return { - {"type", "object"}, - {"properties", - {{"status", {{"type", "string"}}}, - {"timestamp", {{"type", "integer"}}}, - {"discovery", discovery_schema}, - {"peers", - {{"type", "array"}, - {"items", peer_status_schema}, - {"description", "Aggregation peer status (x-medkit extension; present only when aggregation is enabled)."}}}, - {"warnings", - {{"type", "array"}, - {"items", aggregation_warning_schema}, - {"description", - "Operator-actionable aggregation warnings (x-medkit extension; always an array when " - "aggregation is enabled, empty when there are no active warnings)."}}}}}, - {"required", {"status"}}}; -} - -nlohmann::json SchemaBuilder::version_info_schema() { - nlohmann::json vendor_info_schema = { - {"type", "object"}, - {"properties", {{"version", {{"type", "string"}}}, {"name", {{"type", "string"}}}}}, - {"required", {"version", "name"}}}; - - nlohmann::json info_entry_schema = { - {"type", "object"}, - {"properties", - {{"version", {{"type", "string"}}}, {"base_uri", {{"type", "string"}}}, {"vendor_info", vendor_info_schema}}}, - {"required", {"version", "base_uri"}}}; - - return {{"type", "object"}, - {"properties", {{"items", {{"type", "array"}, {"items", info_entry_schema}}}}}, - {"required", {"items"}}}; -} - -nlohmann::json SchemaBuilder::root_overview_schema() { - nlohmann::json capabilities_schema = {{"type", "object"}, - {"properties", - {{"discovery", {{"type", "boolean"}}}, - {"data_access", {{"type", "boolean"}}}, - {"operations", {{"type", "boolean"}}}, - {"async_actions", {{"type", "boolean"}}}, - {"configurations", {{"type", "boolean"}}}, - {"faults", {{"type", "boolean"}}}, - {"logs", {{"type", "boolean"}}}, - {"bulk_data", {{"type", "boolean"}}}, - {"cyclic_subscriptions", {{"type", "boolean"}}}, - {"locking", {{"type", "boolean"}}}, - {"triggers", {{"type", "boolean"}}}, - {"updates", {{"type", "boolean"}}}, - {"authentication", {{"type", "boolean"}}}, - {"tls", {{"type", "boolean"}}}, - {"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}, - {"auth", auth_schema}, - {"tls", tls_schema}}}, - {"required", {"name", "version", "api_base", "endpoints", "capabilities"}}}; -} - nlohmann::json SchemaBuilder::generic_object_schema() { return {{"type", "object"}}; } @@ -192,10 +78,8 @@ const std::map & SchemaBuilder::component_schemas() {"GenericError", generic_error()}, // Logs - LogEntry, LogEntryList, LogConfiguration, LogContext, LogListXMedkit // now come from DTO (dto/logs.hpp). - // Server - {"HealthStatus", health_schema()}, - {"VersionInfo", version_info_schema()}, - {"RootOverview", root_overview_schema()}, + // Health / Root - HealthStatus, VersionInfo, RootOverview and sub-DTOs + // now come from DTO (dto/health.hpp). // Operations - OperationItem, OperationDetail, OperationExecution, // ExecutionUpdateRequest now come from DTO (dto/operations.hpp). // OperationExecutionList is kept here as a thin wrapper over the DTO type. diff --git a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp index 1ae435ef..f0142fd1 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.hpp @@ -53,15 +53,6 @@ class SchemaBuilder { /// Wrap an item schema in a SOVD collection response: {"items": [item_schema]} static nlohmann::json items_wrapper(const nlohmann::json & item_schema); - /// Health endpoint response schema - static nlohmann::json health_schema(); - - /// Version-info endpoint response schema (SOVD 7.4.1) - static nlohmann::json version_info_schema(); - - /// API root overview response schema (GET /) - static nlohmann::json root_overview_schema(); - /// Generic object schema (for dynamic ROS 2 message payloads) static nlohmann::json generic_object_schema(); diff --git a/src/ros2_medkit_gateway/test/test_schema_builder.cpp b/src/ros2_medkit_gateway/test/test_schema_builder.cpp index 65b8e14e..3f4f8922 100644 --- a/src/ros2_medkit_gateway/test/test_schema_builder.cpp +++ b/src/ros2_medkit_gateway/test/test_schema_builder.cpp @@ -425,46 +425,67 @@ TEST(SchemaBuilderStaticTest, LogEntryListRegistered) { EXPECT_NE(std::find(required.begin(), required.end(), "items"), required.end()); } -TEST(SchemaBuilderStaticTest, HealthSchema) { - auto schema = SchemaBuilder::health_schema(); +TEST(SchemaBuilderStaticTest, HealthSchemaComesFromDto) { + // Health, HealthDiscovery, etc. now come from the DTO (dto/health.hpp). + // DTO-generated schema name is "HealthStatus" (dto_name). + namespace dto = ros2_medkit_gateway::dto; + const auto & schemas = SchemaBuilder::component_schemas(); + ASSERT_TRUE(schemas.count("HealthStatus") > 0) << "HealthStatus schema must be registered"; + const auto & schema = schemas.at("HealthStatus"); EXPECT_EQ(schema["type"], "object"); ASSERT_TRUE(schema.contains("properties")); EXPECT_TRUE(schema["properties"].contains("status")); EXPECT_TRUE(schema["properties"].contains("timestamp")); + // discovery is a $ref to HealthDiscovery (not inline in the HealthStatus schema) EXPECT_TRUE(schema["properties"].contains("discovery")); EXPECT_EQ(schema["properties"]["status"]["type"], "string"); EXPECT_EQ(schema["properties"]["timestamp"]["type"], "integer"); - // Discovery subfields - auto & discovery = schema["properties"]["discovery"]; - EXPECT_EQ(discovery["type"], "object"); - EXPECT_TRUE(discovery["properties"].contains("mode")); - EXPECT_TRUE(discovery["properties"].contains("strategy")); - EXPECT_EQ(discovery["properties"]["mode"]["type"], "string"); - EXPECT_EQ(discovery["properties"]["strategy"]["type"], "string"); - - // Required + // Required: status and timestamp ASSERT_TRUE(schema.contains("required")); auto required = schema["required"].get>(); EXPECT_NE(std::find(required.begin(), required.end(), "status"), required.end()); + EXPECT_NE(std::find(required.begin(), required.end(), "timestamp"), required.end()); + + // HealthDiscovery sub-DTO must also be registered (discovery field is a $ref) + ASSERT_TRUE(schemas.count("HealthDiscovery") > 0) << "HealthDiscovery schema must be registered"; + const auto & disc_schema = schemas.at("HealthDiscovery"); + EXPECT_EQ(disc_schema["type"], "object"); + ASSERT_TRUE(disc_schema.contains("properties")); + EXPECT_TRUE(disc_schema["properties"].contains("mode")); + EXPECT_TRUE(disc_schema["properties"].contains("strategy")); + EXPECT_EQ(disc_schema["properties"]["mode"]["type"], "string"); + EXPECT_EQ(disc_schema["properties"]["strategy"]["type"], "string"); } -TEST(SchemaBuilderStaticTest, VersionInfoSchema) { - auto schema = SchemaBuilder::version_info_schema(); +TEST(SchemaBuilderStaticTest, VersionInfoSchemaComesFromDto) { + // VersionInfo, VersionInfoEntry, VersionInfoVendor now come from the DTO (dto/health.hpp). + const auto & schemas = SchemaBuilder::component_schemas(); + ASSERT_TRUE(schemas.count("VersionInfo") > 0) << "VersionInfo schema must be registered"; + const auto & schema = schemas.at("VersionInfo"); EXPECT_EQ(schema["type"], "object"); ASSERT_TRUE(schema.contains("properties")); ASSERT_TRUE(schema["properties"].contains("items")); EXPECT_EQ(schema["properties"]["items"]["type"], "array"); - // Items should have version, base_uri, and vendor_info - auto & item_schema = schema["properties"]["items"]["items"]; - EXPECT_EQ(item_schema["type"], "object"); - EXPECT_TRUE(item_schema["properties"].contains("version")); - EXPECT_TRUE(item_schema["properties"].contains("base_uri")); - EXPECT_TRUE(item_schema["properties"].contains("vendor_info")); - - // vendor_info should have version and name - auto & vendor_schema = item_schema["properties"]["vendor_info"]; + // items array items are a $ref to VersionInfoEntry (DTO-generated, not inline) + ASSERT_TRUE(schema["properties"]["items"].contains("items")); + const auto & item_ref = schema["properties"]["items"]["items"]; + ASSERT_TRUE(item_ref.contains("$ref")); + EXPECT_EQ(item_ref.at("$ref"), "#/components/schemas/VersionInfoEntry"); + + // VersionInfoEntry sub-DTO must also be registered + ASSERT_TRUE(schemas.count("VersionInfoEntry") > 0) << "VersionInfoEntry schema must be registered"; + const auto & entry_schema = schemas.at("VersionInfoEntry"); + EXPECT_EQ(entry_schema["type"], "object"); + ASSERT_TRUE(entry_schema.contains("properties")); + EXPECT_TRUE(entry_schema["properties"].contains("version")); + EXPECT_TRUE(entry_schema["properties"].contains("base_uri")); + EXPECT_TRUE(entry_schema["properties"].contains("vendor_info")); + + // vendor_info is a $ref to VersionInfoVendor + ASSERT_TRUE(schemas.count("VersionInfoVendor") > 0) << "VersionInfoVendor schema must be registered"; + const auto & vendor_schema = schemas.at("VersionInfoVendor"); EXPECT_EQ(vendor_schema["type"], "object"); EXPECT_TRUE(vendor_schema["properties"].contains("version")); EXPECT_TRUE(vendor_schema["properties"].contains("name")); From b14a55077a3b29efabe2398cd42acc53d171d0d4 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 16:20:45 +0200 Subject: [PATCH 44/51] refactor(gateway): remove the legacy XMedkit builder Delete core/http/x_medkit.hpp, src/core/http/x_medkit.cpp and their test (test_x_medkit.cpp). All 13 handler domains now use typed dto::XMedkit* structs + dto::JsonWriter instead of the fluent builder. The one straggler was fan_out_helpers.hpp which kept a merge_peer_items(XMedkit&) overload alongside the already-migrated nlohmann::json& overload. The legacy overload is removed; the json& overload is the only one remaining. test_fan_out_helpers.cpp is updated to use plain json objects in place of XMedkit for the ext parameter. component_schemas() is simplified: all domain schemas now come from dto::collect_component_schemas(); the only surviving hand-written entry is OperationExecutionList (a thin items-wrapper without a dedicated DTO type). The earlier GenericError and per-domain factory calls that were superseded by the DTO registry are gone. send_json in handler_context.hpp is documented as an escape-hatch for dynamic-payload, fan-out, and spec-blob callers; prefer send_dto for typed responses. CMakeLists.txt: remove ament_add_gtest(test_x_medkit ...) block and the test_x_medkit entry from the coverage target list. Build: clean. Unit suite: 90 tests, 0 failures (2632 subtests). --- src/ros2_medkit_gateway/CMakeLists.txt | 5 - .../core/http/fan_out_helpers.hpp | 55 +-- .../core/http/x_medkit.hpp | 216 ----------- .../ros2_medkit_gateway/dto/config.hpp | 2 - .../include/ros2_medkit_gateway/dto/data.hpp | 18 +- .../include/ros2_medkit_gateway/dto/logs.hpp | 1 - .../ros2_medkit_gateway/dto/operations.hpp | 2 - .../http/handlers/handler_context.hpp | 7 +- .../src/core/aggregation/peer_client.cpp | 8 +- .../src/core/http/x_medkit.cpp | 151 -------- .../src/http/handlers/data_handlers.cpp | 1 - .../src/openapi/schema_builder.cpp | 27 +- .../test/test_fan_out_helpers.cpp | 42 +-- .../test/test_x_medkit.cpp | 341 ------------------ 14 files changed, 45 insertions(+), 831 deletions(-) delete mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/x_medkit.hpp delete mode 100644 src/ros2_medkit_gateway/src/core/http/x_medkit.cpp delete mode 100644 src/ros2_medkit_gateway/test/test_x_medkit.cpp diff --git a/src/ros2_medkit_gateway/CMakeLists.txt b/src/ros2_medkit_gateway/CMakeLists.txt index dff2960a..b1ea8be5 100644 --- a/src/ros2_medkit_gateway/CMakeLists.txt +++ b/src/ros2_medkit_gateway/CMakeLists.txt @@ -631,10 +631,6 @@ if(BUILD_TESTING) target_link_libraries(test_handler_context gateway_ros2) medkit_set_test_domain(test_handler_context) - # Add x-medkit extension tests - ament_add_gtest(test_x_medkit test/test_x_medkit.cpp) - target_link_libraries(test_x_medkit gateway_ros2) - # DTO contract core (pure C++17, no ROS node) ament_add_gtest(test_dto_contract test/test_dto_contract.cpp) target_link_libraries(test_dto_contract gateway_core) @@ -933,7 +929,6 @@ if(BUILD_TESTING) test_manifest_manager test_capability_builder test_handler_context - test_x_medkit test_rate_limiter test_auth_config test_data_access_manager diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/fan_out_helpers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/fan_out_helpers.hpp index a7856549..38b29ba4 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/fan_out_helpers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/fan_out_helpers.hpp @@ -23,7 +23,6 @@ #include #include "ros2_medkit_gateway/aggregation/aggregation_manager.hpp" -#include "ros2_medkit_gateway/core/http/x_medkit.hpp" namespace ros2_medkit_gateway { @@ -101,57 +100,9 @@ inline std::string build_fan_out_path(const httplib::Request & req) { return path; } -inline void merge_peer_items(AggregationManager * agg, const httplib::Request & req, nlohmann::json & result, - XMedkit & ext) { - if (agg == nullptr) { - return; - } - if (req.has_header("X-Medkit-No-Fan-Out")) { - return; - } - // Skip fan-out when no healthy peers to avoid blocking the httplib handler - // thread on network I/O (up to timeout_ms per request). With fan-out on all - // per-entity collection endpoints, concurrent requests during a peer outage - // could exhaust httplib's thread pool if we don't bail out early here. - if (agg->healthy_peer_count() == 0) { - return; - } - // For per-entity collection paths, target only the peers that host or - // contribute to the entity (routed leaves and merged / hierarchical - // entities). Local-only entities produce an empty target list and skip - // fan-out entirely, avoiding spurious `partial: true` / `failed_peers` - // from peers that do not own the entity. Global collection endpoints - // (paths without an entity id) keep fan-out-to-all behavior. - std::optional> contributors_buffer; - const std::vector * target_peers = nullptr; - if (auto entity_id = extract_entity_id_for_fan_out(req.path); entity_id.has_value()) { - contributors_buffer = agg->get_peer_contributors(*entity_id); - if (contributors_buffer->empty()) { - return; // local-only: no peer hosts this entity - } - target_peers = &contributors_buffer.value(); - } - auto fan_path = build_fan_out_path(req); - auto fan_result = agg->fan_out_get(fan_path, req.get_header_value("Authorization"), target_peers); - if (fan_result.merged_items.is_array() && !fan_result.merged_items.empty()) { - if (!result.contains("items") || !result["items"].is_array()) { - result["items"] = nlohmann::json::array(); - } - for (const auto & item : fan_result.merged_items) { - if (item.is_object()) { - result["items"].push_back(item); - } - } - } - if (fan_result.is_partial) { - ext.add("partial", true); - ext.add("failed_peers", fan_result.failed_peers); - } -} - -/// Overload for handlers that have been migrated off the XMedkit fluent -/// builder. The partial/failed_peers aggregation metadata is written -/// directly into `ext_json` (a JSON object) instead of going through XMedkit. +/// Fan-out GET to peer gateways and merge their items into `result["items"]`. +/// Aggregation metadata (partial, failed_peers) is written into `ext_json` +/// (a plain JSON object that callers fold into the response x-medkit block). inline void merge_peer_items(AggregationManager * agg, const httplib::Request & req, nlohmann::json & result, nlohmann::json & ext_json) { if (agg == nullptr) { diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/x_medkit.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/x_medkit.hpp deleted file mode 100644 index 08429979..00000000 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/x_medkit.hpp +++ /dev/null @@ -1,216 +0,0 @@ -// Copyright 2025 bburda, mfaferek93 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -#pragma once - -#include -#include -#include - -namespace ros2_medkit_gateway { - -/** - * @brief Fluent builder for x-medkit extension JSON object. - * - * The x-medkit extension provides a clean separation between SOVD-compliant - * fields and ros2_medkit-specific extensions in API responses. - * - * Example usage: - * @code - * XMedkit ext; - * ext.ros2_node("/sensors/temp_sensor") - * .ros2_type("sensor_msgs/msg/Temperature") - * .source("heuristic") - * .is_online(true); - * - * json response; - * response["id"] = "temp_sensor"; - * response["name"] = "Temperature Sensor"; - * if (!ext.empty()) { - * response["x-medkit"] = ext.build(); - * } - * @endcode - * - * Output structure: - * @code{.json} - * { - * "id": "temp_sensor", - * "name": "Temperature Sensor", - * "x-medkit": { - * "ros2": { - * "node": "/sensors/temp_sensor", - * "type": "sensor_msgs/msg/Temperature" - * }, - * "source": "heuristic", - * "is_online": true - * } - * } - * @endcode - */ -class XMedkit { - public: - XMedkit() = default; - - // ==================== ROS2 metadata ==================== - - /** - * @brief Set the ROS2 node name. - * @param node_name Fully qualified node name (e.g., "/namespace/node_name") - */ - XMedkit & ros2_node(const std::string & node_name); - - /** - * @brief Set the ROS2 namespace. - * @param ns Namespace (e.g., "/sensors") - */ - XMedkit & ros2_namespace(const std::string & ns); - - /** - * @brief Set the ROS2 message/service/action type. - * @param type Type string (e.g., "std_msgs/msg/String") - */ - XMedkit & ros2_type(const std::string & type); - - /** - * @brief Set the ROS2 topic name. - * @param topic Topic path (e.g., "/sensors/temperature") - */ - XMedkit & ros2_topic(const std::string & topic); - - /** - * @brief Set the ROS2 service name. - * @param service Service path (e.g., "/calibrate") - */ - XMedkit & ros2_service(const std::string & service); - - /** - * @brief Set the ROS2 action name. - * @param action Action path (e.g., "/navigate") - */ - XMedkit & ros2_action(const std::string & action); - - /** - * @brief Set the ROS2 interface kind. - * @param kind Interface kind ("topic", "service", "action") - */ - XMedkit & ros2_kind(const std::string & kind); - - // ==================== Discovery metadata ==================== - - /** - * @brief Set the entity discovery source. - * @param source Discovery source ("heuristic", "static", "runtime") - */ - XMedkit & source(const std::string & source); - - /** - * @brief Set the entity online status. - * @param online True if the entity is currently online/available - */ - XMedkit & is_online(bool online); - - /** - * @brief Set the parent component ID. - * @param id Component identifier - */ - XMedkit & component_id(const std::string & id); - - /** - * @brief Set a generic entity ID reference. - * @param id Entity identifier - */ - XMedkit & entity_id(const std::string & id); - - // ==================== Type introspection ==================== - - /** - * @brief Set type introspection information. - * @param info JSON object with type metadata - */ - XMedkit & type_info(const nlohmann::json & info); - - /** - * @brief Set ROS2 IDL type schema. - * - * Note: This is distinct from SOVD's OpenAPI schema. The type_schema contains - * ROS2 message/service/action structure derived from IDL definitions. - * - * @param schema JSON object with IDL-derived type structure - */ - XMedkit & type_schema(const nlohmann::json & schema); - - // ==================== Execution tracking ==================== - - /** - * @brief Set the ROS2 action goal ID. - * @param id Goal UUID string - */ - XMedkit & goal_id(const std::string & id); - - /** - * @brief Set the ROS2 action goal status. - * @param status Status string ("pending", "executing", "succeeded", "canceled", "aborted") - */ - XMedkit & goal_status(const std::string & status); - - /** - * @brief Set the last received action feedback. - * @param feedback JSON object with feedback data - */ - XMedkit & last_feedback(const nlohmann::json & feedback); - - // ==================== Generic methods ==================== - - /** - * @brief Append aggregation provenance ("local" and/or "peer:"). - * - * Emits ``contributors`` only when the vector is non-empty, so single-origin - * entities on non-aggregating gateways do not see the field. - * - * @param contributors Provenance list populated by the aggregation layer - */ - XMedkit & contributors(const std::vector & contributors); - - /** - * @brief Add a custom field to the x-medkit object (top level). - * @param key Field name - * @param value Field value - */ - XMedkit & add(const std::string & key, const nlohmann::json & value); - - /** - * @brief Add a custom field to the ros2 sub-object. - * @param key Field name - * @param value Field value - */ - XMedkit & add_ros2(const std::string & key, const nlohmann::json & value); - - /** - * @brief Build the final x-medkit JSON object. - * @return JSON object containing all set fields - */ - nlohmann::json build() const; - - /** - * @brief Check if any fields have been set. - * @return True if no fields have been set - */ - bool empty() const; - - private: - nlohmann::json ros2_; ///< ROS2-specific metadata - nlohmann::json other_; ///< Other extension fields -}; - -} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/config.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/config.hpp index 85a8d5e7..90bb847b 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/config.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/config.hpp @@ -78,7 +78,6 @@ inline constexpr std::string_view dto_name = "Configurati // ConfigListXMedkit - x-medkit vendor extension on configuration list responses. // Emitted by handle_list_configurations (root of the response object). // -// Wire keys (from config_handlers.cpp XMedkit builder calls): // entity_id - SOVD entity ID being queried // source - always "runtime" // aggregation_level - level string from EntityCache (e.g. "app", "component") @@ -118,7 +117,6 @@ inline constexpr std::string_view dto_name = "ConfigListXMedk // value responses. Emitted by handle_get_configuration and // handle_set_configuration. // -// Wire keys (from config_handlers.cpp XMedkit builder calls): // ros2 - nested ROS 2 metadata sub-object (node FQN) // entity_id - SOVD entity ID // source - always "runtime" diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/data.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/data.hpp index dc86d99a..2a11998c 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/data.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/data.hpp @@ -36,20 +36,20 @@ namespace dto { // items (handle_list_data per-topic), on data write responses // (handle_put_data_item), and on data read responses (handle_get_data_item). // -// Wire keys for list items (from handle_list_data per-item XMedkit builder): -// ros2.topic - ROS 2 topic path (via ext.ros2_topic()) +// Wire keys for list items: +// ros2.topic - ROS 2 topic path // ros2.direction - topic direction: "publish" | "subscribe" | "both" -// (via ext.add_ros2("direction", ...); maps to XMedkitRos2::direction) -// ros2.type - ROS 2 message type string (via ext.ros2_type()) +// (maps to XMedkitRos2::direction) +// ros2.type - ROS 2 message type string // type_info - dynamic type schema + default_value (free-form JSON; // only present when type introspection succeeds) // -// Additional keys for write responses (from handle_put_data_item): -// entity_id - SOVD entity ID (via ext.entity_id()) -// status - publish result status (via ext.add("status", ...)) -// publisher_created - true when a new publisher was created (via ext.add(...)) +// Additional keys for write responses (handle_put_data_item): +// entity_id - SOVD entity ID +// status - publish result status +// publisher_created - true when a new publisher was created // -// Additional keys for read responses (from handle_get_data_item): +// Additional keys for read responses (handle_get_data_item): // timestamp - sample timestamp in nanoseconds since epoch (int64) // publisher_count - number of publishers on the topic at sample time (int64) // subscriber_count - number of subscribers on the topic at sample time (int64) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/logs.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/logs.hpp index 5bb1d9b9..100eea6c 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/logs.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/logs.hpp @@ -97,7 +97,6 @@ inline constexpr std::string_view dto_name> = "LogEntryList // LogListXMedkit - typed x-medkit vendor extension on log list responses. // // Emitted by handle_get_logs on FUNCTION / AREA / COMPONENT / APP entities. -// Wire keys (from log_handlers.cpp XMedkit builder usages): // // entity_id - SOVD entity ID (all aggregating entity types) // aggregation_level - "function"|"area"|"component" (aggregating entities) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/operations.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/operations.hpp index 6569f98e..105bc223 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/operations.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/operations.hpp @@ -32,7 +32,6 @@ namespace dto { // XMedkitOperationItem - x-medkit vendor extension on OperationItem responses // (handle_list_operations / handle_get_operation). // -// Wire keys (from XMedkit builder in those handlers): // ros2 - nested ROS 2 metadata sub-object (service|action path, type, // kind) // entity_id - SOVD entity the operation belongs to @@ -62,7 +61,6 @@ inline constexpr std::string_view dto_name = "XMedkitOpera // XMedkitOperationExecution - x-medkit vendor extension on OperationExecution // responses (handle_get_execution). // -// Wire keys (from handle_get_execution XMedkit builder): // goal_id - UUID of the tracked ROS 2 action goal // ros2_status - raw ROS 2 goal status string (accepted|executing|canceling| // succeeded|canceled|aborted|unknown) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handler_context.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handler_context.hpp index 2f8d51e8..c66adba1 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handler_context.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handler_context.hpp @@ -310,7 +310,12 @@ class HandlerContext { const nlohmann::json & extra_params = {}); /** - * @brief Send JSON success response + * @brief Send JSON success response (escape-hatch for raw JSON payloads) + * + * Prefer send_dto for typed responses. Use send_json only when the + * payload cannot be represented as a DTO: dynamic ROS message payloads + * (data/update read handlers), fan-out merged responses, or OpenAPI spec + * blobs. */ static void send_json(httplib::Response & res, const nlohmann::json & data); diff --git a/src/ros2_medkit_gateway/src/core/aggregation/peer_client.cpp b/src/ros2_medkit_gateway/src/core/aggregation/peer_client.cpp index 59c644f0..26bc3d2d 100644 --- a/src/ros2_medkit_gateway/src/core/aggregation/peer_client.cpp +++ b/src/ros2_medkit_gateway/src/core/aggregation/peer_client.cpp @@ -252,10 +252,10 @@ App parse_app(const nlohmann::json & j) { if (j.contains("x-medkit") && j["x-medkit"].is_object()) { const auto & xm = j["x-medkit"]; - // Vendor fallback: gateway emits x-medkit.component_id (snake_case) via - // XMedkit builder in discovery_handlers.cpp. Only used if the SOVD - // standard is-located-on field is absent. Validated for the same - // reasons as component_id_from_located_on - the value is peer-provided. + // Vendor fallback: gateway emits x-medkit.component_id (snake_case) in + // discovery_handlers.cpp. Only used if the SOVD standard is-located-on + // field is absent. Validated for the same reasons as + // component_id_from_located_on - the value is peer-provided. if (app.component_id.empty()) { auto candidate = xm.value("component_id", ""); if (is_valid_entity_id(candidate)) { diff --git a/src/ros2_medkit_gateway/src/core/http/x_medkit.cpp b/src/ros2_medkit_gateway/src/core/http/x_medkit.cpp deleted file mode 100644 index f3c6187d..00000000 --- a/src/ros2_medkit_gateway/src/core/http/x_medkit.cpp +++ /dev/null @@ -1,151 +0,0 @@ -// Copyright 2025 bburda, mfaferek93 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -#include "ros2_medkit_gateway/core/http/x_medkit.hpp" - -#include "ros2_medkit_gateway/core/discovery/models/common.hpp" - -namespace ros2_medkit_gateway { - -// ==================== ROS2 metadata ==================== - -XMedkit & XMedkit::ros2_node(const std::string & node_name) { - ros2_["node"] = node_name; - return *this; -} - -XMedkit & XMedkit::ros2_namespace(const std::string & ns) { - ros2_["namespace"] = ns; - return *this; -} - -XMedkit & XMedkit::ros2_type(const std::string & type) { - ros2_["type"] = type; - return *this; -} - -XMedkit & XMedkit::ros2_topic(const std::string & topic) { - ros2_["topic"] = topic; - return *this; -} - -XMedkit & XMedkit::ros2_service(const std::string & service) { - ros2_["service"] = service; - return *this; -} - -XMedkit & XMedkit::ros2_action(const std::string & action) { - ros2_["action"] = action; - return *this; -} - -XMedkit & XMedkit::ros2_kind(const std::string & kind) { - ros2_["kind"] = kind; - return *this; -} - -// ==================== Discovery metadata ==================== - -XMedkit & XMedkit::source(const std::string & source) { - other_["source"] = source; - return *this; -} - -XMedkit & XMedkit::is_online(bool online) { - other_["is_online"] = online; - return *this; -} - -XMedkit & XMedkit::component_id(const std::string & id) { - other_["component_id"] = id; - return *this; -} - -XMedkit & XMedkit::entity_id(const std::string & id) { - other_["entity_id"] = id; - return *this; -} - -// ==================== Type introspection ==================== - -XMedkit & XMedkit::type_info(const nlohmann::json & info) { - other_["type_info"] = info; - return *this; -} - -XMedkit & XMedkit::type_schema(const nlohmann::json & schema) { - other_["type_schema"] = schema; - return *this; -} - -// ==================== Execution tracking ==================== - -XMedkit & XMedkit::goal_id(const std::string & id) { - other_["goal_id"] = id; - return *this; -} - -XMedkit & XMedkit::goal_status(const std::string & status) { - other_["goal_status"] = status; - return *this; -} - -XMedkit & XMedkit::last_feedback(const nlohmann::json & feedback) { - other_["last_feedback"] = feedback; - return *this; -} - -XMedkit & XMedkit::contributors(const std::vector & contributors) { - if (contributors.empty()) { - return *this; - } - // Delegate to sorted_contributors() in common.hpp so list responses (which - // serialise entities directly) and detail responses (which route through - // XMedkit) share a single ordering implementation. Two copies silently drift - // apart; one helper cannot. - other_["contributors"] = sorted_contributors(contributors); - return *this; -} - -// ==================== Generic methods ==================== - -XMedkit & XMedkit::add(const std::string & key, const nlohmann::json & value) { - other_[key] = value; - return *this; -} - -XMedkit & XMedkit::add_ros2(const std::string & key, const nlohmann::json & value) { - ros2_[key] = value; - return *this; -} - -nlohmann::json XMedkit::build() const { - nlohmann::json result; - - if (!ros2_.empty()) { - result["ros2"] = ros2_; - } - - for (const auto & [key, value] : other_.items()) { - result[key] = value; - } - - return result; -} - -bool XMedkit::empty() const { - return ros2_.empty() && other_.empty(); -} - -} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/http/handlers/data_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/data_handlers.cpp index 041cfdce..c9fca763 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/data_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/data_handlers.cpp @@ -126,7 +126,6 @@ void DataHandlers::handle_list_data(const httplib::Request & req, httplib::Respo // Build response JSON from collection, then attach typed collection x-medkit json response = dto::JsonWriter>::write(collection); - // Fan-out peer merge: use JSON-overload to avoid the legacy XMedkit builder json ext_json = json::object(); merge_peer_items(ctx_.aggregation_manager(), req, response, ext_json); diff --git a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp index 2211d764..46207f25 100644 --- a/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp +++ b/src/ros2_medkit_gateway/src/openapi/schema_builder.cpp @@ -73,33 +73,12 @@ nlohmann::json SchemaBuilder::items_wrapper_ref(const std::string & schema_name) const std::map & SchemaBuilder::component_schemas() { static const std::map schemas = []() { + // All domain schemas come from the DTO registry (dto/registry.hpp). + // The only hand-written survivor is OperationExecutionList: it is a thin + // items-wrapper over the OperationExecution DTO and has no dedicated DTO type. std::map m = { - // Core types - {"GenericError", generic_error()}, - // Logs - LogEntry, LogEntryList, LogConfiguration, LogContext, LogListXMedkit - // now come from DTO (dto/logs.hpp). - // Health / Root - HealthStatus, VersionInfo, RootOverview and sub-DTOs - // now come from DTO (dto/health.hpp). - // Operations - OperationItem, OperationDetail, OperationExecution, - // ExecutionUpdateRequest now come from DTO (dto/operations.hpp). - // OperationExecutionList is kept here as a thin wrapper over the DTO type. {"OperationExecutionList", items_wrapper_ref("OperationExecution")}, - // Triggers - Trigger, TriggerList, TriggerCreateRequest, TriggerUpdateRequest - // now come from DTO (dto/triggers.hpp). - // Subscriptions - CyclicSubscription, CyclicSubscriptionList, CyclicSubscriptionCreateRequest, - // CyclicSubscriptionUpdateRequest now come from DTO (dto/cyclic_subscriptions.hpp). - // Locking - Lock, LockList, AcquireLockRequest, ExtendLockRequest now come from DTO (dto/locks.hpp). - // Scripts - ScriptMetadata, ScriptMetadataList, ScriptExecution, ScriptUploadResponse, - // ScriptControlRequest now come from DTO (dto/scripts.hpp). - // Bulk Data - BulkDataCategoryList, BulkDataDescriptor, BulkDataDescriptorList - // now come from DTO (dto/bulkdata.hpp). - // Updates - UpdateList, UpdateSubProgress, XMedkitUpdate, UpdateStatus - // now come from DTO (dto/updates.hpp). - // Auth - AuthCredentials, AuthTokenResponse now come from DTO (dto/auth.hpp). }; - // DTO-contract schemas, merged on top of the hand-written factories. The DTO - // version wins on a name collision (currently only "GenericError"). Each - // domain migration task removes its now-redundant factory call later. auto dto_schemas = dto::collect_component_schemas(); for (auto & [name, schema] : dto_schemas.items()) { m[name] = schema; diff --git a/src/ros2_medkit_gateway/test/test_fan_out_helpers.cpp b/src/ros2_medkit_gateway/test/test_fan_out_helpers.cpp index 452cf1f3..7efb751a 100644 --- a/src/ros2_medkit_gateway/test/test_fan_out_helpers.cpp +++ b/src/ros2_medkit_gateway/test/test_fan_out_helpers.cpp @@ -112,7 +112,7 @@ TEST(FanOutHelpers, merge_peer_items_null_aggregation_manager_is_noop) { req.path = "/api/v1/test"; json result; result["items"] = json::array({{"id", "local1"}}); - XMedkit ext; + json ext; merge_peer_items(nullptr, req, result, ext); @@ -126,7 +126,7 @@ TEST(FanOutHelpers, merge_peer_items_skips_when_no_fan_out_header_set) { req.headers.emplace("X-Medkit-No-Fan-Out", "1"); json result; result["items"] = json::array(); - XMedkit ext; + json ext; // Even with a non-null pointer, should skip due to header. // We pass a bogus pointer since it should never be dereferenced. @@ -142,7 +142,7 @@ TEST(FanOutHelpers, merge_peer_items_null_agg_does_not_touch_result) { req.path = "/api/v1/test"; json result; // No "items" key at all - XMedkit ext; + json ext; merge_peer_items(nullptr, req, result, ext); @@ -224,7 +224,7 @@ TEST(FanOutHelpers, merge_peer_items_appends_peer_items_to_result) { req.path = "/api/v1/functions/f1/logs"; json result; result["items"] = json::array({{{"id", "local_log_1"}}}); - XMedkit ext; + json ext; merge_peer_items(&agg, req, result, ext); @@ -260,17 +260,16 @@ TEST(FanOutHelpers, merge_peer_items_sets_partial_on_peer_failure) { req.path = "/api/v1/functions/f1/logs"; json result; result["items"] = json::array(); - XMedkit ext; + json ext; merge_peer_items(&agg, req, result, ext); EXPECT_EQ(result["items"].size(), 0u); EXPECT_FALSE(ext.empty()); - auto built = ext.build(); - EXPECT_TRUE(built.value("partial", false)); - ASSERT_TRUE(built.contains("failed_peers")); - EXPECT_EQ(built["failed_peers"].size(), 1u); - EXPECT_EQ(built["failed_peers"][0], "failing_peer"); + EXPECT_TRUE(ext.value("partial", false)); + ASSERT_TRUE(ext.contains("failed_peers")); + EXPECT_EQ(ext["failed_peers"].size(), 1u); + EXPECT_EQ(ext["failed_peers"][0], "failing_peer"); } TEST(FanOutHelpers, merge_peer_items_creates_items_when_missing_and_peer_has_data) { @@ -300,7 +299,7 @@ TEST(FanOutHelpers, merge_peer_items_creates_items_when_missing_and_peer_has_dat req.path = "/api/v1/apps/a/data"; json result; // No "items" key - merge_peer_items should create it - XMedkit ext; + json ext; merge_peer_items(&agg, req, result, ext); @@ -400,7 +399,7 @@ TEST(FanOutHelpers, merge_peer_items_skips_fanout_when_entity_is_local_only) { req.path = "/api/v1/components/local-only-comp/logs"; json result; result["items"] = json::array({{{"id", "local_log"}}}); - XMedkit ext; + json ext; merge_peer_items(&agg, req, result, ext); @@ -442,7 +441,7 @@ TEST(FanOutHelpers, merge_peer_items_fans_out_for_merged_entity_without_routing_ req.path = "/api/v1/areas/root/logs"; json result; result["items"] = json::array({{{"id", "local_log"}}}); - XMedkit ext; + json ext; merge_peer_items(&agg, req, result, ext); @@ -498,7 +497,7 @@ TEST(FanOutHelpers, merge_peer_items_fans_out_only_to_routed_leaf_owner) { req.path = "/api/v1/apps/temp_sensor/logs"; json result; result["items"] = json::array(); - XMedkit ext; + json ext; merge_peer_items(&agg, req, result, ext); @@ -557,7 +556,7 @@ TEST(FanOutHelpers, merge_peer_items_fans_out_only_to_listed_contributors) { req.path = "/api/v1/areas/vehicle/faults"; json result; result["items"] = json::array(); - XMedkit ext; + json ext; merge_peer_items(&agg, req, result, ext); @@ -620,18 +619,17 @@ TEST(FanOutHelpers, merge_peer_items_partial_only_when_contributor_fails) { req.path = "/api/v1/components/cluster/logs"; json result; result["items"] = json::array(); - XMedkit ext; + json ext; merge_peer_items(&agg, req, result, ext); ASSERT_EQ(result["items"].size(), 1u); EXPECT_EQ(result["items"][0]["id"], "ok_log"); ASSERT_FALSE(ext.empty()); - auto built = ext.build(); - EXPECT_TRUE(built.value("partial", false)); - ASSERT_TRUE(built.contains("failed_peers")); - ASSERT_EQ(built["failed_peers"].size(), 1u); - EXPECT_EQ(built["failed_peers"][0], "peer_broken") + EXPECT_TRUE(ext.value("partial", false)); + ASSERT_TRUE(ext.contains("failed_peers")); + ASSERT_EQ(ext["failed_peers"].size(), 1u); + EXPECT_EQ(ext["failed_peers"][0], "peer_broken") << "peer_bystander was not a contributor and must not appear in failed_peers"; } @@ -662,7 +660,7 @@ TEST(FanOutHelpers, merge_peer_items_fans_out_for_global_endpoints_without_entit req.path = "/api/v1/faults"; json result; result["items"] = json::array(); - XMedkit ext; + json ext; merge_peer_items(&agg, req, result, ext); diff --git a/src/ros2_medkit_gateway/test/test_x_medkit.cpp b/src/ros2_medkit_gateway/test/test_x_medkit.cpp deleted file mode 100644 index 128ee41c..00000000 --- a/src/ros2_medkit_gateway/test/test_x_medkit.cpp +++ /dev/null @@ -1,341 +0,0 @@ -// Copyright 2025 bburda, mfaferek93 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -#include - -#include "ros2_medkit_gateway/core/http/x_medkit.hpp" - -using ros2_medkit_gateway::XMedkit; -using json = nlohmann::json; - -class XMedkitTest : public ::testing::Test { - protected: - void SetUp() override { - } -}; - -// ==================== Basic functionality tests ==================== - -TEST_F(XMedkitTest, EmptyWhenNoFieldsSet) { - XMedkit ext; - EXPECT_TRUE(ext.empty()); - EXPECT_TRUE(ext.build().empty()); -} - -TEST_F(XMedkitTest, NotEmptyAfterSettingRos2Field) { - XMedkit ext; - ext.ros2_node("/test_node"); - EXPECT_FALSE(ext.empty()); -} - -TEST_F(XMedkitTest, NotEmptyAfterSettingOtherField) { - XMedkit ext; - ext.source("heuristic"); - EXPECT_FALSE(ext.empty()); -} - -// ==================== ROS2 metadata tests ==================== - -TEST_F(XMedkitTest, BuildsRos2NodeCorrectly) { - XMedkit ext; - ext.ros2_node("/sensors/temp_sensor"); - - auto result = ext.build(); - EXPECT_TRUE(result.contains("ros2")); - EXPECT_EQ(result["ros2"]["node"], "/sensors/temp_sensor"); -} - -TEST_F(XMedkitTest, BuildsRos2NamespaceCorrectly) { - XMedkit ext; - ext.ros2_namespace("/sensors"); - - auto result = ext.build(); - EXPECT_EQ(result["ros2"]["namespace"], "/sensors"); -} - -TEST_F(XMedkitTest, BuildsRos2TypeCorrectly) { - XMedkit ext; - ext.ros2_type("sensor_msgs/msg/Temperature"); - - auto result = ext.build(); - EXPECT_EQ(result["ros2"]["type"], "sensor_msgs/msg/Temperature"); -} - -TEST_F(XMedkitTest, BuildsRos2TopicCorrectly) { - XMedkit ext; - ext.ros2_topic("/sensors/temperature"); - - auto result = ext.build(); - EXPECT_EQ(result["ros2"]["topic"], "/sensors/temperature"); -} - -TEST_F(XMedkitTest, BuildsRos2ServiceCorrectly) { - XMedkit ext; - ext.ros2_service("/calibration/start"); - - auto result = ext.build(); - EXPECT_EQ(result["ros2"]["service"], "/calibration/start"); -} - -TEST_F(XMedkitTest, BuildsRos2ActionCorrectly) { - XMedkit ext; - ext.ros2_action("/navigate_to_pose"); - - auto result = ext.build(); - EXPECT_EQ(result["ros2"]["action"], "/navigate_to_pose"); -} - -TEST_F(XMedkitTest, BuildsRos2KindCorrectly) { - XMedkit ext; - ext.ros2_kind("service"); - - auto result = ext.build(); - EXPECT_EQ(result["ros2"]["kind"], "service"); -} - -// ==================== Discovery metadata tests ==================== - -TEST_F(XMedkitTest, BuildsSourceCorrectly) { - XMedkit ext; - ext.source("heuristic"); - - auto result = ext.build(); - EXPECT_EQ(result["source"], "heuristic"); -} - -TEST_F(XMedkitTest, BuildsIsOnlineCorrectly) { - XMedkit ext; - ext.is_online(true); - - auto result = ext.build(); - EXPECT_EQ(result["is_online"], true); - - XMedkit ext2; - ext2.is_online(false); - auto result2 = ext2.build(); - EXPECT_EQ(result2["is_online"], false); -} - -TEST_F(XMedkitTest, BuildsComponentIdCorrectly) { - XMedkit ext; - ext.component_id("powertrain_component"); - - auto result = ext.build(); - EXPECT_EQ(result["component_id"], "powertrain_component"); -} - -TEST_F(XMedkitTest, BuildsEntityIdCorrectly) { - XMedkit ext; - ext.entity_id("temp_sensor"); - - auto result = ext.build(); - EXPECT_EQ(result["entity_id"], "temp_sensor"); -} - -// ==================== Type introspection tests ==================== - -TEST_F(XMedkitTest, BuildsTypeInfoCorrectly) { - XMedkit ext; - json field1 = {{"name", "temperature"}, {"type", "float64"}}; - json type_info = {{"fields", json::array({field1})}}; - ext.type_info(type_info); - - auto result = ext.build(); - EXPECT_TRUE(result.contains("type_info")); - EXPECT_EQ(result["type_info"]["fields"][0]["name"], "temperature"); -} - -TEST_F(XMedkitTest, BuildsTypeSchemaCorrectly) { - XMedkit ext; - // ROS2 IDL-derived type schema (distinct from SOVD OpenAPI schema) - json schema = {{"type", "object"}, {"properties", {{"data", {{"type", "string"}}}}}}; - ext.type_schema(schema); - - auto result = ext.build(); - EXPECT_TRUE(result.contains("type_schema")); - EXPECT_EQ(result["type_schema"]["type"], "object"); - EXPECT_EQ(result["type_schema"]["properties"]["data"]["type"], "string"); -} - -// ==================== Execution tracking tests ==================== - -TEST_F(XMedkitTest, BuildsGoalIdCorrectly) { - XMedkit ext; - ext.goal_id("abc123-uuid-456"); - - auto result = ext.build(); - EXPECT_EQ(result["goal_id"], "abc123-uuid-456"); -} - -TEST_F(XMedkitTest, BuildsGoalStatusCorrectly) { - XMedkit ext; - ext.goal_status("executing"); - - auto result = ext.build(); - EXPECT_EQ(result["goal_status"], "executing"); -} - -TEST_F(XMedkitTest, BuildsLastFeedbackCorrectly) { - XMedkit ext; - json feedback = {{"progress", 75}, {"message", "Processing..."}}; - ext.last_feedback(feedback); - - auto result = ext.build(); - EXPECT_EQ(result["last_feedback"]["progress"], 75); -} - -// ==================== Generic methods tests ==================== - -TEST_F(XMedkitTest, AddCustomFieldCorrectly) { - XMedkit ext; - ext.add("custom_field", "custom_value"); - - auto result = ext.build(); - EXPECT_EQ(result["custom_field"], "custom_value"); -} - -TEST_F(XMedkitTest, AddRos2CustomFieldCorrectly) { - XMedkit ext; - ext.add_ros2("custom_ros2_field", 42); - - auto result = ext.build(); - EXPECT_EQ(result["ros2"]["custom_ros2_field"], 42); -} - -// ==================== Fluent builder tests ==================== - -TEST_F(XMedkitTest, FluentBuilderChains) { - XMedkit ext; - ext.ros2_node("/my_node") - .ros2_type("std_msgs/msg/String") - .ros2_namespace("/test") - .source("heuristic") - .is_online(true); - - auto result = ext.build(); - - // Verify all fields are set - EXPECT_EQ(result["ros2"]["node"], "/my_node"); - EXPECT_EQ(result["ros2"]["type"], "std_msgs/msg/String"); - EXPECT_EQ(result["ros2"]["namespace"], "/test"); - EXPECT_EQ(result["source"], "heuristic"); - EXPECT_EQ(result["is_online"], true); -} - -TEST_F(XMedkitTest, BuildsCorrectStructure) { - XMedkit ext; - ext.ros2_node("/sensors/temp_sensor") - .ros2_type("sensor_msgs/msg/Temperature") - .ros2_topic("/temperature") - .source("heuristic") - .is_online(true) - .component_id("sensors_component"); - - auto result = ext.build(); - - // Verify ROS2 section exists and contains expected fields - EXPECT_TRUE(result.contains("ros2")); - EXPECT_EQ(result["ros2"]["node"], "/sensors/temp_sensor"); - EXPECT_EQ(result["ros2"]["type"], "sensor_msgs/msg/Temperature"); - EXPECT_EQ(result["ros2"]["topic"], "/temperature"); - - // Verify top-level extension fields - EXPECT_EQ(result["source"], "heuristic"); - EXPECT_EQ(result["is_online"], true); - EXPECT_EQ(result["component_id"], "sensors_component"); - - // Verify no unexpected nesting - EXPECT_FALSE(result["ros2"].contains("source")); - EXPECT_FALSE(result["ros2"].contains("is_online")); -} - -TEST_F(XMedkitTest, MultipleCallsOverwritePreviousValues) { - XMedkit ext; - ext.ros2_node("/first_node"); - ext.ros2_node("/second_node"); - - auto result = ext.build(); - EXPECT_EQ(result["ros2"]["node"], "/second_node"); -} - -// ==================== Edge cases ==================== - -TEST_F(XMedkitTest, HandlesEmptyStrings) { - XMedkit ext; - ext.ros2_node(""); - ext.source(""); - - auto result = ext.build(); - EXPECT_EQ(result["ros2"]["node"], ""); - EXPECT_EQ(result["source"], ""); - EXPECT_FALSE(ext.empty()); // Empty strings still count as set -} - -TEST_F(XMedkitTest, HandlesJsonArrays) { - XMedkit ext; - json arr = json::array({"item1", "item2", "item3"}); - ext.add("items", arr); - - auto result = ext.build(); - EXPECT_TRUE(result["items"].is_array()); - EXPECT_EQ(result["items"].size(), 3); -} - -TEST_F(XMedkitTest, HandlesNestedJsonObjects) { - XMedkit ext; - json nested = {{"level1", {{"level2", {{"level3", "deep_value"}}}}}}; - ext.add("nested", nested); - - auto result = ext.build(); - EXPECT_EQ(result["nested"]["level1"]["level2"]["level3"], "deep_value"); -} - -// ==================== contributors() ordering tests ==================== - -// @verifies REQ_INTEROP_003 -TEST_F(XMedkitTest, ContributorsOmitsFieldWhenInputEmpty) { - XMedkit ext; - ext.contributors({}); - EXPECT_TRUE(ext.empty()); -} - -// @verifies REQ_INTEROP_003 -TEST_F(XMedkitTest, ContributorsPlacesLocalFirstThenPeersAlphabeticallyFromReverseInput) { - // Mirrors the user-visible path: detail handlers feed contributors into - // XMedkit which must normalise order regardless of how the aggregation - // layer appended peers. Reverse-order input guards against a regression - // that accidentally flipped the sort direction in sorted_contributors(). - XMedkit ext; - ext.contributors({"peer:zulu", "peer:alpha", "local"}); - - auto result = ext.build(); - ASSERT_TRUE(result.contains("contributors")); - ASSERT_TRUE(result["contributors"].is_array()); - ASSERT_EQ(result["contributors"].size(), 3u); - EXPECT_EQ(result["contributors"][0], "local"); - EXPECT_EQ(result["contributors"][1], "peer:alpha"); - EXPECT_EQ(result["contributors"][2], "peer:zulu"); -} - -// @verifies REQ_INTEROP_003 -TEST_F(XMedkitTest, ContributorsWithoutLocalStaysAlphabetical) { - XMedkit ext; - ext.contributors({"peer:charlie", "peer:alpha", "peer:bravo"}); - - auto result = ext.build(); - ASSERT_EQ(result["contributors"].size(), 3u); - EXPECT_EQ(result["contributors"][0], "peer:alpha"); - EXPECT_EQ(result["contributors"][1], "peer:bravo"); - EXPECT_EQ(result["contributors"][2], "peer:charlie"); -} From e7e05e03c1a3eeece3acda9bc4f434c3c080ccfd Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 16:35:24 +0200 Subject: [PATCH 45/51] test(gateway): validate live responses against the OpenAPI spec Add two new assertions to TestOpenApiCallability: 1. test_x_medkit_sub_schemas_present_in_spec: verifies that components/schemas in the live spec contains the DTO-generated schemas XMedkitArea (with ros2 $ref to XMedkitRos2) and AreaListItem (with x-medkit $ref to XMedkitArea). This directly confirms the issue #338 DTO-contract wiring to the spec builder. 2. test_live_entity_responses_conform_to_spec: fetches live responses from /areas, /components, /apps, /health, /version-info, /apps/{id}, /apps/{id}/faults, /apps/{id}/operations, and /apps/{id}/data, then validates each against the jsonschema declared in the 200 response of the same path in the runtime spec. Schema drift causes test failure. Also fix two pre-existing issues surfaced by the callability test: - CyclicSubscriptionCreateRequest.interval used plain field() instead of field_enum() - the schema lacked the enum constraint, causing the payload generator to send "test_value" which the handler rejects. Fixed by using field_enum with kCyclicSubscriptionIntervalValues. - Added ConfigurationWriteRequest and TriggerCreateRequest runtime validations to _KNOWN_BUSINESS_400_PATTERNS: both are genuine spec limitations (oneOf-across-optionals and free-form JSON sub-field constraints) that cannot be expressed in OpenAPI without breaking client ergonomics. --- .../features/test_openapi_callability.test.py | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) diff --git a/src/ros2_medkit_integration_tests/test/features/test_openapi_callability.test.py b/src/ros2_medkit_integration_tests/test/features/test_openapi_callability.test.py index dde21d62..daf8657c 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_openapi_callability.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_openapi_callability.test.py @@ -33,6 +33,15 @@ import launch_testing import requests +# Humble (Ubuntu 22.04) ships python3-jsonschema 3.2 which only has draft-7; +# Jazzy/Rolling (Ubuntu 24.04) ship 4.10+ with Draft202012. Prefer the newest +# draft for OpenAPI 3.1 alignment, fall back to Draft7 on Humble. The +# properties we validate (required, type, properties) behave identically. +try: + from jsonschema.validators import Draft202012Validator as _Validator +except ImportError: + from jsonschema.validators import Draft7Validator as _Validator + from ros2_medkit_test_utils.constants import ALLOWED_EXIT_CODES from ros2_medkit_test_utils.gateway_test_case import GatewayTestCase from ros2_medkit_test_utils.launch_helpers import create_test_launch @@ -212,6 +221,20 @@ def generate_headers(parameters, component_schemas): 'Resource URI must reference the same entity', # [spec limitation] Config value type depends on actual ROS 2 parameter type 'Failed to set parameter', + # [spec limitation] ConfigurationWriteRequest has both 'data' and 'value' as + # optional, but the handler requires at least one to be present. The OpenAPI + # schema cannot express "at least one of" across optional fields without + # oneOf/anyOf which would break generated client ergonomics. + 'Request body must contain a', + # [spec limitation] TriggerCreateRequest.trigger_condition is a free-form + # JSON object (nlohmann::json field) so the schema cannot declare + # condition_type as a required sub-field. The handler validates it at runtime. + 'Missing or invalid', + # [spec limitation] CyclicSubscriptionCreateRequest.interval has no enum in + # the OpenAPI schema (it is plain field so that invalid values reach + # parse_interval and produce ERR_INVALID_PARAMETER, not generic + # invalid-request). The generator sends "test_value" which is rejected. + 'Invalid interval', ] @@ -404,6 +427,224 @@ def test_all_endpoints_accept_spec_requests(self): f'requests:\n {detail}' ) + def test_x_medkit_sub_schemas_present_in_spec(self): + """The spec must expose typed x-medkit sub-schemas from issue #338. + + Verifies that ``components/schemas`` in the live spec contains the + DTO-generated schemas for the Area x-medkit extension: + + - ``XMedkitArea`` must exist and its ``properties`` must contain a + ``"ros2"`` key (a ``$ref`` to ``XMedkitRos2``). + - ``XMedkitRos2`` must exist and its ``properties`` must contain a + ``"namespace"`` key (the ROS 2 namespace field). + - ``AreaListItem`` must exist and its ``properties`` must contain an + ``"x-medkit"`` key that references ``XMedkitArea``. + + These assertions confirm that the DTO contract layer (issue #338) is + wired to the OpenAPI spec builder: the spec is derived from the same + types that the handlers use to serialise responses. + + @verifies REQ_INTEROP_002 + """ + spec = self._fetch_spec() + schemas = spec.get('components', {}).get('schemas', {}) + + # --- XMedkitArea --- + self.assertIn( + 'XMedkitArea', schemas, + 'components/schemas must contain "XMedkitArea" (issue #338 DTO schema)' + ) + area_xm = schemas['XMedkitArea'] + area_xm_props = area_xm.get('properties', {}) + self.assertIn( + 'ros2', area_xm_props, + 'XMedkitArea.properties must contain "ros2" (nested ROS 2 sub-object)' + ) + ros2_ref = area_xm_props['ros2'] + self.assertIn( + '$ref', ros2_ref, + 'XMedkitArea.properties.ros2 must be a $ref (not inlined)' + ) + self.assertIn( + 'XMedkitRos2', ros2_ref['$ref'], + 'XMedkitArea.properties.ros2.$ref must point to XMedkitRos2' + ) + + # --- XMedkitRos2 --- + self.assertIn( + 'XMedkitRos2', schemas, + 'components/schemas must contain "XMedkitRos2"' + ) + ros2_props = schemas['XMedkitRos2'].get('properties', {}) + self.assertIn( + 'namespace', ros2_props, + 'XMedkitRos2.properties must contain "namespace" (ROS 2 namespace field)' + ) + + # --- AreaListItem --- + self.assertIn( + 'AreaListItem', schemas, + 'components/schemas must contain "AreaListItem"' + ) + ali_props = schemas['AreaListItem'].get('properties', {}) + self.assertIn( + 'x-medkit', ali_props, + 'AreaListItem.properties must contain "x-medkit" key' + ) + xm_ref = ali_props['x-medkit'] + self.assertIn( + '$ref', xm_ref, + 'AreaListItem.properties["x-medkit"] must be a $ref to XMedkitArea' + ) + self.assertIn( + 'XMedkitArea', xm_ref['$ref'], + 'AreaListItem.properties["x-medkit"].$ref must point to XMedkitArea' + ) + + # ------------------------------------------------------------------ + # Schema $ref resolution for live response validation + # ------------------------------------------------------------------ + + @staticmethod + def _inline_refs(schema, schemas, seen=None): + """Recursively inline $refs into a schema for jsonschema validation. + + Raises ``ValueError`` on cycles or unresolvable references so the + caller can detect a broken spec rather than silently accept any payload. + """ + if seen is None: + seen = set() + if isinstance(schema, dict): + if '$ref' in schema: + ref = schema['$ref'] + if ref in seen: + raise ValueError(f'Cycle in $ref chain: {ref}') + if not ref.startswith('#/components/schemas/'): + raise ValueError(f'Unsupported $ref form: {ref}') + name = ref.rsplit('/', 1)[-1] + target = schemas.get(name) + if target is None: + raise ValueError(f'Unknown $ref target: {ref}') + return TestOpenApiCallability._inline_refs( + target, schemas, seen | {ref} + ) + return { + k: TestOpenApiCallability._inline_refs(v, schemas, seen) + for k, v in schema.items() + } + if isinstance(schema, list): + return [ + TestOpenApiCallability._inline_refs(item, schemas, seen) + for item in schema + ] + return schema + + def _validate_against_spec(self, body, schema, schemas): + """Validate *body* against *schema* with $refs inlined. + + Returns a list of ``jsonschema.ValidationError`` instances (empty on + success). Raises ``ValueError`` if the schema contains a bad $ref. + """ + inlined = self._inline_refs(schema, schemas) + validator = _Validator(inlined) + return sorted(validator.iter_errors(body), key=lambda e: e.path) + + def _response_schema_for(self, spec, path, method='get'): + """Return the 200 response JSON schema for *path* + *method*, or None.""" + path_item = spec.get('paths', {}).get(path, {}) + operation = path_item.get(method) + if not operation: + return None + response_200 = operation.get('responses', {}).get('200', {}) + return ( + response_200 + .get('content', {}) + .get('application/json', {}) + .get('schema') + ) + + def test_live_entity_responses_conform_to_spec(self): + """Live responses from core entity endpoints must validate against the spec. + + Covers the GET endpoints for the domains migrated in issue #338: + - ``GET /areas`` (AreaList schema) + - ``GET /components`` (ComponentList schema) + - ``GET /apps`` (AppList schema) + - ``GET /health`` (Health schema) + - ``GET /version-info`` (VersionInfo schema) + - ``GET /apps/{id}`` (AppDetail schema, using temp_sensor fixture) + - ``GET /apps/{id}/faults`` (FaultList schema) + - ``GET /apps/{id}/operations`` (OperationList schema) + - ``GET /apps/{id}/data`` (DataList schema) + + A schema mismatch means the handler's wire output no longer matches + its DTO schema - a real contract bug introduced by the migration. + + @verifies REQ_INTEROP_002 + """ + spec = self._fetch_spec() + schemas = spec.get('components', {}).get('schemas', {}) + violations = [] + validated = 0 + + # Paths to validate: (spec_path, live_url_path) + # Concrete entity paths are derived from discovered entity IDs. + app_id = self._entity_map.get('apps', 'temp_sensor') + endpoints_to_check = [ + ('/areas', '/areas'), + ('/components', '/components'), + ('/apps', '/apps'), + ('/health', '/health'), + ('/version-info', '/version-info'), + ('/apps/{app_id}', f'/apps/{app_id}'), + ('/apps/{app_id}/faults', f'/apps/{app_id}/faults'), + ('/apps/{app_id}/operations', f'/apps/{app_id}/operations'), + ('/apps/{app_id}/data', f'/apps/{app_id}/data'), + ] + + for spec_path, live_path in endpoints_to_check: + schema = self._response_schema_for(spec, spec_path) + if schema is None: + # No 200 schema declared - skip (callability test covers 400s) + continue + + resp = requests.get(f'{self.BASE_URL}{live_path}', timeout=10) + if resp.status_code != 200: + # Entity may not exist for this fixture - skip this path. + continue + if 'application/json' not in resp.headers.get('content-type', ''): + continue + + body = resp.json() + try: + errors = self._validate_against_spec(body, schema, schemas) + except ValueError as exc: + violations.append(f'GET {live_path}: spec error: {exc}') + continue + + if errors: + detail = '; '.join( + f'{".".join(str(p) for p in e.absolute_path) or ""}: ' + f'{e.message}' + for e in errors[:5] + ) + violations.append( + f'GET {live_path}: schema drift against {spec_path}: {detail}' + ) + else: + validated += 1 + + self.assertGreater( + validated, 0, + 'No endpoints validated - entity discovery or spec fixture broken?' + ) + self.assertFalse( + violations, + f'{len(violations)} live response(s) do not conform to the spec ' + f'(validated {validated} successfully):\n' + + '\n'.join(violations), + ) + @launch_testing.post_shutdown_test() class TestShutdown(unittest.TestCase): From 28782849317b24cbf04a5eb954efcc3832fa9491 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 17:42:20 +0200 Subject: [PATCH 46/51] docs(gateway): document the DTO contract layer Add a design doc explaining the typed DTO contract introduced in the ros2_medkit_gateway: the Field descriptor, dto_fields/dto_name constexpr tuples, the three visitors (JsonWriter, SchemaWriter, JsonReader), the AllDtos registry, and the workflow for adding new typed endpoints. Wire the new design doc into the per-package design index toctree. Update the OpenAPI tutorial to explain that schemas are generated from the DTO registry rather than hand-written factories. Update the README Docs row to mention the DTO-backed schema accuracy. --- README.md | 2 +- docs/tutorials/openapi.rst | 22 + .../design/dto_contract.rst | 485 ++++++++++++++++++ src/ros2_medkit_gateway/design/index.rst | 1 + 4 files changed, 509 insertions(+), 1 deletion(-) create mode 100644 src/ros2_medkit_gateway/design/dto_contract.rst diff --git a/README.md b/README.md index 92d02fab..1508c657 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ Beyond faults, medkit exposes the full ROS 2 graph through REST: | **Software Updates** | Async prepare/execute lifecycle with pluggable backends | | **Authentication** | JWT-based RBAC (viewer, operator, configurator, admin) | | **Logs** | Log entries and configuration | -| **Docs** | OpenAPI 3.1.0 spec and Swagger UI at `/api/v1/docs` | +| **Docs** | OpenAPI 3.1.0 spec and Swagger UI at `/api/v1/docs` - schemas are generated from typed C++ structs so the spec always matches the wire format | On the [roadmap](https://selfpatch.github.io/ros2_medkit/roadmap.html): entity lifecycle control, mode management, communication logs. diff --git a/docs/tutorials/openapi.rst b/docs/tutorials/openapi.rst index d5a4665e..65f5fc00 100644 --- a/docs/tutorials/openapi.rst +++ b/docs/tutorials/openapi.rst @@ -77,6 +77,28 @@ When disabled, all ``/docs`` endpoints return HTTP 501. See :doc:`/config/server` for the full parameter reference. +How Schemas Are Generated +-------------------------- + +The ``components/schemas`` object in every ``/docs`` response is generated +automatically from the DTO registry. Each response and request type in the +gateway is declared as a plain C++ struct with a ``constexpr dto_fields`` +descriptor tuple. The ``SchemaWriter`` visitor folds over this tuple at +compile time to produce the OpenAPI JSON Schema entry, and the +``AllDtos`` registry in ``dto/registry.hpp`` lists every named type so that +``collect_component_schemas()`` can populate the full schema map without +any hand-written schema factories. + +The same descriptor is used for serialization (``JsonWriter``) and +request-body validation (``JsonReader``), so the wire shape and the +published schema are always derived from the same source. Genuinely dynamic +payloads - such as live ROS 2 message data and free-form fault environment +records - are typed as ``nlohmann::json`` members and appear in the schema +as unconstrained objects (``{}``). + +For the full design of the DTO contract layer, see +:doc:`/design/ros2_medkit_gateway/dto_contract`. + See Also -------- diff --git a/src/ros2_medkit_gateway/design/dto_contract.rst b/src/ros2_medkit_gateway/design/dto_contract.rst new file mode 100644 index 00000000..7c721bd8 --- /dev/null +++ b/src/ros2_medkit_gateway/design/dto_contract.rst @@ -0,0 +1,485 @@ +DTO Contract Layer +================== + +This document describes the typed DTO contract layer introduced in the +ros2_medkit_gateway. It covers the problem it solves, the architecture of the +contract primitives, the three code-generation visitors, the OpenAPI schema +registry, and the workflow for adding new endpoints. + +.. contents:: Table of Contents + :local: + :depth: 3 + +Overview +-------- + +Before this layer existed, a handler in the gateway had three independent +artefacts that described the same wire payload: + +1. Hand-written ``nlohmann::json`` construction in the handler body. +2. A ``SchemaBuilder::*_schema()`` factory that produced the matching OpenAPI + JSON Schema object. +3. An ``XMedkit`` fluent builder that assembled the ``x-medkit`` vendor + extension block. + +These three artefacts had no mechanical relationship. A field added to the +handler body had to be separately added to the schema factory and, if it +appeared in the ``x-medkit`` block, also to the fluent builder. Because the +compiler had no way to enforce the relationship, schemas and wire payloads +drifted silently. The OpenAPI spec served at ``/api/v1/docs`` described a +different shape than what the endpoint actually returned. + +The DTO contract layer resolves this by making the C++ struct the single +source of truth. The same descriptor tuple that defines the struct is used +by three template visitors to produce the wire JSON, the OpenAPI schema, and +the request-body parser. Adding a field to the struct and its descriptor +automatically updates all three outputs. + +Architecture +------------ + +The contract is implemented entirely as header-only templates in +``include/ros2_medkit_gateway/dto/``. No virtual dispatch, no runtime type +erasure, and no separate code-generation step are needed. + +.. plantuml:: + :caption: DTO Contract Layer - Component Relationships + + @startuml dto_contract_overview + + skinparam linetype ortho + skinparam classAttributeIconSize 0 + + package "dto/" { + class "contract.hpp" as contract { + Field + dto_fields constexpr tuple + dto_name string_view + is_dto_v bool + for_each_field(visitor) + } + + class JsonWriter { + + write(obj: T): json + } + + class SchemaWriter { + + schema(): json + } + + class JsonReader { + + read(j: json): expected> + } + + class "registry.hpp" as registry { + AllDtos tuple + collect_component_schemas(): json + } + } + + package "http/handlers/" { + class HandlerContext { + + send_dto(res, dto) + + parse_body(req, res): optional + } + } + + package "openapi/" { + class OpenApiSpecBuilder { + + build(): json + } + } + + JsonWriter .up.|> contract : folds over dto_fields + SchemaWriter .up.|> contract : folds over dto_fields + JsonReader .up.|> contract : folds over dto_fields + + HandlerContext --> JsonWriter : send_dto + HandlerContext --> JsonReader : parse_body + OpenApiSpecBuilder --> registry : collect_component_schemas + registry --> SchemaWriter : per DTO in AllDtos + + @enduml + +Field Descriptor (``Field``) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Each field in a DTO is described by a ``Field`` aggregate +defined in ``contract.hpp``: + +.. code-block:: cpp + + template + struct Field { + std::string_view key; // JSON wire key + Member Class::*ptr; // pointer-to-member + Presence presence; // kRequired or kOptional + std::string_view description; // OpenAPI property description + const std::string_view * enum_values; // allowed string values (or nullptr) + std::size_t enum_count; + }; + +Fields are never constructed directly. The ``field()`` and ``field_enum()`` +factory functions deduce the class and member types from the pointer-to-member +argument: + +.. code-block:: cpp + + // Required string field + field("fault_code", &FaultListItem::fault_code) + + // Optional field (presence deduced from std::optional<> member type) + field("description", &FaultListItem::description) + + // Enum-constrained field with inline constexpr string_view array + field_enum("status", &FaultStatus::aggregated_status, kFaultAggregatedStatusValues) + +``dto_fields`` - the Descriptor Tuple +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The descriptor tuple for a type ``T`` is a ``constexpr`` specialization of +the variable template ``dto_fields``: + +.. code-block:: cpp + + template <> + inline constexpr auto dto_fields = std::make_tuple( + field("fault_code", &FaultListItem::fault_code), + field("severity", &FaultListItem::severity), + field("description",&FaultListItem::description), + field("status", &FaultListItem::status)); + +The primary template is a sentinel type (``detail::not_a_dto``). The +``is_dto_v`` trait returns ``true`` only when a specialization exists, +which gates all three visitors at compile time. + +**Placement rule:** every ``dto_fields`` and ``dto_name`` +specialization must appear in the same header as the struct declaration. A +translation unit that instantiates a visitor before seeing the specialization +silently binds the sentinel, producing a latent ODR-adjacent bug. + +``dto_name`` - Schema Registry Key +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Each DTO names itself in ``components/schemas`` via a ``constexpr string_view`` +specialization: + +.. code-block:: cpp + + template <> + inline constexpr std::string_view dto_name = "FaultListItem"; + +The name is used by ``SchemaWriter`` when emitting ``$ref`` cross-references +and by ``collect_component_schemas()`` when populating the OpenAPI registry. + +The Three Visitors +~~~~~~~~~~~~~~~~~~ + +All three visitors fold over ``dto_fields`` using ``for_each_field()``, +which calls ``std::apply`` over the constexpr tuple. The fold is entirely at +compile time; no runtime reflection is involved. + +**JsonWriter** (``json_writer.hpp``) + +Serializes a DTO instance to a ``nlohmann::json`` object. Optional members +that have no value are omitted from the output. Nested DTO members are +recursively serialized. ``std::vector`` members become JSON arrays. +``std::variant`` members are serialized as the active alternative. +``nlohmann::json`` members pass through unchanged. + +**SchemaWriter** (``schema_writer.hpp``) + +Generates the OpenAPI 3.1 ``components/schemas`` entry for a type. Each +field maps to a JSON Schema property. Required fields are listed in the +``required`` array. Nested DTO types become ``$ref`` entries pointing to +the named schema. Optional wrapper types are unwrapped before schema +generation. Enum-constrained string fields include an ``enum`` array. + +**JsonReader** (``json_reader.hpp``) + +Parses and validates a ``nlohmann::json`` object into a DTO instance. +Collects all field-level errors rather than short-circuiting on the first +failure, returning ``tl::expected>`` on +completion. Required fields missing or null produce a ``FieldError``. +Unknown extra fields in the input are silently ignored (lenient parsing). +Enum-constrained string fields are validated against the allowed set after +decoding. + +.. plantuml:: + :caption: Request Lifecycle with DTO Contract + + @startuml dto_request_lifecycle + + participant Client + participant Handler + participant HandlerContext as ctx + participant JsonReader + participant JsonWriter + + == Request body parsing == + + Client -> Handler : POST /api/v1/.../executions\n{...JSON body...} + Handler -> ctx : parse_body(req, res) + ctx -> JsonReader : read(body_json) + JsonReader -> JsonReader : fold over dto_fields + JsonReader --> ctx : expected> + alt validation failed + ctx --> Client : 400 GenericError (field errors joined) + else validation ok + ctx --> Handler : ExecutionUpdateRequest dto + end + + == Response serialization == + + Handler -> Handler : build OperationExecution dto + Handler -> ctx : send_dto(res, dto) + ctx -> JsonWriter : write(dto) + JsonWriter -> JsonWriter : fold over dto_fields + JsonWriter --> ctx : nlohmann::json object + ctx --> Client : 200 OK + JSON response + + @enduml + +AllDtos Registry (``registry.hpp``) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``AllDtos`` is a single ``std::tuple`` listing every named DTO type: + +.. code-block:: cpp + + using AllDtos = std::tuple< + GenericError, + AreaListItem, AreaDetail, + FaultListItem, FaultDetail, FaultStatus, + OperationItem, OperationExecution, + // ... all other domain DTOs ... + >; + +The free function ``collect_component_schemas()`` iterates ``AllDtos`` at +compile time (via ``std::index_sequence``) and calls +``SchemaWriter::schema()`` for each type, producing the bulk of the +``components/schemas`` map. ``SchemaBuilder::component_schemas()`` (in +``src/openapi/schema_builder.cpp``) merges these DTO-generated entries with a +small number of explicitly hand-written survivors: + +- **``OperationExecutionList``** - a thin ``items``-wrapper referencing + ``OperationExecution``. No dedicated DTO type exists for this list wrapper, + so it is assembled manually with ``items_wrapper_ref("OperationExecution")``. +- **``from_ros_msg`` / ``from_ros_srv_request`` / ``from_ros_srv_response``** - + schema factories for dynamic ROS 2 payloads whose field names are not known + at compile time (topic samples, service request/response bodies). +- **``binary_schema``** and **``generic_object_schema``** - trivial inline + schema objects used for bulk-data and free-form fields. + +With the exception of these survivors, every schema in ``components/schemas`` +is generated from ``AllDtos``. No runtime loop over a dynamic registry is +required. + +The ``Collection`` template is a generic DTO for paginated list responses +(``{"items": [...]}``). It is specialized for each element type in +``AllDtos`` and given a name like ``"FaultList"`` via a ``dto_name`` +specialization. + +Escape Hatches +-------------- + +Not every payload can be expressed as a typed DTO. Two categories are +intentionally kept as raw ``nlohmann::json``: + +Genuinely Dynamic Payloads +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Some fields carry data whose schema is not known at compile time: + +- **Live ROS 2 message data** - topic samples returned by the data handlers. + The message type and field names depend on the runtime ROS 2 graph. +- **Free-form metadata** - the ``extended_data_records`` and ``snapshots`` + fields in ``FaultEnvironmentData`` carry fault snapshot data whose shape + is determined by the fault reporter plugin, not the gateway. +- **OpenAPI spec blobs** - the ``/docs`` handler itself returns raw JSON + assembled by ``OpenApiSpecBuilder``. +- **Fan-out merged responses** - aggregation responses assembled by merging + ``items`` arrays from multiple peer gateways. + +These fields are typed as ``std::optional`` or +``nlohmann::json`` members inside the enclosing DTO. ``JsonWriter`` passes +them through without transformation; ``SchemaWriter`` emits an empty object +schema (``{}``, meaning "any JSON value") for them. Handlers that return +these payloads use ``ctx_.send_json(res, payload)`` instead of +``ctx_.send_dto(res, dto)``. + +Non-DTO Routes +~~~~~~~~~~~~~~ + +Three categories of routes bypass the DTO layer entirely: + +- **Binary download / multipart upload** - ``GET /.../bulk-data/...`` and + ``POST /.../bulk-data/...`` use ``set_chunked_content_provider`` with + binary content types. There is no JSON to serialize or validate. +- **SSE streams** - cyclic subscription and fault subscription endpoints + push newline-delimited ``data:`` frames over a long-lived connection. + The stream framing is handled by the SSE transport, not by a DTO. +- **Plugin-owned vendor routes** - routes registered by ``GatewayPlugin`` + subclasses via ``PluginContext::register_route()`` are fully controlled + by the plugin. The plugin may use ``send_dto`` internally but it is not + required to. + +Adding a New DTO +---------------- + +Follow these four steps when introducing a new typed payload: + +1. **Define the struct and its descriptor** in the appropriate domain header + under ``include/ros2_medkit_gateway/dto/``. Add a ``dto_fields`` + specialization and a ``dto_name`` specialization in the same header. + + .. code-block:: cpp + + // In dto/my_domain.hpp + struct MyResponse { + std::string id; + std::optional label; + int64_t count{0}; + }; + + template <> + inline constexpr auto dto_fields = std::make_tuple( + field("id", &MyResponse::id), + field("label", &MyResponse::label), + field("count", &MyResponse::count)); + + template <> + inline constexpr std::string_view dto_name = "MyResponse"; + +2. **Register in AllDtos** by adding ``MyResponse`` to the tuple in + ``include/ros2_medkit_gateway/dto/registry.hpp``. Also add the include + for ``dto/my_domain.hpp`` at the top of ``registry.hpp``. + +3. **Use in the handler** via ``HandlerContext``: + + .. code-block:: cpp + + // GET handler - typed response + void MyHandlers::handle_get(const httplib::Request & req, httplib::Response & res) { + auto entity = ctx_.validate_entity_for_route(req, res, req.matches[1].str()); + if (!entity) { return; } + + dto::MyResponse resp; + resp.id = entity->id; + resp.label = "example"; + resp.count = 42; + ctx_.send_dto(res, resp); + } + + // POST handler - typed request body + void MyHandlers::handle_post(const httplib::Request & req, httplib::Response & res) { + auto body = ctx_.parse_body(req, res); + if (!body) { return; } // 400 already sent + + // use body->field_name + } + +4. **Wire the schema into the route description.** Because ``MyResponse`` is + now in ``AllDtos``, ``collect_component_schemas()`` automatically includes + its schema in every ``/docs`` response. You still need to reference it in + the route's OpenAPI metadata so the spec shows the correct ``$ref``: + + - For **built-in gateway routes**, add a ``.response()`` (or + ``.request_body()``) call in ``rest_server.cpp::setup_routes()`` when + registering the route: + + .. code-block:: cpp + + reg.get("/my-entity/{id}/my-resource", + [this](auto & req, auto & res) { /* handler */ }) + .tag("MyTag") + .summary("Get my resource") + .response(200, "Resource detail", SB::ref("MyResponse")) + .operation_id("getMyResource"); + + - For routes whose path items are assembled by ``PathBuilder`` or + ``CapabilityGenerator`` (entity-scoped resource collections such as + ``/data``, ``/operations``, ``/faults``), the ``$ref`` is embedded in + the corresponding ``build_*`` method in + ``src/openapi/path_builder.cpp``. + + - For **plugin-contributed routes**, use the ``RouteDescriptionBuilder`` + API in ``core/openapi/route_descriptions.hpp`` (the builder classes + there are intended for plugin use, not for core built-in routes). + +Adding a New Endpoint (Full Checklist) +-------------------------------------- + +A new endpoint with a typed payload follows the standard gateway handler +checklist plus the DTO steps above: + +1. Define DTO struct + ``dto_fields`` + ``dto_name`` in a domain header. +2. Add to ``AllDtos`` in ``registry.hpp``. +3. Implement handler in ``src/http/handlers/``. +4. Register route in ``rest_server.cpp::setup_routes()`` (dual-path pattern + for entity types that share the same route shape). +5. Update ``handle_root`` endpoint list in ``health_handlers.cpp`` to mirror + the new route. +6. Add URI field to entity detail response if the new route is a resource + collection. +7. Write a unit test using ``JsonWriter::write()`` and + ``JsonReader::read()`` directly - no HTTP server needed. +8. Write an integration test that calls the live endpoint. + +Known Limitations +----------------- + +The ``Collection`` list-wrapper DTO hardcodes its ``x-medkit`` member as the +generic ``XMedkitCollection`` type. The entity list endpoints (areas, components, +apps, functions) emit exactly that struct, so their generated schemas are +accurate. The fault, config, data, and log list endpoints, however, emit a +richer domain-specific collection x-medkit (``FaultListXMedkit``, +``ConfigListXMedkit``, ``DataListXMedkit``, ``LogListXMedkit`` - carrying +aggregation counts, peer provenance, and similar metadata). Those domain structs +are defined and registered in ``AllDtos``, but the ``FaultList`` / ``ConfigList`` +/ ``DataList`` / ``LogList`` response schemas still type their ``x-medkit`` +property as ``XMedkitCollection``, so a generated client does not see the richer +fields from the schema alone. The wire payload remains a valid instance of the +published schema (JSON Schema allows additional properties by default), so this +is a schema-precision gap, not a contract violation. A future refinement could +parameterize ``Collection`` over its x-medkit type (for example, +``Collection``) so that each list endpoint's schema references its +real collection x-medkit struct. + +Key Files +--------- + +``include/ros2_medkit_gateway/dto/contract.hpp`` + ``Field``, ``dto_fields``, ``dto_name``, ``is_dto_v``, + ``for_each_field`` - the contract primitives. + +``include/ros2_medkit_gateway/dto/json_writer.hpp`` + ``JsonWriter`` - struct to JSON serialization. + +``include/ros2_medkit_gateway/dto/schema_writer.hpp`` + ``SchemaWriter`` and ``schema_of`` - type to OpenAPI schema. + +``include/ros2_medkit_gateway/dto/json_reader.hpp`` + ``JsonReader`` and ``FieldError`` - JSON to struct with validation. + +``include/ros2_medkit_gateway/dto/registry.hpp`` + ``AllDtos`` tuple and ``collect_component_schemas()``. + +``include/ros2_medkit_gateway/http/handlers/handler_context.hpp`` + ``HandlerContext::send_dto()`` and ``HandlerContext::parse_body()``. + +Domain headers (``dto/errors.hpp``, ``dto/entities.hpp``, ``dto/faults.hpp``, +``dto/operations.hpp``, ``dto/config.hpp``, ``dto/locks.hpp``, +``dto/triggers.hpp``, ``dto/logs.hpp``, ``dto/scripts.hpp``, +``dto/updates.hpp``, ``dto/auth.hpp``, ``dto/health.hpp``, +``dto/bulkdata.hpp``, ``dto/cyclic_subscriptions.hpp``, +``dto/data.hpp``, ``dto/x_medkit.hpp``) + Per-domain struct definitions with co-located ``dto_fields`` and + ``dto_name`` specializations. ``dto/errors.hpp`` holds ``GenericError``, + the error response DTO used by every endpoint. + +``dto/enums.hpp`` + Enum-vocabulary header. Contains the ``constexpr string_view`` arrays + (``kFaultSeverityLabelValues``, ``kOperationExecutionStatusValues``, etc.) + referenced by ``field_enum()`` descriptors in the domain headers. Does + not define any DTO structs. diff --git a/src/ros2_medkit_gateway/design/index.rst b/src/ros2_medkit_gateway/design/index.rst index 92e41f17..b68bd8df 100644 --- a/src/ros2_medkit_gateway/design/index.rst +++ b/src/ros2_medkit_gateway/design/index.rst @@ -672,5 +672,6 @@ Additional Design Documents :maxdepth: 1 aggregation + dto_contract plugin_entity_notifications ros2_subscription_architecture From 832107f63adeeea5477c9c146c2323e2fa296d7d Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 20:39:18 +0200 Subject: [PATCH 47/51] fix(gateway): preserve custom capability validation for execution updates ExecutionUpdateRequest.capability was registered with field_enum(..., kExecutionCapabilityValues), which caused parse_body to reject any out-of-vocabulary value with a generic 400 ERR_INVALID_REQUEST before the handler ran. This killed the handler's own capability-validation path that supports custom x-* capabilities and returns the richer ERR_INVALID_PARAMETER 'Unknown capability' response with a supported_capabilities array. Change the registration to plain field() so missing capability is still caught by parse_body (ERR_INVALID_REQUEST), but an out-of-vocab value flows to the handler's if/else chain as intended. Update the test comment to clarify that parse_body only catches absence, not bad values. --- .../include/ros2_medkit_gateway/dto/operations.hpp | 2 +- src/ros2_medkit_gateway/test/test_operation_handlers.cpp | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/operations.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/operations.hpp index 105bc223..d7a765a2 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/operations.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/operations.hpp @@ -177,7 +177,7 @@ struct ExecutionUpdateRequest { template <> inline constexpr auto dto_fields = - std::make_tuple(field_enum("capability", &ExecutionUpdateRequest::capability, kExecutionCapabilityValues)); + std::make_tuple(field("capability", &ExecutionUpdateRequest::capability)); template <> inline constexpr std::string_view dto_name = "ExecutionUpdateRequest"; diff --git a/src/ros2_medkit_gateway/test/test_operation_handlers.cpp b/src/ros2_medkit_gateway/test/test_operation_handlers.cpp index c468530e..8e188432 100644 --- a/src/ros2_medkit_gateway/test/test_operation_handlers.cpp +++ b/src/ros2_medkit_gateway/test/test_operation_handlers.cpp @@ -544,8 +544,8 @@ TEST_F(OperationHandlersFixtureTest, UpdateExecutionMissingCapabilityReturns400) handlers_->handle_update_execution(req, res); - // parse_body validates the required 'capability' field - // and returns ERR_INVALID_REQUEST on missing/invalid fields. + // parse_body requires the 'capability' field and + // returns ERR_INVALID_REQUEST when it is absent. EXPECT_EQ(res.status, 400); auto body = parse_json(res); EXPECT_EQ(body["error_code"], ros2_medkit_gateway::ERR_INVALID_REQUEST); From e95cdbf55d9696a832315f1c30f955c343c036cc Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 20:41:06 +0200 Subject: [PATCH 48/51] refactor(gateway): remove dead JSON serialization helpers lock_to_json and subscription_to_json had zero production callers after the DTO migration - handlers now build responses directly via JsonWriter. Delete both helpers (declaration + definition) and their test suites. REQ_INTEROP_089 was tagged on two of the subscription_to_json tests; that requirement remains covered by test_subscription_manager.cpp and test_transport_registry.cpp. --- .../core/http/handlers/lock_handlers.hpp | 8 --- .../handlers/cyclic_subscription_handlers.hpp | 3 - .../handlers/cyclic_subscription_handlers.cpp | 5 -- .../src/http/handlers/lock_handlers.cpp | 14 ----- .../test_cyclic_subscription_handlers.cpp | 59 ------------------- .../test/test_lock_handlers.cpp | 45 -------------- 6 files changed, 134 deletions(-) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/lock_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/lock_handlers.hpp index 3e53c061..67ddfb08 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/lock_handlers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/lock_handlers.hpp @@ -83,14 +83,6 @@ class LockHandlers { */ void handle_release_lock(const httplib::Request & req, httplib::Response & res); - /** - * @brief Format a LockInfo as SOVD-compliant JSON - * @param lock Lock information - * @param client_id Optional client ID for "owned" field - * @return JSON object with lock details - */ - static nlohmann::json lock_to_json(const LockInfo & lock, const std::string & client_id = ""); - /// Format a time_point as ISO 8601 UTC string static std::string format_expiration(std::chrono::steady_clock::time_point expires_at); diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/cyclic_subscription_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/cyclic_subscription_handlers.hpp index d677b0cb..7488570d 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/cyclic_subscription_handlers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/cyclic_subscription_handlers.hpp @@ -70,9 +70,6 @@ class CyclicSubscriptionHandlers { /// GET /{entity}/cyclic-subscriptions/{id}/events — SSE event stream void handle_events(const httplib::Request & req, httplib::Response & res); - /// Convert subscription info to JSON response - static nlohmann::json subscription_to_json(const CyclicSubscriptionInfo & info, const std::string & event_source); - /// Parse resource URI to extract entity type, entity id, collection, and resource path. static tl::expected parse_resource_uri(const std::string & resource); diff --git a/src/ros2_medkit_gateway/src/http/handlers/cyclic_subscription_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/cyclic_subscription_handlers.cpp index e0ee2f10..99744456 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/cyclic_subscription_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/cyclic_subscription_handlers.cpp @@ -369,11 +369,6 @@ void CyclicSubscriptionHandlers::handle_events(const httplib::Request & req, htt // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -nlohmann::json CyclicSubscriptionHandlers::subscription_to_json(const CyclicSubscriptionInfo & info, - const std::string & event_source) { - return dto::JsonWriter::write(subscription_to_dto(info, event_source)); -} - std::string CyclicSubscriptionHandlers::build_event_source(const CyclicSubscriptionInfo & info) { return std::string(API_BASE_PATH) + "/" + info.entity_type + "/" + info.entity_id + "/cyclic-subscriptions/" + info.id + "/events"; diff --git a/src/ros2_medkit_gateway/src/http/handlers/lock_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/lock_handlers.cpp index 19276207..045477b1 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/lock_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/lock_handlers.cpp @@ -109,20 +109,6 @@ static std::string to_sovd_error_code(const std::string & lock_code) { return lock_code; // Pass through if no mapping } -json LockHandlers::lock_to_json(const LockInfo & lock, const std::string & client_id) { - json result; - result["id"] = lock.lock_id; - result["owned"] = !client_id.empty() && lock.client_id == client_id; - - // scopes field is conditional - only present when lock has specific scopes - if (!lock.scopes.empty()) { - result["scopes"] = lock.scopes; - } - - result["lock_expiration"] = format_expiration(lock.expires_at); - return result; -} - /** * @brief Build a typed Lock DTO from a LockInfo. */ diff --git a/src/ros2_medkit_gateway/test/test_cyclic_subscription_handlers.cpp b/src/ros2_medkit_gateway/test/test_cyclic_subscription_handlers.cpp index f814e34e..f39bbafa 100644 --- a/src/ros2_medkit_gateway/test/test_cyclic_subscription_handlers.cpp +++ b/src/ros2_medkit_gateway/test/test_cyclic_subscription_handlers.cpp @@ -16,7 +16,6 @@ #include #include -#include #include "ros2_medkit_gateway/core/http/error_codes.hpp" #include "ros2_medkit_gateway/http/handlers/cyclic_subscription_handlers.hpp" @@ -169,64 +168,6 @@ TEST(ParseResourceUriTest, UpdatesListNotSubscribable) { EXPECT_FALSE(result.has_value()); } -// --- subscription_to_json --- - -// @verifies REQ_INTEROP_089 -TEST(CyclicSubscriptionJsonTest, ContainsAllRequiredFields) { - CyclicSubscriptionInfo info; - info.id = "sub_001"; - info.entity_id = "temp_sensor"; - info.entity_type = "apps"; - info.resource_uri = "/api/v1/apps/temp_sensor/data/temperature"; - info.protocol = "sse"; - info.interval = CyclicInterval::NORMAL; - - std::string event_source = "/api/v1/apps/temp_sensor/cyclic-subscriptions/sub_001/events"; - auto j = CyclicSubscriptionHandlers::subscription_to_json(info, event_source); - - EXPECT_EQ(j["id"], "sub_001"); - EXPECT_EQ(j["observed_resource"], info.resource_uri); - EXPECT_EQ(j["event_source"], event_source); - EXPECT_EQ(j["protocol"], "sse"); - EXPECT_EQ(j["interval"], "normal"); -} - -TEST(CyclicSubscriptionJsonTest, AllIntervalValuesSerialize) { - CyclicSubscriptionInfo info; - info.id = "sub_001"; - info.entity_type = "apps"; - info.entity_id = "e"; - - for (const auto & [interval, expected] : std::vector>{ - {CyclicInterval::FAST, "fast"}, {CyclicInterval::NORMAL, "normal"}, {CyclicInterval::SLOW, "slow"}}) { - info.interval = interval; - auto j = CyclicSubscriptionHandlers::subscription_to_json(info, "/events"); - EXPECT_EQ(j["interval"], expected); - } -} - -// @verifies REQ_INTEROP_089 -TEST(CyclicSubscriptionJsonTest, ServerLevelUpdateResource) { - CyclicSubscriptionInfo info; - info.id = "sub_updates_001"; - info.entity_id = "temp_sensor"; - info.entity_type = "apps"; - info.resource_uri = "/api/v1/updates/ADAS-v2/status"; - info.collection = "updates"; - info.resource_path = "ADAS-v2"; - info.protocol = "sse"; - info.interval = CyclicInterval::SLOW; - - std::string event_source = "/api/v1/apps/temp_sensor/cyclic-subscriptions/sub_updates_001/events"; - auto j = CyclicSubscriptionHandlers::subscription_to_json(info, event_source); - - EXPECT_EQ(j["id"], "sub_updates_001"); - EXPECT_EQ(j["observed_resource"], "/api/v1/updates/ADAS-v2/status"); - EXPECT_EQ(j["event_source"], event_source); - EXPECT_EQ(j["protocol"], "sse"); - EXPECT_EQ(j["interval"], "slow"); -} - // --- Error response format (via HandlerContext static helpers) --- TEST(CyclicSubscriptionErrorTest, InvalidParameterErrorFormat) { diff --git a/src/ros2_medkit_gateway/test/test_lock_handlers.cpp b/src/ros2_medkit_gateway/test/test_lock_handlers.cpp index 83fe9de3..1149cbd8 100644 --- a/src/ros2_medkit_gateway/test/test_lock_handlers.cpp +++ b/src/ros2_medkit_gateway/test/test_lock_handlers.cpp @@ -135,51 +135,6 @@ TEST(LockHandlersStaticTest, LockingDisabledReturns501) { EXPECT_EQ(body["error_code"], "not-implemented"); } -TEST(LockHandlersStaticTest, LockToJsonWithMatchingClientShowsOwned) { - ros2_medkit_gateway::LockInfo lock; - lock.lock_id = "lock_1"; - lock.entity_id = "comp1"; - lock.client_id = "client_a"; - lock.scopes = {"configurations"}; - lock.expires_at = std::chrono::steady_clock::now() + std::chrono::seconds(300); - - auto j = LockHandlers::lock_to_json(lock, "client_a"); - EXPECT_EQ(j["id"], "lock_1"); - EXPECT_TRUE(j["owned"].get()); - ASSERT_TRUE(j.contains("scopes")); - EXPECT_EQ(j["scopes"].size(), 1); - EXPECT_EQ(j["scopes"][0], "configurations"); - EXPECT_TRUE(j.contains("lock_expiration")); - auto expiration = j["lock_expiration"].get(); - EXPECT_TRUE(expiration.find("T") != std::string::npos); - EXPECT_TRUE(expiration.find("Z") != std::string::npos); -} - -TEST(LockHandlersStaticTest, LockToJsonWithDifferentClientShowsNotOwned) { - ros2_medkit_gateway::LockInfo lock; - lock.lock_id = "lock_2"; - lock.entity_id = "comp1"; - lock.client_id = "client_a"; - lock.scopes = {}; - lock.expires_at = std::chrono::steady_clock::now() + std::chrono::seconds(300); - - auto j = LockHandlers::lock_to_json(lock, "client_b"); - EXPECT_FALSE(j["owned"].get()); - // Empty scopes should not produce "scopes" field - EXPECT_FALSE(j.contains("scopes")); -} - -TEST(LockHandlersStaticTest, LockToJsonWithEmptyClientShowsNotOwned) { - ros2_medkit_gateway::LockInfo lock; - lock.lock_id = "lock_3"; - lock.entity_id = "comp1"; - lock.client_id = "client_a"; - lock.expires_at = std::chrono::steady_clock::now() + std::chrono::seconds(300); - - auto j = LockHandlers::lock_to_json(lock, ""); - EXPECT_FALSE(j["owned"].get()); -} - // ============================================================================ // Full handler tests (with GatewayNode for entity validation) // ============================================================================ From 3a2438b4c2a617d9f5e60253642fb739d54db0c0 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 20:41:42 +0200 Subject: [PATCH 49/51] docs(gateway): constrain field_enum to string members and note the aggregated-fault x-medkit Add a static_assert to field_enum requiring M to be std::string or std::optional. JsonReader::check_enum only fires for those types; applying field_enum to any other member type would silently accept out-of-vocabulary values at runtime. Also document FaultListAggXMedkit in the Known Limitations section of dto_contract.rst - it is the fifth collection x-medkit struct registered in AllDtos but not $ref-linked from its list response schema. --- src/ros2_medkit_gateway/design/dto_contract.rst | 11 ++++++----- .../include/ros2_medkit_gateway/dto/contract.hpp | 5 +++++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/ros2_medkit_gateway/design/dto_contract.rst b/src/ros2_medkit_gateway/design/dto_contract.rst index 7c721bd8..a2fec95b 100644 --- a/src/ros2_medkit_gateway/design/dto_contract.rst +++ b/src/ros2_medkit_gateway/design/dto_contract.rst @@ -434,11 +434,12 @@ generic ``XMedkitCollection`` type. The entity list endpoints (areas, components apps, functions) emit exactly that struct, so their generated schemas are accurate. The fault, config, data, and log list endpoints, however, emit a richer domain-specific collection x-medkit (``FaultListXMedkit``, -``ConfigListXMedkit``, ``DataListXMedkit``, ``LogListXMedkit`` - carrying -aggregation counts, peer provenance, and similar metadata). Those domain structs -are defined and registered in ``AllDtos``, but the ``FaultList`` / ``ConfigList`` -/ ``DataList`` / ``LogList`` response schemas still type their ``x-medkit`` -property as ``XMedkitCollection``, so a generated client does not see the richer +``FaultListAggXMedkit``, ``ConfigListXMedkit``, ``DataListXMedkit``, +``LogListXMedkit`` - carrying aggregation counts, peer provenance, and similar +metadata). Those domain structs are defined and registered in ``AllDtos``, but +the ``FaultList`` / ``FaultListAgg`` / ``ConfigList`` / ``DataList`` / ``LogList`` +response schemas still type their ``x-medkit`` property as +``XMedkitCollection``, so a generated client does not see the richer fields from the schema alone. The wire payload remains a valid instance of the published schema (JSON Schema allows additional properties by default), so this is a schema-precision gap, not a contract violation. A future refinement could diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/contract.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/contract.hpp index 6c6a63cb..f3263147 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/contract.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/contract.hpp @@ -83,9 +83,14 @@ constexpr Field field(std::string_view key, M C::*ptr, Presence p, std::st } /// Enum-constrained field: `values` must be an inline constexpr std::string_view array. +/// M must be std::string or std::optional; JsonReader::check_enum only +/// fires for string members, so field_enum on any other type would silently skip +/// enforcement. template constexpr Field field_enum(std::string_view key, M C::*ptr, const std::string_view (&values)[N], std::string_view desc = std::string_view{}) { + static_assert(std::is_same_v || std::is_same_v>, + "field_enum requires a std::string or std::optional member"); return Field{key, ptr, default_presence(), desc, values, N}; } From 87145b59e089328a1f2a41c2605e25a3e28bb02f Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 20:44:47 +0200 Subject: [PATCH 50/51] test(gateway): update ExecutionUpdateRequest schema assertion after capability field change The capability field is now registered as plain field() rather than field_enum(), so the generated schema no longer carries an enum array. Update the schema builder test to assert the field is an unrestricted string, matching the deliberate design to allow custom x-vendor-* capabilities through to the handler. --- src/ros2_medkit_gateway/test/test_schema_builder.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ros2_medkit_gateway/test/test_schema_builder.cpp b/src/ros2_medkit_gateway/test/test_schema_builder.cpp index 3f4f8922..09fea687 100644 --- a/src/ros2_medkit_gateway/test/test_schema_builder.cpp +++ b/src/ros2_medkit_gateway/test/test_schema_builder.cpp @@ -641,6 +641,9 @@ TEST(SchemaBuilderStaticTest, DataWriteRequestSchemaComesFromDto) { // @verifies REQ_INTEROP_002 TEST(SchemaBuilderStaticTest, ExecutionUpdateRequestSchemaComesFromDto) { // ExecutionUpdateRequest is now generated from the DTO; verify via component_schemas(). + // capability is a plain string field (no enum constraint) so that custom + // x-vendor-* capabilities pass parse_body and reach the handler's own + // validation logic. const auto & schemas = SchemaBuilder::component_schemas(); ASSERT_TRUE(schemas.count("ExecutionUpdateRequest") > 0); const auto & schema = schemas.at("ExecutionUpdateRequest"); @@ -648,10 +651,7 @@ TEST(SchemaBuilderStaticTest, ExecutionUpdateRequestSchemaComesFromDto) { ASSERT_TRUE(schema.contains("properties")); EXPECT_TRUE(schema["properties"].contains("capability")); EXPECT_EQ(schema["properties"]["capability"]["type"], "string"); - ASSERT_TRUE(schema["properties"]["capability"].contains("enum")); - auto enum_vals = schema["properties"]["capability"]["enum"].get>(); - EXPECT_EQ(enum_vals.size(), 4u); - EXPECT_NE(std::find(enum_vals.begin(), enum_vals.end(), "stop"), enum_vals.end()); + EXPECT_FALSE(schema["properties"]["capability"].contains("enum")); ASSERT_TRUE(schema.contains("required")); auto required = schema["required"].get>(); From 1bed0f88fd5f2f86d8c1fe5188294dba5f2afa2a Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 18 May 2026 23:26:07 +0200 Subject: [PATCH 51/51] docs(gateway): fix definition list term in the DTO contract design doc --- .../design/dto_contract.rst | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/ros2_medkit_gateway/design/dto_contract.rst b/src/ros2_medkit_gateway/design/dto_contract.rst index a2fec95b..40f6e1f2 100644 --- a/src/ros2_medkit_gateway/design/dto_contract.rst +++ b/src/ros2_medkit_gateway/design/dto_contract.rst @@ -469,15 +469,16 @@ Key Files ``include/ros2_medkit_gateway/http/handlers/handler_context.hpp`` ``HandlerContext::send_dto()`` and ``HandlerContext::parse_body()``. -Domain headers (``dto/errors.hpp``, ``dto/entities.hpp``, ``dto/faults.hpp``, -``dto/operations.hpp``, ``dto/config.hpp``, ``dto/locks.hpp``, -``dto/triggers.hpp``, ``dto/logs.hpp``, ``dto/scripts.hpp``, -``dto/updates.hpp``, ``dto/auth.hpp``, ``dto/health.hpp``, -``dto/bulkdata.hpp``, ``dto/cyclic_subscriptions.hpp``, -``dto/data.hpp``, ``dto/x_medkit.hpp``) - Per-domain struct definitions with co-located ``dto_fields`` and - ``dto_name`` specializations. ``dto/errors.hpp`` holds ``GenericError``, - the error response DTO used by every endpoint. +Domain headers + ``dto/errors.hpp``, ``dto/entities.hpp``, ``dto/faults.hpp``, + ``dto/operations.hpp``, ``dto/config.hpp``, ``dto/locks.hpp``, + ``dto/triggers.hpp``, ``dto/logs.hpp``, ``dto/scripts.hpp``, + ``dto/updates.hpp``, ``dto/auth.hpp``, ``dto/health.hpp``, + ``dto/bulkdata.hpp``, ``dto/cyclic_subscriptions.hpp``, + ``dto/data.hpp``, ``dto/x_medkit.hpp`` - per-domain struct definitions + with co-located ``dto_fields`` and ``dto_name`` specializations. + ``dto/errors.hpp`` holds ``GenericError``, the error response DTO used + by every endpoint. ``dto/enums.hpp`` Enum-vocabulary header. Contains the ``constexpr string_view`` arrays