Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>()
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
#include <ctime>
#include <filesystem>
#include <fstream>
#include <optional>
#include <string>
#include <vector>

Expand Down Expand Up @@ -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<std::string> & 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;
Expand Down
29 changes: 29 additions & 0 deletions src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/faults.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -457,5 +457,34 @@ struct dto_sample<FaultClearResult> {
}
};

// =============================================================================
// FaultListQuery - query parameters for GET /faults and GET /{entity}/faults.
// Read by the handlers via TypedRequest::query<FaultListQuery>() and declared in
// the OpenAPI spec via the same descriptor, so the two cannot drift.
// =============================================================================
struct FaultListQuery {
std::optional<std::string> status;
bool include_muted = false;
bool include_clusters = false;
};

template <>
inline constexpr auto dto_fields<FaultListQuery> = 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<std::string> status;
};

template <>
inline constexpr auto dto_fields<FaultClearQuery> =
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
15 changes: 15 additions & 0 deletions src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/logs.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -173,5 +173,20 @@ inline constexpr auto dto_fields<LogConfiguration> = std::make_tuple(
template <>
inline constexpr std::string_view dto_name<LogConfiguration> = "LogConfiguration";

// =============================================================================
// LogQuery - query parameters for GET /{entity}/logs. Read by the handler via
// TypedRequest::query<LogQuery>() and declared in the OpenAPI spec via the same
// descriptor, so the two cannot drift.
// =============================================================================
struct LogQuery {
std::optional<std::string> severity;
std::optional<std::string> context;
};

template <>
inline constexpr auto dto_fields<LogQuery> = 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
108 changes: 108 additions & 0 deletions src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/query.hpp
Original file line number Diff line number Diff line change
@@ -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<T>` 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<T> - emits the OpenAPI `parameters` array (all
// `in: query`) for the route, mirroring SchemaWriter.
// * TypedRequest::query<T>() (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 <nlohmann/json.hpp>
#include <optional>
#include <string>
#include <type_traits>
#include <utility>

#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> -> 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 <class M>
struct query_value {
using type = M;
};
template <class U>
struct query_value<std::optional<U>> {
using type = U;
};
template <class M>
using query_value_t = typename query_value<M>::type;

/// Emits the OpenAPI `parameters` array (all `in: query`) for a query DTO `T`,
/// derived from the same `dto_fields<T>` descriptor the typed reader consumes.
template <class T>
struct QueryParamWriter {
static nlohmann::json parameters() {
nlohmann::json params = nlohmann::json::array();
for_each_field<T>([&](const auto & f) {
using FieldT = std::decay_t<decltype(f)>;
static_assert(!is_opaque_object_field_v<FieldT>, "query DTOs must not contain opaque-object fields");
using MemberT = std::decay_t<decltype(std::declval<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<query_value_t<MemberT>>();
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 <class M>
void assign_query_field(M & member, const std::string & raw) {
if constexpr (std::is_same_v<M, std::string> || std::is_same_v<M, std::optional<std::string>>) {
member = raw;
} else if constexpr (std::is_same_v<M, bool> || std::is_same_v<M, std::optional<bool>>) {
member = (raw == "true");
} else {
static_assert(sizeof(M) == 0, "assign_query_field: unsupported query field type");
}
}

} // namespace dto
} // namespace ros2_medkit_gateway
Original file line number Diff line number Diff line change
Expand Up @@ -289,5 +289,20 @@ inline constexpr auto dto_fields<UpdateRegisterResponse> = std::make_tuple(field
template <>
inline constexpr std::string_view dto_name<UpdateRegisterResponse> = "UpdateRegisterResponse";

// =============================================================================
// UpdateListQuery - query parameters for GET /updates. Read by the handler via
// TypedRequest::query<UpdateListQuery>() and declared in the OpenAPI spec via
// the same descriptor, so the two cannot drift.
// =============================================================================
struct UpdateListQuery {
std::optional<std::string> origin;
std::optional<std::string> target_version;
};

template <>
inline constexpr auto dto_fields<UpdateListQuery> =
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
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
#include <string_view>

#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
Expand Down Expand Up @@ -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<T>` descriptor that declares the parameters in the OpenAPI
/// spec (via `dto::QueryParamWriter<T>`). 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 <class T>
T query() const {
T out{};
dto::for_each_field<T>([&](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<std::string> header(std::string_view name) const {
// Note: see query_param() comment - Humble compatibility requires c_str().
Expand Down
51 changes: 51 additions & 0 deletions src/ros2_medkit_gateway/scripts/check_handlers_typed_query.sh
Original file line number Diff line number Diff line change
@@ -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<T>() 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<T>) 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<T>(), and read it with req.query<T>(). 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)"
Comment on lines +33 to +40
if [[ -n "${hits}" ]]; then
echo "ERROR: handlers must read query parameters via TypedRequest::query<T>(), 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<T>(), and read it via req.query<T>(). See dto/query.hpp."
exit 1
fi

echo "OK: handlers read query parameters only via the typed query<T>() contract."
7 changes: 7 additions & 0 deletions src/ros2_medkit_gateway/src/core/openapi/route_registry.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
25 changes: 10 additions & 15 deletions src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -81,19 +81,15 @@ tl::expected<std::string, ErrorInfo> read_fault_code(const http::TypedRequest &

/// Build a populated FaultStatusFilter from query params, surfacing an
/// ErrorInfo when the `status` value is unknown.
tl::expected<FaultStatusFilter, ErrorInfo> read_fault_status_filter(const http::TypedRequest & req,
tl::expected<FaultStatusFilter, ErrorInfo> read_fault_status_filter(const std::optional<std::string> & 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();
Expand Down Expand Up @@ -397,20 +393,17 @@ dto::FaultDetail FaultHandlers::build_sovd_fault_response(const json & fault_jso

http::Result<dto::FaultListResult> FaultHandlers::list_all_faults(const http::TypedRequest & req) {
try {
auto filter_result = read_fault_status_filter(req);
const auto q = req.query<dto::FaultListQuery>();
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}}));
Expand Down Expand Up @@ -517,7 +510,8 @@ http::Result<dto::FaultListResult> 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<dto::FaultListQuery>();
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());
}
Expand Down Expand Up @@ -1015,7 +1009,8 @@ http::Result<http::NoContent> FaultHandlers::clear_all_faults(const http::TypedR
http::Result<std::pair<http::NoContent, http::ResponseAttachments>>
FaultHandlers::clear_all_faults_global(const http::TypedRequest & req) {
try {
auto filter_result = read_fault_status_filter(req);
const auto q = req.query<dto::FaultClearQuery>();
auto filter_result = read_fault_status_filter(q.status);
if (!filter_result) {
return tl::make_unexpected(filter_result.error());
}
Expand Down
Loading
Loading