Skip to content

Commit 4443ae2

Browse files
committed
fix(openapi): tighten query-parameter contract and harden its regression gate
Constrain query DTOs to the scalar shapes the parser actually supports and gate both the schema writer and the request reader on one trait, so the OpenAPI parameters and the parser can no longer advertise different types, an unenforced `required` flag, or a boolean the parser reads differently than the schema. - dto/query.hpp: is_query_scalar_v (string/bool) is now the single source of truth enforced by both QueryParamWriter and assign_query_field; static_assert that no query field is required (the parser never enforces presence); parse booleans as true/1 case-insensitively to match the advertised type: boolean. Scope the no-drift guarantee to parameter names/presence - value validation (enum membership, format) stays the handler's responsibility. - dto/faults.hpp: add FaultEntityListQuery (status only) for the per-entity GET /{entity}/faults route, which ignores the global-only correlation flags, so the route advertises exactly what the handler reads. - scripts/check_handlers_typed_query.sh: also ban has_param() and whitespace- evaded raw reads, scan both the source and include handler layers, and fail the gate on grep I/O errors instead of passing silently. - quality.yml: run the typed-query gate in CI (format-lint job). - tests: per-entity faults advertises status only; boolean spelling parsing.
1 parent 9f18020 commit 4443ae2

9 files changed

Lines changed: 193 additions & 21 deletions

File tree

.github/workflows/quality.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ jobs:
7878
- name: No naked rclcpp subscription APIs (issue #375 regression gate)
7979
run: ./scripts/check_no_naked_subscriptions.sh
8080

81+
- name: Handlers read query params via typed query<T>() (zero-query-params regression gate)
82+
run: ./src/ros2_medkit_gateway/scripts/check_handlers_typed_query.sh
83+
8184
- name: Show results
8285
if: always()
8386
run: colcon test-result --verbose

src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/enums.hpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ inline constexpr std::string_view kFaultSeverityLabelValues[] = {"INFO", "WARN",
3030
/// Fault aggregated status (fault_detail_schema - status.aggregatedStatus).
3131
inline constexpr std::string_view kFaultAggregatedStatusValues[] = {"active", "passive", "cleared"};
3232

33-
/// Fault status query filter (FaultListQuery.status / FaultClearQuery.status).
33+
/// Fault status query filter (FaultListQuery.status / FaultEntityListQuery.status
34+
/// / FaultClearQuery.status).
3435
/// Mirrors the values parse_fault_status_param() accepts in http_utils.hpp;
3536
/// any other value yields ERR_INVALID_PARAMETER. The leading entry must be a
3637
/// handler-accepted value (the OpenAPI callability test sends enum[0]).

src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/faults.hpp

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -458,9 +458,10 @@ struct dto_sample<FaultClearResult> {
458458
};
459459

460460
// =============================================================================
461-
// FaultListQuery - query parameters for GET /faults and GET /{entity}/faults.
462-
// Read by the handlers via TypedRequest::query<FaultListQuery>() and declared in
463-
// the OpenAPI spec via the same descriptor, so the two cannot drift.
461+
// FaultListQuery - query parameters for the global GET /faults list only. Read
462+
// by list_all_faults via TypedRequest::query<FaultListQuery>() and declared in
463+
// the OpenAPI spec via the same descriptor, so the two cannot drift. The
464+
// per-entity GET /{entity}/faults uses the narrower FaultEntityListQuery below.
464465
// =============================================================================
465466
struct FaultListQuery {
466467
std::optional<std::string> status;
@@ -476,6 +477,21 @@ inline constexpr auto dto_fields<FaultListQuery> = std::make_tuple(
476477
field("include_clusters", &FaultListQuery::include_clusters, Presence::kOptional,
477478
"Include fault clusters in the response"));
478479

480+
// FaultEntityListQuery - query parameters for the per-entity GET /{entity}/faults
481+
// route. Only `status` applies: include_muted / include_clusters are honored
482+
// solely by the global GET /faults, because their correlation metadata is
483+
// computed across the whole fault manager and a scoped response would leak
484+
// cross-entity data. Declaring a narrower DTO here keeps the route advertising
485+
// exactly what the handler reads (no spec-vs-runtime drift on the declared axis).
486+
struct FaultEntityListQuery {
487+
std::optional<std::string> status;
488+
};
489+
490+
template <>
491+
inline constexpr auto dto_fields<FaultEntityListQuery> =
492+
std::make_tuple(field_enum("status", &FaultEntityListQuery::status, kFaultStatusFilterValues,
493+
"Filter by fault status: pending, confirmed, cleared, healed, or all"));
494+
479495
// FaultClearQuery - query parameters for DELETE /faults (clear all). Only the
480496
// status filter applies; the correlation flags are list-only.
481497
struct FaultClearQuery {

src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/query.hpp

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,22 @@
2929
// are what the spec advertises, a handler cannot read an undeclared query
3030
// parameter. Adding a parameter means adding a member - which appears in the
3131
// spec automatically.
32+
//
33+
// Scope of the guarantee: the descriptor binds parameter NAMES and PRESENCE -
34+
// those cannot drift between the spec and the parser. It does NOT police a
35+
// parameter's VALUE. The emitted `schema` (enum membership, type/format) is
36+
// advisory metadata; enforcing it (e.g. rejecting `?status=bogus`) is the
37+
// handler's job, where the richer 400 payload lives. To keep the two type
38+
// universes aligned, query fields are constrained at compile time to the scalar
39+
// shapes the parser supports (string / bool, optionally std::optional), and a
40+
// query parameter can never be `required` (the parser always tolerates absence,
41+
// leaving the member at its default).
3242

43+
#include <cctype>
3344
#include <nlohmann/json.hpp>
3445
#include <optional>
3546
#include <string>
47+
#include <tuple>
3648
#include <type_traits>
3749
#include <utility>
3850

@@ -56,16 +68,48 @@ struct query_value<std::optional<U>> {
5668
template <class M>
5769
using query_value_t = typename query_value<M>::type;
5870

71+
/// The scalar shapes a query parameter may take (after std::optional unwrapping).
72+
/// This is the single source of truth shared by both sides of the contract: the
73+
/// reader (`assign_query_field`) parses exactly these, and the writer
74+
/// (`QueryParamWriter`) static_asserts every field against it - so the emitted
75+
/// schema can never advertise a type the parser would reject.
76+
template <class U>
77+
inline constexpr bool is_query_scalar_v = std::is_same_v<U, std::string> || std::is_same_v<U, bool>;
78+
79+
/// True iff every field of the query DTO `T` is declared optional (never
80+
/// `Presence::kRequired`). The parser leaves absent parameters at their default
81+
/// and never rejects a missing query parameter, so a `required:true` parameter
82+
/// would be advertised but silently unenforced; this check makes that
83+
/// unrepresentable. Evaluated in a `static_assert` inside `QueryParamWriter`.
84+
template <class T>
85+
constexpr bool query_dto_all_optional() {
86+
bool all_optional = true;
87+
std::apply(
88+
[&all_optional](const auto &... f) {
89+
((all_optional = all_optional && (f.presence != Presence::kRequired)), ...);
90+
},
91+
dto_fields<T>);
92+
return all_optional;
93+
}
94+
5995
/// Emits the OpenAPI `parameters` array (all `in: query`) for a query DTO `T`,
6096
/// derived from the same `dto_fields<T>` descriptor the typed reader consumes.
6197
template <class T>
6298
struct QueryParamWriter {
6399
static nlohmann::json parameters() {
100+
static_assert(query_dto_all_optional<T>(),
101+
"query DTO fields must be optional: the parser tolerates absence and never enforces "
102+
"presence, so a required query parameter would be advertised but silently unenforced. "
103+
"Declare the member as std::optional<...> or pass Presence::kOptional.");
64104
nlohmann::json params = nlohmann::json::array();
65105
for_each_field<T>([&](const auto & f) {
66106
using FieldT = std::decay_t<decltype(f)>;
67107
static_assert(!is_opaque_object_field_v<FieldT>, "query DTOs must not contain opaque-object fields");
68108
using MemberT = std::decay_t<decltype(std::declval<T>().*(f.ptr))>;
109+
static_assert(is_query_scalar_v<query_value_t<MemberT>>,
110+
"query DTO fields must be std::string or bool (optionally std::optional). The reader "
111+
"(assign_query_field) parses only these, so any other type would advertise a schema the "
112+
"parser cannot honour.");
69113

70114
nlohmann::json param;
71115
param["name"] = std::string(f.key);
@@ -76,6 +120,8 @@ struct QueryParamWriter {
76120
}
77121
nlohmann::json schema = schema_of<query_value_t<MemberT>>();
78122
if (f.enum_count > 0) {
123+
// Advisory only: the parser does not reject off-enum values (see the
124+
// file header). The handler validates membership and owns the 400.
79125
nlohmann::json values = nlohmann::json::array();
80126
for (std::size_t i = 0; i < f.enum_count; ++i) {
81127
values.push_back(std::string(f.enum_values[i]));
@@ -89,18 +135,37 @@ struct QueryParamWriter {
89135
}
90136
};
91137

138+
/// Parses a raw query-string value as a boolean, matching the `type: boolean`
139+
/// the schema advertises. `true`/`1` (case-insensitive) are truthy; every other
140+
/// value - including an empty flag-style `?x`, `false`, and `0` - is false. The
141+
/// parser does not reject malformed values (query parsing never 400s; see the
142+
/// file header), so a typo simply reads as false.
143+
inline bool parse_query_bool(const std::string & raw) {
144+
std::string lowered;
145+
lowered.reserve(raw.size());
146+
for (char c : raw) {
147+
lowered.push_back(static_cast<char>(std::tolower(static_cast<unsigned char>(c))));
148+
}
149+
return lowered == "true" || lowered == "1";
150+
}
151+
92152
/// Assigns a raw query-string value to a typed query-DTO member. Only the scalar
93153
/// shapes a query parameter can take are supported; any other field type is a
94154
/// compile error so unsupported types fail loudly at the route, not silently at
95-
/// runtime.
155+
/// runtime. `QueryParamWriter` static_asserts the same type set, so the schema
156+
/// and the parser can never advertise different shapes.
96157
template <class M>
97158
void assign_query_field(M & member, const std::string & raw) {
98-
if constexpr (std::is_same_v<M, std::string> || std::is_same_v<M, std::optional<std::string>>) {
159+
// Gate on the SAME trait QueryParamWriter enforces, so the reader and the
160+
// schema writer can never advertise different type sets - is_query_scalar_v is
161+
// the one source of truth, consulted by both sides.
162+
static_assert(is_query_scalar_v<query_value_t<M>>,
163+
"assign_query_field: unsupported query field type (expected std::string or bool, optionally "
164+
"std::optional). is_query_scalar_v is the shared source of truth QueryParamWriter also enforces.");
165+
if constexpr (std::is_same_v<query_value_t<M>, std::string>) {
99166
member = raw;
100-
} else if constexpr (std::is_same_v<M, bool> || std::is_same_v<M, std::optional<bool>>) {
101-
member = (raw == "true");
102-
} else {
103-
static_assert(sizeof(M) == 0, "assign_query_field: unsupported query field type");
167+
} else { // query_value_t<M> is bool, guaranteed by the static_assert above
168+
member = parse_query_bool(raw);
104169
}
105170
}
106171

src/ros2_medkit_gateway/scripts/check_handlers_typed_query.sh

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,55 @@
3131
set -euo pipefail
3232

3333
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
34-
HANDLERS_DIR="$(cd "${SCRIPT_DIR}/../src/http/handlers" && pwd)"
34+
35+
# Handler layers to scan. Both the HTTP and the core handler layers are covered,
36+
# on the source side (src/.../handlers, the .cpp bodies) AND the include side
37+
# (include/.../handlers, where inline handler methods live in .hpp) - a raw read
38+
# hidden in a header or a second handler layer would otherwise bypass the gate.
39+
# The plugin HTTP shim (src/plugins/plugin_http_types.cpp) is intentionally NOT
40+
# scanned: it is the sanctioned raw-httplib boundary the typed wrapper is built on.
41+
scan_dirs=()
42+
for rel in \
43+
"../src/http/handlers" \
44+
"../src/core/http/handlers" \
45+
"../include/ros2_medkit_gateway/http/handlers" \
46+
"../include/ros2_medkit_gateway/core/http/handlers"; do
47+
abs="${SCRIPT_DIR}/${rel}"
48+
if [[ -d "${abs}" ]]; then
49+
scan_dirs+=("$(cd "${abs}" && pwd)")
50+
fi
51+
done
52+
53+
if [[ ${#scan_dirs[@]} -eq 0 ]]; then
54+
echo "ERROR: no handler directories found to scan under ${SCRIPT_DIR}/../src or ../include."
55+
exit 1
56+
fi
3557

3658
# Path reads (req.path), header reads (req.header), and fan-out (raw_for_framework
3759
# for path/Authorization) are legitimate; only query-string reads are banned.
38-
pattern='\.query_param\(|->query_param\(|get_param_value\('
60+
# Match the accessor name directly (not "req." + name) so whitespace tricks like
61+
# `req . query_param (` cannot slip past, and ban has_param() too - it is the
62+
# presence half of a raw query read.
63+
pattern='(query_param|get_param_value|has_param)[[:space:]]*\('
64+
65+
# Run the scan with grep's own status preserved: 0 = matches, 1 = none, >=2 = a
66+
# real error (unreadable file, bad path). A blanket `|| true` would mask exit 2
67+
# as "no matches", so an unscannable handler file would pass the gate silently;
68+
# treat >=2 as a hard failure of the gate itself instead.
69+
set +e
70+
raw_hits="$(grep -rnE "${pattern}" "${scan_dirs[@]}" --include='*.cpp' --include='*.hpp')"
71+
grep_rc=$?
72+
set -e
73+
if [[ ${grep_rc} -ge 2 ]]; then
74+
echo "ERROR: grep failed (exit ${grep_rc}) while scanning handler dirs; cannot certify the gate."
75+
exit 1
76+
fi
3977

40-
hits="$(grep -rnE "${pattern}" "${HANDLERS_DIR}" --include='*.cpp' || true)"
78+
# Heuristic: drop matches that sit in a whole-line // comment. Inline trailing
79+
# comments and block/string occurrences are not stripped (a grep-level lint
80+
# cannot parse C++), but those are rare and a stray mention in a comment is
81+
# better flagged than a real read silently missed.
82+
hits="$(printf '%s' "${raw_hits}" | grep -vE '^[^:]*:[0-9]+:[[:space:]]*//' || true)"
4183
if [[ -n "${hits}" ]]; then
4284
echo "ERROR: handlers must read query parameters via TypedRequest::query<T>(), not raw query reads."
4385
echo "Offending lines:"

src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -462,19 +462,18 @@ http::Result<dto::FaultListResult> FaultHandlers::list_faults(const http::TypedR
462462
return tl::make_unexpected(err);
463463
}
464464

465-
const auto q = req.query<dto::FaultListQuery>();
465+
const auto q = req.query<dto::FaultEntityListQuery>();
466466
auto filter_result = read_fault_status_filter(q.status, json{{entity_info.id_field, entity_id}});
467467
if (!filter_result) {
468468
return tl::make_unexpected(filter_result.error());
469469
}
470470
const auto filter = *filter_result;
471471

472-
// Note: include_muted / include_clusters URL params are intentionally
473-
// ignored on per-entity routes - the underlying service returns
474-
// correlation metadata computed across the entire fault manager, so
475-
// emitting it on a scoped response would leak cross-entity data
476-
// (review finding N1). The global `GET /faults` route is the only place
477-
// these flags are honored.
472+
// Note: the per-entity query DTO (FaultEntityListQuery) deliberately omits
473+
// include_muted / include_clusters - the underlying service returns
474+
// correlation metadata computed across the entire fault manager, so emitting
475+
// it on a scoped response would leak cross-entity data. The global
476+
// `GET /faults` route is the only place these flags are declared and honored.
478477

479478
auto fault_mgr = ctx_.node()->get_fault_manager();
480479

src/ros2_medkit_gateway/src/http/rest_server.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -656,7 +656,7 @@ void RESTServer::setup_routes() {
656656
.summary(std::string("List faults for ") + et.singular)
657657
.description(std::string("Returns all active faults reported by this ") + et.singular + ".")
658658
.operation_id(std::string("list") + capitalize(et.singular) + "Faults")
659-
.query<dto::FaultListQuery>();
659+
.query<dto::FaultEntityListQuery>();
660660

661661
reg.get<dto::FaultDetailResult>(entity_path + "/faults/{fault_code}",
662662
[this](http::TypedRequest req) -> http::Result<dto::FaultDetailResult> {

src/ros2_medkit_gateway/test/test_route_registry.cpp

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ inline constexpr std::string_view dto_name<RouteRegistryTestSeedDto> = "RouteReg
5454
} // namespace ros2_medkit_gateway
5555

5656
using namespace ros2_medkit_gateway::openapi;
57+
using ros2_medkit_gateway::dto::FaultEntityListQuery;
5758
using ros2_medkit_gateway::dto::FaultListQuery;
5859
using ros2_medkit_gateway::dto::RouteRegistryTestSeedDto;
5960
using ros2_medkit_gateway::http::Result;
@@ -210,6 +211,30 @@ TEST_F(RouteRegistryTest, TypedQueryDeclaresParametersFromDto) {
210211
EXPECT_FALSE((*include_muted)["required"].get<bool>());
211212
}
212213

214+
// @verifies REQ_INTEROP_002
215+
TEST_F(RouteRegistryTest, PerEntityFaultsQueryAdvertisesStatusOnly) {
216+
// The per-entity /faults route uses the narrower FaultEntityListQuery so the
217+
// spec advertises exactly what the handler reads: status only. The
218+
// correlation flags (include_muted / include_clusters) are global-only, so
219+
// they must NOT appear on the per-entity route.
220+
seed_get(registry_, "/apps/{app_id}/faults").tag("Faults").query<FaultEntityListQuery>();
221+
222+
auto paths = registry_.to_openapi_paths();
223+
ASSERT_TRUE(paths.contains("/apps/{app_id}/faults"));
224+
auto & get_op = paths["/apps/{app_id}/faults"]["get"];
225+
ASSERT_TRUE(get_op.contains("parameters"));
226+
227+
std::vector<std::string> query_names;
228+
for (const auto & p : get_op["parameters"]) {
229+
if (p["in"] == "query") {
230+
query_names.push_back(p["name"].get<std::string>());
231+
}
232+
}
233+
234+
ASSERT_EQ(query_names.size(), 1u);
235+
EXPECT_EQ(query_names[0], "status");
236+
}
237+
213238
// =============================================================================
214239
// to_regex_path - path conversion
215240
// =============================================================================

src/ros2_medkit_gateway/test/test_typed_router.cpp

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,27 @@ TEST(TypedRouter_TypedRequest, TypedQueryLeavesAbsentParamsAtDefault) {
183183
EXPECT_FALSE(q.include_clusters);
184184
}
185185

186+
TEST(TypedRouter_TypedRequest, TypedQueryParsesBooleanSpellings) {
187+
// The schema advertises type: boolean; "true"/"1" (case-insensitive) are
188+
// truthy, every other spelling - including "0", "false", and an empty
189+
// flag-style value - parses as false.
190+
auto parse_include_muted = [](const char * value) {
191+
httplib::Request req;
192+
req.path = "/api/v1/faults";
193+
req.params.emplace("include_muted", value);
194+
return TypedRequest(req).query<FaultListQuery>().include_muted;
195+
};
196+
197+
EXPECT_TRUE(parse_include_muted("true"));
198+
EXPECT_TRUE(parse_include_muted("TRUE"));
199+
EXPECT_TRUE(parse_include_muted("True"));
200+
EXPECT_TRUE(parse_include_muted("1"));
201+
EXPECT_FALSE(parse_include_muted("false"));
202+
EXPECT_FALSE(parse_include_muted("0"));
203+
EXPECT_FALSE(parse_include_muted(""));
204+
EXPECT_FALSE(parse_include_muted("yes"));
205+
}
206+
186207
TEST(TypedRouter_TypedRequest, FanOutDisabledTrueOnlyWhenHeaderPresent) {
187208
{
188209
httplib::Request req;

0 commit comments

Comments
 (0)