From 537462a3b2b2390d010f66a7f519b52e0e8e006e Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Thu, 11 Jun 2026 17:34:51 +0200 Subject: [PATCH] feat(openapi): derive query parameters from a typed query contract Query parameters were the one part of the HTTP contract not derived from a descriptor. Handlers read them ad-hoc via req.query_param(), and the OpenAPI generator had no way to see them, so the exported spec - and every client generated from it - carried zero query parameters even though the gateway reads them at runtime. Make query parameters a typed, descriptor-driven contract, like request and response bodies: - dto::QueryParamWriter emits the OpenAPI `parameters` (all in: query) for a query DTO from its dto_fields, mirroring SchemaWriter. - TypedRequest::query() parses the request query string into a typed query DTO via the same descriptor. - RouteEntry::query() declares those parameters on the route. A handler can only read fields that exist on its query DTO, and those fields are exactly what the route advertises, so the parsed object and the spec cannot drift. Adding a parameter means adding a member - which appears in the spec automatically. Applied to the endpoints whose handlers read query parameters: - GET /faults and GET /{entity}/faults: status, include_muted, include_clusters - DELETE /faults: status - GET /{entity}/logs: severity, context - GET /updates: origin, target-version A pre-commit guard (check_handlers_typed_query.sh) bans raw query reads in handlers so the typed path stays the only way in. Adds RouteRegistry and TypedRequest tests covering the writer and reader. --- .pre-commit-config.yaml | 6 + .../core/http/http_utils.hpp | 13 ++- .../ros2_medkit_gateway/dto/faults.hpp | 29 +++++ .../include/ros2_medkit_gateway/dto/logs.hpp | 15 +++ .../include/ros2_medkit_gateway/dto/query.hpp | 108 ++++++++++++++++++ .../ros2_medkit_gateway/dto/updates.hpp | 15 +++ .../ros2_medkit_gateway/http/typed_router.hpp | 18 +++ .../scripts/check_handlers_typed_query.sh | 51 +++++++++ .../src/core/openapi/route_registry.cpp | 7 ++ .../src/http/handlers/fault_handlers.cpp | 25 ++-- .../src/http/handlers/log_handlers.cpp | 5 +- .../src/http/handlers/update_handlers.cpp | 9 +- .../src/http/rest_server.cpp | 15 ++- .../src/openapi/route_registry.hpp | 13 +++ .../test/test_route_registry.cpp | 79 +++++++++++++ .../test/test_typed_router.cpp | 27 +++++ 16 files changed, 403 insertions(+), 32 deletions(-) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/query.hpp create mode 100755 src/ros2_medkit_gateway/scripts/check_handlers_typed_query.sh diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4fde4d4f8..2f862ee40 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -78,6 +78,12 @@ repos: stages: [pre-push] pass_filenames: false always_run: true + - id: gateway-handlers-typed-query + name: handlers must read query params via typed query() + entry: ./src/ros2_medkit_gateway/scripts/check_handlers_typed_query.sh + language: script + pass_filenames: false + always_run: true # ── Incremental clang-tidy (pre-push only) ──────────────────────── # Requires: pre-commit install --hook-type pre-push diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/http_utils.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/http_utils.hpp index 75c8c27bf..96c170f77 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/http_utils.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/http_utils.hpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include @@ -99,15 +100,15 @@ struct FaultStatusFilter { }; /** - * @brief Parse fault status query parameter from request - * @param req HTTP request - * @return Filter flags and validity. If status param is invalid, is_valid=false. + * @brief Map a fault `status` query value to a FaultStatusFilter. + * @param status_opt The `status` query parameter value, or nullopt if absent. + * @return Filter flags and validity. If status value is unknown, is_valid=false. */ -inline FaultStatusFilter parse_fault_status_param(const httplib::Request & req) { +inline FaultStatusFilter parse_fault_status_param(const std::optional & status_opt) { FaultStatusFilter filter; - if (req.has_param("status")) { - std::string status = req.get_param_value("status"); + if (status_opt) { + const std::string & status = *status_opt; // Reset defaults when explicit status filter is provided filter.include_pending = false; filter.include_confirmed = false; 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 475b6064e..11fc48d49 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 @@ -457,5 +457,34 @@ struct dto_sample { } }; +// ============================================================================= +// FaultListQuery - query parameters for GET /faults and GET /{entity}/faults. +// Read by the handlers via TypedRequest::query() and declared in +// the OpenAPI spec via the same descriptor, so the two cannot drift. +// ============================================================================= +struct FaultListQuery { + std::optional status; + bool include_muted = false; + bool include_clusters = false; +}; + +template <> +inline constexpr auto dto_fields = std::make_tuple( + field("status", &FaultListQuery::status, "Filter by fault status: pending, confirmed, cleared, healed, or all"), + field("include_muted", &FaultListQuery::include_muted, Presence::kOptional, "Include muted faults in the response"), + field("include_clusters", &FaultListQuery::include_clusters, Presence::kOptional, + "Include fault clusters in the response")); + +// FaultClearQuery - query parameters for DELETE /faults (clear all). Only the +// status filter applies; the correlation flags are list-only. +struct FaultClearQuery { + std::optional status; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("status", &FaultClearQuery::status, + "Clear only faults in this status: pending, confirmed, cleared, healed, or all")); + } // namespace dto } // namespace ros2_medkit_gateway 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 5d1ac0a62..e39b14d57 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 @@ -173,5 +173,20 @@ inline constexpr auto dto_fields = std::make_tuple( template <> inline constexpr std::string_view dto_name = "LogConfiguration"; +// ============================================================================= +// LogQuery - query parameters for GET /{entity}/logs. Read by the handler via +// TypedRequest::query() and declared in the OpenAPI spec via the same +// descriptor, so the two cannot drift. +// ============================================================================= +struct LogQuery { + std::optional severity; + std::optional context; +}; + +template <> +inline constexpr auto dto_fields = std::make_tuple( + field("severity", &LogQuery::severity, "Filter by minimum severity: debug, info, warning, error, or fatal"), + field("context", &LogQuery::context, "Filter by logger context substring (max 256 chars)")); + } // namespace dto } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/query.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/query.hpp new file mode 100644 index 000000000..292e27a07 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/query.hpp @@ -0,0 +1,108 @@ +// 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 + +// Query-parameter contract: the fourth descriptor-driven visitor. +// +// A "query DTO" is a plain struct with a `dto_fields` specialization whose +// members are query-parameter scalars (string / bool, optionally wrapped in +// std::optional). The same descriptor drives two sides so they cannot drift: +// +// * QueryParamWriter - emits the OpenAPI `parameters` array (all +// `in: query`) for the route, mirroring SchemaWriter. +// * TypedRequest::query() (see http/typed_router.hpp) - parses the request +// query string into a typed T, using `assign_query_field` below. +// +// Because a handler can only read fields that exist on T, and those same fields +// are what the spec advertises, a handler cannot read an undeclared query +// parameter. Adding a parameter means adding a member - which appears in the +// spec automatically. + +#include +#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 { + +/// Unwrap std::optional -> U, identity otherwise. Query-parameter optionality +/// is expressed via `required:false`, not a nullable schema, so the emitted +/// `schema` uses the unwrapped scalar type. +template +struct query_value { + using type = M; +}; +template +struct query_value> { + using type = U; +}; +template +using query_value_t = typename query_value::type; + +/// Emits the OpenAPI `parameters` array (all `in: query`) for a query DTO `T`, +/// derived from the same `dto_fields` descriptor the typed reader consumes. +template +struct QueryParamWriter { + static nlohmann::json parameters() { + nlohmann::json params = nlohmann::json::array(); + for_each_field([&](const auto & f) { + using FieldT = std::decay_t; + static_assert(!is_opaque_object_field_v, "query DTOs must not contain opaque-object fields"); + using MemberT = std::decay_t().*(f.ptr))>; + + nlohmann::json param; + param["name"] = std::string(f.key); + param["in"] = "query"; + param["required"] = (f.presence == Presence::kRequired); + if (!f.description.empty()) { + param["description"] = std::string(f.description); + } + nlohmann::json schema = schema_of>(); + 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])); + } + schema["enum"] = std::move(values); + } + param["schema"] = std::move(schema); + params.push_back(std::move(param)); + }); + return params; + } +}; + +/// Assigns a raw query-string value to a typed query-DTO member. Only the scalar +/// shapes a query parameter can take are supported; any other field type is a +/// compile error so unsupported types fail loudly at the route, not silently at +/// runtime. +template +void assign_query_field(M & member, const std::string & raw) { + if constexpr (std::is_same_v || std::is_same_v>) { + member = raw; + } else if constexpr (std::is_same_v || std::is_same_v>) { + member = (raw == "true"); + } else { + static_assert(sizeof(M) == 0, "assign_query_field: unsupported query field type"); + } +} + +} // namespace dto +} // namespace ros2_medkit_gateway 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 index 867121e7b..ee5ecc372 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/updates.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/updates.hpp @@ -289,5 +289,20 @@ inline constexpr auto dto_fields = std::make_tuple(field template <> inline constexpr std::string_view dto_name = "UpdateRegisterResponse"; +// ============================================================================= +// UpdateListQuery - query parameters for GET /updates. Read by the handler via +// TypedRequest::query() and declared in the OpenAPI spec via +// the same descriptor, so the two cannot drift. +// ============================================================================= +struct UpdateListQuery { + std::optional origin; + std::optional target_version; +}; + +template <> +inline constexpr auto dto_fields = + std::make_tuple(field("origin", &UpdateListQuery::origin, "Filter by update origin identifier"), + field("target-version", &UpdateListQuery::target_version, "Filter by target version")); + } // namespace dto } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/typed_router.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/typed_router.hpp index d1c3a08f5..81cfbe3b6 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/typed_router.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/typed_router.hpp @@ -21,6 +21,7 @@ #include #include "ros2_medkit_gateway/core/http/error_codes.hpp" +#include "ros2_medkit_gateway/dto/query.hpp" // Re-export the httplib-free handler-result vocabulary so existing includers of // typed_router.hpp keep seeing Result/NoContent/Forwarded/ValidatorResult/ // ResponseAttachments. The markers themselves live in this leaf header, which @@ -94,6 +95,23 @@ class TypedRequest { return req_.get_param_value(name_str.c_str()); } + /// Parses the request's query string into a typed query DTO `T`, using the + /// same `dto_fields` descriptor that declares the parameters in the OpenAPI + /// spec (via `dto::QueryParamWriter`). Absent parameters leave their member + /// at its default. This is the sanctioned way for handlers to read query + /// parameters: a handler cannot read a parameter the route did not declare, + /// because it can only access members of `T`. + template + T query() const { + T out{}; + dto::for_each_field([&](const auto & f) { + if (auto raw = query_param(f.key)) { + dto::assign_query_field(out.*(f.ptr), *raw); + } + }); + return out; + } + /// Returns the value of the named header, or std::nullopt if absent. std::optional header(std::string_view name) const { // Note: see query_param() comment - Humble compatibility requires c_str(). diff --git a/src/ros2_medkit_gateway/scripts/check_handlers_typed_query.sh b/src/ros2_medkit_gateway/scripts/check_handlers_typed_query.sh new file mode 100755 index 000000000..fdac68bc0 --- /dev/null +++ b/src/ros2_medkit_gateway/scripts/check_handlers_typed_query.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# 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. + +# Asserts that HTTP handlers read query parameters ONLY through the typed +# TypedRequest::query() contract, never via raw req.query_param() or +# get_param_value(). +# +# Why: the typed path derives the OpenAPI `parameters` from the same query-DTO +# descriptor (dto::QueryParamWriter) that the handler parses. A raw read is +# invisible to the spec, so the parameter never reaches the generated clients - +# exactly the regression that left every query parameter out of the published +# 0.5.0 clients. Forcing the typed path makes that drift impossible: a handler +# can only read fields that exist on its query DTO, and those fields are what the +# route declares. +# +# To add a query parameter: define a query DTO (struct + dto_fields), declare it +# on the route with .query(), and read it with req.query(). See +# include/ros2_medkit_gateway/dto/query.hpp. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HANDLERS_DIR="$(cd "${SCRIPT_DIR}/../src/http/handlers" && pwd)" + +# Path reads (req.path), header reads (req.header), and fan-out (raw_for_framework +# for path/Authorization) are legitimate; only query-string reads are banned. +pattern='\.query_param\(|->query_param\(|get_param_value\(' + +hits="$(grep -rnE "${pattern}" "${HANDLERS_DIR}" --include='*.cpp' || true)" +if [[ -n "${hits}" ]]; then + echo "ERROR: handlers must read query parameters via TypedRequest::query(), not raw query reads." + echo "Offending lines:" + echo "${hits}" + echo + echo "Fix: define a query DTO (struct + dto_fields), declare it on the route with" + echo ".query(), and read it via req.query(). See dto/query.hpp." + exit 1 +fi + +echo "OK: handlers read query parameters only via the typed query() contract." diff --git a/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp b/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp index 467364e46..1e15e6cd1 100644 --- a/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp +++ b/src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp @@ -101,6 +101,13 @@ RouteEntry & RouteEntry::query_param(const std::string & name, const std::string return *this; } +RouteEntry & RouteEntry::add_query_parameters(const nlohmann::json & params) { + for (const auto & param : params) { + parameters_.push_back(param); + } + return *this; +} + RouteEntry & RouteEntry::header_param(const std::string & name, const std::string & desc, bool required, const nlohmann::json & schema) { nlohmann::json param; 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 61a87cea5..91116e606 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp @@ -81,19 +81,15 @@ tl::expected read_fault_code(const http::TypedRequest & /// Build a populated FaultStatusFilter from query params, surfacing an /// ErrorInfo when the `status` value is unknown. -tl::expected read_fault_status_filter(const http::TypedRequest & req, +tl::expected read_fault_status_filter(const std::optional & status, const json & extra_params = {}) { -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - const auto & raw_req = req.raw_for_framework(); -#pragma GCC diagnostic pop - auto filter = parse_fault_status_param(raw_req); + auto filter = parse_fault_status_param(status); if (filter.is_valid) { return filter; } json params{{"allowed_values", "pending, confirmed, cleared, healed, all"}, {"parameter", "status"}, - {"value", raw_req.get_param_value("status")}}; + {"value", status.value_or("")}}; if (extra_params.is_object()) { for (auto it = extra_params.begin(); it != extra_params.end(); ++it) { params[it.key()] = it.value(); @@ -397,20 +393,17 @@ dto::FaultDetail FaultHandlers::build_sovd_fault_response(const json & fault_jso http::Result FaultHandlers::list_all_faults(const http::TypedRequest & req) { try { - auto filter_result = read_fault_status_filter(req); + const auto q = req.query(); + auto filter_result = read_fault_status_filter(q.status); if (!filter_result) { return tl::make_unexpected(filter_result.error()); } const auto filter = *filter_result; - // Parse correlation query parameters - const bool include_muted = req.query_param("include_muted").value_or(std::string{}) == "true"; - const bool include_clusters = req.query_param("include_clusters").value_or(std::string{}) == "true"; - auto fault_mgr = ctx_.node()->get_fault_manager(); // Empty source_id = no filtering, return all faults auto result = fault_mgr->list_faults("", filter.include_pending, filter.include_confirmed, filter.include_cleared, - filter.include_healed, include_muted, include_clusters); + filter.include_healed, q.include_muted, q.include_clusters); if (!result.success) { return tl::make_unexpected( make_error(503, ERR_SERVICE_UNAVAILABLE, "Failed to get faults", json{{"details", result.error_message}})); @@ -517,7 +510,8 @@ http::Result FaultHandlers::list_faults(const http::TypedR return tl::make_unexpected(err); } - auto filter_result = read_fault_status_filter(req, json{{entity_info.id_field, entity_id}}); + const auto q = req.query(); + auto filter_result = read_fault_status_filter(q.status, json{{entity_info.id_field, entity_id}}); if (!filter_result) { return tl::make_unexpected(filter_result.error()); } @@ -1015,7 +1009,8 @@ http::Result FaultHandlers::clear_all_faults(const http::TypedR http::Result> FaultHandlers::clear_all_faults_global(const http::TypedRequest & req) { try { - auto filter_result = read_fault_status_filter(req); + const auto q = req.query(); + auto filter_result = read_fault_status_filter(q.status); if (!filter_result) { return tl::make_unexpected(filter_result.error()); } 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 abffcd003..c7145ed62 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/log_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/log_handlers.cpp @@ -112,8 +112,9 @@ LogHandlers::get_logs(const http::TypedRequest & req) { } // Validate optional query parameters (shared across all entity types) - const std::string min_severity = req.query_param("severity").value_or(std::string{}); - const std::string context_filter = req.query_param("context").value_or(std::string{}); + const auto q = req.query(); + const std::string min_severity = q.severity.value_or(std::string{}); + const std::string context_filter = q.context.value_or(std::string{}); if (!min_severity.empty() && !LogManager::is_valid_severity(min_severity)) { return tl::unexpected(make_error(400, ERR_INVALID_PARAMETER, 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 481b0b21f..83918b2a2 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/update_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/update_handlers.cpp @@ -197,12 +197,13 @@ http::Result UpdateHandlers::get_updates(const http::TypedReque return tl::unexpected(*guard); } try { + const auto q = req.query(); UpdateFilter filter; - if (auto origin = req.query_param("origin")) { - filter.origin = *origin; + if (q.origin) { + filter.origin = *q.origin; } - if (auto target = req.query_param("target-version")) { - filter.target_version = *target; + if (q.target_version) { + filter.target_version = *q.target_version; } auto result = update_mgr_->list_updates(filter); diff --git a/src/ros2_medkit_gateway/src/http/rest_server.cpp b/src/ros2_medkit_gateway/src/http/rest_server.cpp index 0de074cb1..bafd9bdcb 100644 --- a/src/ros2_medkit_gateway/src/http/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/http/rest_server.cpp @@ -655,7 +655,8 @@ void RESTServer::setup_routes() { .tag("Faults") .summary(std::string("List faults for ") + et.singular) .description(std::string("Returns all active faults reported by this ") + et.singular + ".") - .operation_id(std::string("list") + capitalize(et.singular) + "Faults"); + .operation_id(std::string("list") + capitalize(et.singular) + "Faults") + .query(); reg.get(entity_path + "/faults/{fault_code}", [this](http::TypedRequest req) -> http::Result { @@ -700,7 +701,8 @@ void RESTServer::setup_routes() { .tag("Logs") .summary(std::string("Query log entries for ") + et.singular) .description(std::string("Queries application log entries for this ") + et.singular + ".") - .operation_id(std::string("list") + capitalize(et.singular) + "Logs"); + .operation_id(std::string("list") + capitalize(et.singular) + "Logs") + .query(); reg.get(entity_path + "/logs/configuration", [this](http::TypedRequest req) -> http::Result { @@ -1406,7 +1408,8 @@ void RESTServer::setup_routes() { .tag("Faults") .summary("List all faults globally") .description("Retrieve all faults across the system.") - .operation_id("listAllFaults"); + .operation_id("listAllFaults") + .query(); reg.del( "/faults", @@ -1416,7 +1419,8 @@ void RESTServer::setup_routes() { .tag("Faults") .summary("Clear all faults globally") .description("Clears all faults across the entire system.") - .operation_id("clearAllFaults"); + .operation_id("clearAllFaults") + .query(); // === Software Updates === // @@ -1451,7 +1455,8 @@ void RESTServer::setup_routes() { .tag("Updates") .summary("List software updates") .description("Lists all registered software updates.") - .operation_id("listUpdates"); + .operation_id("listUpdates") + .query(); reg.post( "/updates", diff --git a/src/ros2_medkit_gateway/src/openapi/route_registry.hpp b/src/ros2_medkit_gateway/src/openapi/route_registry.hpp index ac0037c35..4eae61f2f 100644 --- a/src/ros2_medkit_gateway/src/openapi/route_registry.hpp +++ b/src/ros2_medkit_gateway/src/openapi/route_registry.hpp @@ -32,6 +32,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/query.hpp" #include "ros2_medkit_gateway/http/alternate_status.hpp" #include "ros2_medkit_gateway/http/detail/forward_response_scope.hpp" #include "ros2_medkit_gateway/http/detail/primitives.hpp" @@ -78,6 +79,18 @@ class RouteEntry { 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"); + + /// Appends a pre-built array of OpenAPI query parameter objects to this route. + RouteEntry & add_query_parameters(const nlohmann::json & params); + + /// Typed query parameters: declares every member of the query DTO `T` as an + /// `in: query` parameter, derived from the same `dto_fields` descriptor a + /// handler reads via `TypedRequest::query()`. The declared parameters and + /// the parsed object cannot drift - both come from one descriptor. + template + RouteEntry & query() { + return add_query_parameters(dto::QueryParamWriter::parameters()); + } RouteEntry & header_param(const std::string & name, const std::string & desc, bool required = true, const nlohmann::json & schema = {{"type", "string"}}); RouteEntry & deprecated(); diff --git a/src/ros2_medkit_gateway/test/test_route_registry.cpp b/src/ros2_medkit_gateway/test/test_route_registry.cpp index fb792d5a9..9e0b045bc 100644 --- a/src/ros2_medkit_gateway/test/test_route_registry.cpp +++ b/src/ros2_medkit_gateway/test/test_route_registry.cpp @@ -25,6 +25,7 @@ #include "../src/openapi/route_registry.hpp" #include "ros2_medkit_gateway/dto/contract.hpp" +#include "ros2_medkit_gateway/dto/faults.hpp" #include "ros2_medkit_gateway/http/typed_router.hpp" // ----------------------------------------------------------------------------- @@ -53,6 +54,7 @@ inline constexpr std::string_view dto_name = "RouteReg } // namespace ros2_medkit_gateway using namespace ros2_medkit_gateway::openapi; +using ros2_medkit_gateway::dto::FaultListQuery; using ros2_medkit_gateway::dto::RouteRegistryTestSeedDto; using ros2_medkit_gateway::http::Result; using ros2_medkit_gateway::http::TypedRequest; @@ -131,6 +133,83 @@ TEST_F(RouteRegistryTest, ToOpenapiPathsMultipleMethodsSamePath) { EXPECT_TRUE(paths["/data"].contains("post")); } +// @verifies REQ_INTEROP_002 +TEST_F(RouteRegistryTest, ToOpenapiPathsEmitsQueryParameters) { + seed_get(registry_, "/components/{component_id}/logs") + .tag("Logs") + .query_param("severity", "Filter by minimum severity") + .query_param("context", "Filter by logger context") + .query_param("include_muted", "Include muted entries", "boolean"); + + auto paths = registry_.to_openapi_paths(); + + ASSERT_TRUE(paths.contains("/components/{component_id}/logs")); + auto & get_op = paths["/components/{component_id}/logs"]["get"]; + ASSERT_TRUE(get_op.contains("parameters")); + + // Collect the query parameters by name (the path param is auto-generated). + std::vector query_names; + const nlohmann::json * severity = nullptr; + const nlohmann::json * include_muted = nullptr; + for (const auto & p : get_op["parameters"]) { + if (p["in"] == "query") { + query_names.push_back(p["name"].get()); + if (p["name"] == "severity") { + severity = &p; + } + if (p["name"] == "include_muted") { + include_muted = &p; + } + } + } + + EXPECT_EQ(query_names.size(), 3u); + ASSERT_NE(severity, nullptr); + EXPECT_EQ((*severity)["in"], "query"); + EXPECT_FALSE((*severity)["required"].get()); + EXPECT_EQ((*severity)["schema"]["type"], "string"); + ASSERT_NE(include_muted, nullptr); + EXPECT_EQ((*include_muted)["schema"]["type"], "boolean"); +} + +// @verifies REQ_INTEROP_002 +TEST_F(RouteRegistryTest, TypedQueryDeclaresParametersFromDto) { + // .query() derives the OpenAPI parameters straight from dto_fields - the + // same descriptor a handler reads via TypedRequest::query(), so the two + // cannot drift. + seed_get(registry_, "/faults").tag("Faults").query(); + + auto paths = registry_.to_openapi_paths(); + ASSERT_TRUE(paths.contains("/faults")); + auto & get_op = paths["/faults"]["get"]; + ASSERT_TRUE(get_op.contains("parameters")); + + const nlohmann::json * status = nullptr; + const nlohmann::json * include_muted = nullptr; + std::size_t query_count = 0; + for (const auto & p : get_op["parameters"]) { + if (p["in"] == "query") { + ++query_count; + if (p["name"] == "status") { + status = &p; + } + if (p["name"] == "include_muted") { + include_muted = &p; + } + } + } + + // status + include_muted + include_clusters. + EXPECT_EQ(query_count, 3u); + ASSERT_NE(status, nullptr); + EXPECT_EQ((*status)["in"], "query"); + EXPECT_FALSE((*status)["required"].get()); // optional -> not required + EXPECT_EQ((*status)["schema"]["type"], "string"); + ASSERT_NE(include_muted, nullptr); + EXPECT_EQ((*include_muted)["schema"]["type"], "boolean"); // bool member, kOptional presence + EXPECT_FALSE((*include_muted)["required"].get()); +} + // ============================================================================= // to_regex_path - path conversion // ============================================================================= diff --git a/src/ros2_medkit_gateway/test/test_typed_router.cpp b/src/ros2_medkit_gateway/test/test_typed_router.cpp index 3ccc5a0e7..8b8c55d11 100644 --- a/src/ros2_medkit_gateway/test/test_typed_router.cpp +++ b/src/ros2_medkit_gateway/test/test_typed_router.cpp @@ -19,11 +19,13 @@ #include #include "ros2_medkit_gateway/core/http/error_codes.hpp" +#include "ros2_medkit_gateway/dto/faults.hpp" #include "ros2_medkit_gateway/http/typed_router.hpp" namespace { using ros2_medkit_gateway::ErrorInfo; +using ros2_medkit_gateway::dto::FaultListQuery; using ros2_medkit_gateway::http::Forwarded; using ros2_medkit_gateway::http::NoContent; using ros2_medkit_gateway::http::ResponseAttachments; @@ -156,6 +158,31 @@ TEST(TypedRouter_TypedRequest, QueryParamReturnsValueWhenPresent) { EXPECT_FALSE(wrapper.query_param("limit").has_value()); } +TEST(TypedRouter_TypedRequest, TypedQueryParsesPresentParamsIntoDto) { + httplib::Request req; + req.path = "/api/v1/faults"; + req.params.emplace("status", "confirmed"); + req.params.emplace("include_muted", "true"); + TypedRequest wrapper(req); + + const auto q = wrapper.query(); + ASSERT_TRUE(q.status.has_value()); + EXPECT_EQ(*q.status, "confirmed"); + EXPECT_TRUE(q.include_muted); + EXPECT_FALSE(q.include_clusters); // absent boolean -> default false +} + +TEST(TypedRouter_TypedRequest, TypedQueryLeavesAbsentParamsAtDefault) { + httplib::Request req; + req.path = "/api/v1/faults"; + TypedRequest wrapper(req); + + const auto q = wrapper.query(); + EXPECT_FALSE(q.status.has_value()); + EXPECT_FALSE(q.include_muted); + EXPECT_FALSE(q.include_clusters); +} + TEST(TypedRouter_TypedRequest, FanOutDisabledTrueOnlyWhenHeaderPresent) { { httplib::Request req;