Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ repos:
language: script
pass_filenames: false
always_run: true
- id: gateway-plugin-header-purity
name: plugin-facing headers must not depend on httplib
entry: ./src/ros2_medkit_gateway/scripts/check_headers_httplib_free.sh
language: script
stages: [pre-push]
pass_filenames: false
always_run: true

# ── Incremental clang-tidy (pre-push only) ────────────────────────
# Requires: pre-commit install --hook-type pre-push
Expand Down
76 changes: 76 additions & 0 deletions scripts/check_isolated_build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/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.

# Reproduce the ROS build-farm topology locally on Jazzy: build + install the
# gateway into a throwaway prefix, then build each plugin LIBRARY against ONLY
# the installed gateway with httplib genuinely WITHHELD (a poisoned shadow
# <httplib.h> injected via -isystem ahead of /usr/include). A leaked <httplib.h>
# in a plugin-facing public header (masked in normal CI by the colcon overlay +
# system libcpp-httplib-dev) trips the poison's #error here, exactly like the
# build farm / Docker Publish. The leak is distro-independent, so Jazzy alone
# catches this class. Run before pushing changes to gateway public headers or
# plugins.
set -euo pipefail

DISTRO="${ROS_DISTRO:-jazzy}"
# The ament setup scripts reference optional env vars, so relax nounset while
# sourcing (otherwise `set -u` aborts on AMENT_TRACE_SETUP_FILES et al.).
set +u
# shellcheck disable=SC1090
source "/opt/ros/${DISTRO}/setup.bash"
set -u

# Cap build parallelism. The gateway has many heavy TUs (PCH + httplib +
# nlohmann + jwt); compiling all of them at once on a high-core / low-memory
# host (e.g. a CI container) can exhaust RAM and get OOM-killed. Build one
# package at a time (--parallel-workers 1) and cap per-package compile jobs.
# Override MEDKIT_ISO_JOBS for faster builds on high-memory hosts.
NPROC="$(nproc)"
MEDKIT_ISO_JOBS="${MEDKIT_ISO_JOBS:-$(( NPROC < 6 ? NPROC : 6 ))}"
export MAKEFLAGS="-j${MEDKIT_ISO_JOBS}"
export CMAKE_BUILD_PARALLEL_LEVEL="${MEDKIT_ISO_JOBS}"

ISO="$(mktemp -d /tmp/medkit_iso.XXXXXX)"
POISON="$(mktemp -d /tmp/medkit_poison.XXXXXX)"
trap 'rm -rf "${ISO}" "${POISON}"' EXIT

# A shadow httplib.h that fails loudly if any plugin TU includes <httplib.h>.
cat > "${POISON}/httplib.h" <<'POISONEOF'
#error "httplib.h leaked into a plugin build: a plugin-facing gateway public header transitively includes <httplib.h>. See ros2_medkit_gateway/http/handler_result.hpp."
POISONEOF

echo "==> Stage 1: build + install gateway into ${ISO} (httplib available here)"
colcon build --parallel-workers 1 --build-base "${ISO}/build" --install-base "${ISO}/install" \
--packages-up-to ros2_medkit_gateway \
--cmake-args -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF

# Stage 2 sources ONLY the isolated gateway install (NOT the dev overlay
# install/, which would re-expose the gateway build-tree httplib include path).
# Each plugin overlays into the SAME isolated prefix so it resolves the gateway
# and ros2_medkit_cmake from the install, never from the source overlay.
set +u
# shellcheck disable=SC1091
source "${ISO}/install/setup.bash"
set -u

for plugin in ros2_medkit_graph_provider ros2_medkit_sovd_service_interface ros2_medkit_opcua; do
echo "==> Stage 2: isolated lib build ${plugin} (httplib poisoned)"
colcon build --parallel-workers 1 --build-base "${ISO}/build" --install-base "${ISO}/install" \
--packages-select "${plugin}" \
--cmake-args -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF \
"-DCMAKE_CXX_FLAGS=-isystem ${POISON}"
done

echo "OK: all plugin libraries build against the installed gateway with httplib withheld."
7 changes: 7 additions & 0 deletions src/ros2_medkit_gateway/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,13 @@ if(BUILD_TESTING)
)
set_tests_properties(gateway_core_purity PROPERTIES LABELS "linter")

add_test(
NAME gateway_plugin_header_purity
COMMAND bash "${CMAKE_CURRENT_SOURCE_DIR}/scripts/check_headers_httplib_free.sh"
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
)
set_tests_properties(gateway_plugin_header_purity PROPERTIES LABELS "linter")

# ─── gateway_core link-time smoke test ────────────────────────────────────
# Compiles a translation unit including a sampling of core/ headers and
# links exclusively against gateway_core + GTest. No ament_target_dependencies
Expand Down
4 changes: 4 additions & 0 deletions src/ros2_medkit_gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1472,6 +1472,10 @@ The `gateway_node` executable and existing test targets link `gateway_ros2`, so
- `gateway_core_purity` (linter label) - greps `core/` for any ROS-package include and fails on any match.
- `test_gateway_core_smoke` (unit label) - compiles a translation unit that includes a sampling of `core/` headers and links exclusively against `gateway_core` + GTest with no `ament_target_dependencies`. Build failure indicates a transitive ROS coupling that the grep guard might miss.

A third purity gate keeps cpp-httplib out of the plugin ABI. Plugin-facing public headers - the provider interfaces, the `GatewayPlugin` base headers, and the DTOs they include - must not depend on `<httplib.h>`. cpp-httplib is a gateway-internal detail; plugins exchange `nlohmann::json`, typed `dto::` structs, `tl::expected`, and the opaque `PluginRequest`/`PluginResponse` shim across the `.so` boundary, so the gateway and its plugins need not share an httplib version. The httplib-free handler-result vocabulary (`Result`, `NoContent`, `Forwarded`, `ValidatorResult`, `ResponseAttachments`) lives in `http/handler_result.hpp`; only `http/typed_router.hpp` and the handler-internal headers touch httplib. The invariant is enforced by:

- `gateway_plugin_header_purity` (linter label) - runs `scripts/check_headers_httplib_free.sh`, a preprocessor-only scan (`g++ -M -MG`) over the plugin-facing surface that fails on any transitive `httplib.h` dependency. Also wired into the pre-push hook. The build-farm topology (installed gateway, no vendored httplib on the include path) is reproduced locally by `scripts/check_isolated_build.sh`.

### Components

- **Gateway Node**: Main ROS 2 node that runs the REST server
Expand Down
33 changes: 33 additions & 0 deletions src/ros2_medkit_gateway/design/dto_contract.rst
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,39 @@ interface directly. Out-of-tree plugins that previously returned raw
``nlohmann::json`` must wrap their response in the matching envelope type;
the conversion is mechanical (``Result.content = std::move(json_payload)``).

Header Purity: No httplib Across the Plugin Boundary
----------------------------------------------------

Plugin-facing public headers - the provider interfaces
(``core/providers/*.hpp``), the plugin base headers a ``GatewayPlugin``
subclass includes (``core/plugins/gateway_plugin.hpp``,
``plugin_context.hpp``, ``plugin_http_types.hpp``,
``plugins/ros_plugin_context.hpp``), and every DTO header they pull in -
MUST NOT depend on ``<httplib.h>``. cpp-httplib is a gateway-internal
implementation detail. Across the ``.so`` boundary plugins exchange only
``nlohmann::json``, typed ``dto::`` structs, ``tl::expected``, and the opaque
``PluginRequest`` / ``PluginResponse`` shim. Because no httplib type ever
crosses that boundary, the gateway and its plugins do not need to share an
httplib version: a plugin built against the installed gateway (the ROS
build-farm / Docker topology, where the gateway's vendored httplib is not on
the include path) still compiles.

The httplib-free handler-result vocabulary - ``Result``, ``NoContent``,
``Forwarded``, ``ValidatorResult``, ``ResponseAttachments`` - lives in
``http/handler_result.hpp``. Only ``http/typed_router.hpp`` (which owns
``TypedRequest`` and the raw-response escape hatch) and the handler-internal
headers downstream of it touch ``<httplib.h>``; ``typed_router.hpp``
re-exports the ``handler_result.hpp`` vocabulary so existing includers keep
working without pulling httplib transitively.

The invariant is enforced by the ``gateway_plugin_header_purity`` ctest
(``scripts/check_headers_httplib_free.sh``, ``linter`` label), which runs a
preprocessor-only scan (``g++ -M -MG``) over the plugin-facing surface and
fails on any transitive ``httplib.h`` dependency, and by the pre-push hook of
the same name. The build-farm topology (installed gateway, no vendored
httplib on the include path) is reproduced locally by
``scripts/check_isolated_build.sh``.

Fan-Out Observability
---------------------

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

#pragma once

#include "ros2_medkit_gateway/http/typed_router.hpp"
#include "ros2_medkit_gateway/http/handler_result.hpp"

namespace ros2_medkit_gateway {
namespace http {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// 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

// httplib-free handler-result vocabulary. Split out of typed_router.hpp so that
// the DTO/provider header chain (operation_provider.hpp -> dto/operations.hpp ->
// alternate_status.hpp) does not transitively pull <httplib.h> into downstream
// plugins. Only TypedRequest (in typed_router.hpp) depends on cpp-httplib.

#include <optional>
#include <string>
#include <tl/expected.hpp>
#include <type_traits>
#include <utility>
#include <variant>
#include <vector>

#include "ros2_medkit_gateway/core/models/error_info.hpp"

namespace ros2_medkit_gateway {
namespace http {

/// Handler return type. The success branch carries the DTO (or marker like
/// NoContent) the handler wants to send; the error branch carries a fully
/// formed ErrorInfo the framework can serialize as a SOVD GenericError.
template <class T>
using Result = tl::expected<T, ErrorInfo>;

/// Empty success marker used by handlers that complete with no body (typically
/// DELETE -> 204 No Content). Distinct from `Result<void>` so that the
/// framework can statically dispatch the response writer.
struct NoContent {};

/// Sentinel returned by validators when the entity belongs to a remote peer
/// and the validator has already committed the wire response by proxying the
/// request to that peer. Returning Forwarded tells the caller "the response
/// has been written, do not write anything else".
struct Forwarded {};

namespace detail {
/// Variant ordering invariant: ErrorInfo must be the first alternative so a
/// default-constructed `std::variant<ErrorInfo, Forwarded>` (the error state
/// callers may inadvertently produce) denotes an "unknown error", not the
/// misleading "request already proxied" Forwarded state. If a future refactor
/// reorders the alternatives, this static_assert breaks compilation.
template <class T>
inline constexpr bool kValidatorVariantOrderingOk =
std::is_same_v<std::variant_alternative_t<0, std::variant<ErrorInfo, Forwarded>>, ErrorInfo>;
static_assert(kValidatorVariantOrderingOk<void>, "ErrorInfo must be the first variant alternative");
} // namespace detail

/// Return type for HandlerContext::validate_entity_for_route and similar
/// validators that may either succeed locally, fail with an ErrorInfo, or
/// short-circuit by proxying the request to a remote peer (Forwarded).
template <class T>
using ValidatorResult = tl::expected<T, std::variant<ErrorInfo, Forwarded>>;

/// Side-channel a handler can attach to its successful response when the
/// default "200 OK + DTO body" is not enough. Examples:
/// - 201 Created with a `Location` header for POST creating a resource.
/// - 202 Accepted for asynchronously processed requests.
/// - 207 Multi-Status for aggregated fan-out responses.
/// - Vendor headers such as `X-Medkit-Local-Only`.
struct ResponseAttachments {
/// HTTP status to use instead of the default 200. Leave empty for 200.
std::optional<int> status_override;
/// Additional headers to append. Order is preserved; duplicate names are
/// allowed (cpp-httplib delivers them as separate Set-Cookie-style headers).
std::vector<std::pair<std::string, std::string>> headers;

/// Fluent setter for the status override.
ResponseAttachments & with_status(int status) {
status_override = status;
return *this;
}

/// Fluent setter that appends a header entry.
ResponseAttachments & with_header(std::string name, std::string value) {
headers.emplace_back(std::move(name), std::move(value));
return *this;
}
};

} // namespace http
} // namespace ros2_medkit_gateway
Original file line number Diff line number Diff line change
Expand Up @@ -19,79 +19,17 @@
#include <optional>
#include <string>
#include <string_view>
#include <tl/expected.hpp>
#include <type_traits>
#include <utility>
#include <variant>
#include <vector>

#include "ros2_medkit_gateway/core/http/error_codes.hpp"
#include "ros2_medkit_gateway/core/models/error_info.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
// the DTO/provider chain includes WITHOUT pulling <httplib.h>.
#include "ros2_medkit_gateway/http/handler_result.hpp"

namespace ros2_medkit_gateway {
namespace http {

/// Handler return type. The success branch carries the DTO (or marker like
/// NoContent) the handler wants to send; the error branch carries a fully
/// formed ErrorInfo the framework can serialize as a SOVD GenericError.
template <class T>
using Result = tl::expected<T, ErrorInfo>;

/// Empty success marker used by handlers that complete with no body (typically
/// DELETE -> 204 No Content). Distinct from `Result<void>` so that the
/// framework can statically dispatch the response writer.
struct NoContent {};

/// Sentinel returned by validators when the entity belongs to a remote peer
/// and the validator has already committed the wire response by proxying the
/// request to that peer. Returning Forwarded tells the caller "the response
/// has been written, do not write anything else".
struct Forwarded {};

namespace detail {
/// Variant ordering invariant: ErrorInfo must be the first alternative so a
/// default-constructed `std::variant<ErrorInfo, Forwarded>` (the error state
/// callers may inadvertently produce) denotes an "unknown error", not the
/// misleading "request already proxied" Forwarded state. If a future refactor
/// reorders the alternatives, this static_assert breaks compilation.
template <class T>
inline constexpr bool kValidatorVariantOrderingOk =
std::is_same_v<std::variant_alternative_t<0, std::variant<ErrorInfo, Forwarded>>, ErrorInfo>;
static_assert(kValidatorVariantOrderingOk<void>, "ErrorInfo must be the first variant alternative");
} // namespace detail

/// Return type for HandlerContext::validate_entity_for_route and similar
/// validators that may either succeed locally, fail with an ErrorInfo, or
/// short-circuit by proxying the request to a remote peer (Forwarded).
template <class T>
using ValidatorResult = tl::expected<T, std::variant<ErrorInfo, Forwarded>>;

/// Side-channel a handler can attach to its successful response when the
/// default "200 OK + DTO body" is not enough. Examples:
/// - 201 Created with a `Location` header for POST creating a resource.
/// - 202 Accepted for asynchronously processed requests.
/// - 207 Multi-Status for aggregated fan-out responses.
/// - Vendor headers such as `X-Medkit-Local-Only`.
struct ResponseAttachments {
/// HTTP status to use instead of the default 200. Leave empty for 200.
std::optional<int> status_override;
/// Additional headers to append. Order is preserved; duplicate names are
/// allowed (cpp-httplib delivers them as separate Set-Cookie-style headers).
std::vector<std::pair<std::string, std::string>> headers;

/// Fluent setter for the status override.
ResponseAttachments & with_status(int status) {
status_override = status;
return *this;
}

/// Fluent setter that appends a header entry.
ResponseAttachments & with_header(std::string name, std::string value) {
headers.emplace_back(std::move(name), std::move(value));
return *this;
}
};

/// Thin, type-safe wrapper around `const httplib::Request &` so handlers do
/// not see raw cpp-httplib types. A future commit will extend the constructor
/// with a RouteEntry parameter to support named path-parameter lookup; for now
Expand Down
Loading
Loading