DTO Contract Layer
This document describes the typed DTO contract layer of 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, the typed router that consumes the contract, the named escape hatches for non-DTO routes, the typed-only Provider ABI, and the workflow for adding new endpoints.
Overview
Before this layer existed, a handler in the gateway had three independent artefacts that described the same wire payload:
Hand-written
nlohmann::jsonconstruction in the handler body.A
SchemaBuilder::*_schema()factory that produced the matching OpenAPI JSON Schema object.An
XMedkitfluent builder that assembled thex-medkitvendor 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.
DTO Contract Layer - Component Relationships
Field Descriptor (Field<C, M>)
Each field in a DTO is described by a Field<Class, Member> aggregate
defined in contract.hpp:
template <class Class, class Member>
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:
// 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<T> - the Descriptor Tuple
The descriptor tuple for a type T is a constexpr specialization of
the variable template dto_fields<T>:
template <>
inline constexpr auto dto_fields<FaultListItem> = 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 pointer (detail::not_a_dto<T>*) so
the type is identifiable for is_dto_v checks without forcing
instantiation of not_a_dto<T> at every probe. The is_dto_v<T> trait
returns true only when a specialization exists, which gates all three
visitors at compile time.
Placement rule: every dto_fields<X> and dto_name<X>
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<T> - Schema Registry Key
Each DTO names itself in components/schemas via a constexpr string_view
specialization:
template <>
inline constexpr std::string_view dto_name<FaultListItem> = "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<T> using for_each_field<T>(),
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<T, std::vector<FieldError>> 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.
Request Lifecycle through the Typed Router
AllDtos Registry (registry.hpp)
AllDtos is a single std::tuple listing every named DTO type:
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<T>::schema() for each type. SchemaBuilder::component_schemas()
(in src/openapi/schema_builder.cpp) returns exactly this map: every entry in
components/schemas is generated from AllDtos, with no hand-written
survivors merged in. No runtime loop over a dynamic registry is required.
The hand-written schema factories that remain on SchemaBuilder -
from_ros_msg / from_ros_srv_request / from_ros_srv_response (for
dynamic ROS 2 payloads whose field names are not known at compile time) and
binary_schema / generic_object_schema - are no longer part of the
components/schemas map. They are called by the path builder
(src/openapi/path_builder.cpp) to emit inline operation schemas for the
per-topic / per-service / per-action routes, whose request and response shape is
derived from the live ROS 2 type rather than from a named DTO.
The Collection<T> 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.
Typed Router
Every built-in route is registered through RouteRegistry using a typed
overload that names its DTOs in the template parameter list. The framework
owns request decoding, response writing, and status-code dispatch; handlers
never touch httplib::Response.
Handler Signatures
A typed handler returns http::Result<TResponse> (which is
tl::expected<TResponse, ErrorInfo>) and receives a TypedRequest
plus, on POST / PUT / PATCH overloads, an already-parsed TBody:
// GET /entity/{id}/resource -> 200 + JSON body
reg.get<dto::MyResponse>(
"/entity/{id}/resource",
[this](http::TypedRequest req) -> http::Result<dto::MyResponse> {
// ... build dto::MyResponse or return tl::make_unexpected(err) ...
})
.tag("MyTag")
.summary("...")
.operation_id("getMyResource");
// POST /entity/{id}/resource with parsed body -> 200 + JSON body
reg.post<dto::MyCreateRequest, dto::MyResponse>(
"/entity/{id}/resource",
[this](http::TypedRequest req, dto::MyCreateRequest body)
-> http::Result<dto::MyResponse> {
// ... return result ...
});
When a handler needs to override the success status (201 + Location, 204 +
custom header, …) the pair-returning overload makes the framework apply
ResponseAttachments after the body is written:
reg.post<dto::Req, dto::Resp>(
"/...",
[](http::TypedRequest, dto::Req)
-> http::Result<std::pair<dto::Resp, http::ResponseAttachments>> {
dto::Resp r;
http::ResponseAttachments att;
att.status_override = 201;
att.headers.emplace_back("Location", "/resources/123");
return std::make_pair(std::move(r), std::move(att));
});
The framework writes the response body via JsonWriter<TResponse>, applies
the attachments, and renders any error branch via the route’s configured
ErrorRenderer (kSovdGenericError by default; the /auth/* routes
opt into kOAuth2Error to emit the RFC 6749 wire shape).
Type-System Guarantees
Each typed overload carries a static_assert(dto::has_dto_shape_v<T>)
gate, so any non-DTO type passed as TResponse or TBody rejects at
compile time with a contract-aware diagnostic. has_dto_shape_v<T> is true
when either is_dto_v<T> (a regular field-walking DTO) or
is_opaque_dto_v<T> (a hand-written opaque DTO envelope) is true; the
NoContent marker is the third accepted shape and triggers a 204
empty-body branch in write_success_body.
The OpenAPI schema slot for every typed route is wired automatically from
TResponse and TBody (and from the alternates in
post_alternates<TBody, TAlt...> / del_alternates<TAlt...>). The
registry calls RouteEntry::response<T>(200, "") /
RouteEntry::request_body<TB>("") so the wire JSON and the published
schema cannot drift: the same C++ type names both. Hand-attached
.response(...) / .request_body(...) calls are reserved for non-200
status documentation (404 / 409 / …) and for the rare body-less typed
post / put overloads that parse non-JSON bodies (form-urlencoded
auth) and need an explicit OpenAPI request_body annotation.
Escape Hatches
Not every payload can be expressed as a typed DTO. Two orthogonal categories
of escape hatches exist: in-body dynamic payloads (opaque_object fields
inside a DTO, see below) and dedicated non-DTO route helpers.
Named Route Escape Hatches
The typed RouteRegistry exposes a closed set of named escape hatches for
routes whose wire shape is not JSON-and-only-JSON. Each helper produces a
typed handler with a purpose-built response type, so even non-DTO routes
remain compile-time-checked at their boundary.
reg.sse(path, factory)- registers a Server-Sent Events route. The factory returns aResult<http::SseStream>whosenext_eventcallback the framework drives via cpp-httplib’s chunked content provider. Used by the fault SSE stream and by cyclic-subscription event streams.reg.binary_download(path, handler)- registers a range-aware binary download. The handler returns aResult<http::BinaryResponse>carryingprovider,content_type,filename,supports_ranges, andtotal_size; the framework wiresproviderinto cpp-httplib’s range-aware content-provider machinery so partial-content fetches work without manualContent-Rangeplumbing.reg.multipart_upload<TResponse>(path, handler)- registers amultipart/form-dataupload. The handler receiveshttp::MultipartBody(already parsed by cpp-httplib) and returnsResult<std::pair<TResponse, http::ResponseAttachments>>so it can pin 201 +Locationon successful uploads. Used by bulk-data POST/PUT.reg.static_asset(path, handler)- serves bytes already in memory (Swagger UI bundles, embedded HTML/JS/CSS) asResult<http::StaticAsset>carryingbytes,content_type, and per-responseheaders(Cache-Control,ETag).reg.docs_endpoint(path, handler)- registers the OpenAPI JSON endpoint at the given path. The handler returnsResult<nlohmann::json>; this is the only built-in route allowed to use rawnlohmann::jsonasTResponse, because the body is the spec itself.reg.docs_subtree(regex, handler)- catch-all for the Swagger UI subtree (asset paths without a fixed shape). Hidden from the OpenAPI output so it does not pollute the generated spec.reg.post_alternates<TBody, TAlt...>(path, handler)/reg.del_alternates<TAlt...>(path, handler)- register multi-shape responses. The active variant alternative is dispatched to itsdto_alternate_status<T>::value(default 200; specialize per type, for exampleAccepted-> 202,NoContent-> 204). The published spec lists every alternative under its own status code, and the wire status is picked by the active alternative at call time.
Plugin-Owned Routes (PluginContext::register_route())
Routes contributed by GatewayPlugin subclasses bypass the typed router
entirely and run a void(PluginRequest, PluginResponse) handler that the
plugin owns. PluginResponse is a thin shim over a cpp-httplib response
whose send_json / send_error methods route through the same internal
http::detail::write_json_body primitive used by the typed router, so
plugin responses remain wire-format-identical to built-in responses (same
SOVD GenericError shape, same Content-Type handling). The plugin
ABI is locked by test_plugin_abi_conformance; nothing here changes for
out-of-tree plugins.
Opaque Object Policy
Some DTO fields carry an entire JSON object whose internal shape is decided
at runtime by an upstream component the gateway cannot introspect at compile
time. The opaque_object("key", &T::field) descriptor in
dto/contract.hpp binds such a field to a nlohmann::json member:
JsonWriter writes the member as-is (no introspection, no schema check).
JsonReader accepts any JSON object value, rejects scalars / arrays / null with a
FieldError; an absent field leaves the member at its default (empty object).SchemaWriter emits
{type: object, additionalProperties: true, x-medkit-opaque: true}and marks the field required (opaque fields are not wrapped instd::optional).
Use opaque_object for fields whose runtime shape depends on context, not
for fields the gateway could describe but chose not to. Concrete cases in
the codebase:
Live ROS 2 message payloads - topic samples returned by data handlers carry whatever fields the actual message type declares at runtime.
Plugin-defined fault envelopes -
FaultListResult/FaultDetailResult/FaultClearResultreturned by the typedFaultProviderABI wrap acontentopaque object so UDS, OPC-UA, and vendor backends can each emit their own per-item shape.Action results / service responses -
Operation*execution payloads whose field set is determined by the ROS 2 service / action type bound to the operation, not by the gateway.OpenAPI spec body - the
/docsendpoint returns the spec itself, declared viareg.docs_endpoint(path, Result<json>(...))(a route-level escape hatch, not an opaque DTO field).
Fields backed by std::optional<nlohmann::json> rather than
opaque_object (notably extended_data_records / snapshots on
FaultEnvironmentData) follow the same rule: pass the JSON through
verbatim because the fault reporter plugin owns the shape. The opaque DTO
marker (is_opaque_dto_v<T> = true) plays the analogous role at the
envelope level: it tells the framework “this whole DTO has a hand-written
JsonWriter / JsonReader / SchemaWriter trio because its shape is opaque”,
which is what the typed Provider envelopes use.
Provider ABI: Typed-Only Policy
Per-entity provider interfaces (FaultProvider, DataProvider,
OperationProvider) and the singleton UpdateProvider all return typed
DTOs. None of them return raw tl::expected<nlohmann::json, ErrorInfo>
any more.
The typed envelopes - FaultListResult, FaultDetailResult,
FaultClearResult, the corresponding Data*Result and Operation*Result
shapes, and UpdateProvider::get_update’s typed return - wrap an opaque
content payload so the wire bytes are byte-identical to the pre-typed
ABI: JsonWriter emits the content object verbatim, SchemaWriter
publishes x-medkit-opaque: true, and JsonReader accepts any JSON object
on round-trip. This keeps backend-specific shapes (UDS DTC records, OPC-UA
alarm metadata, vendor extensions) flowing through unchanged while pinning
the envelope itself to a single typed contract.
Commercial plugins (UDS, OPC-UA, Uptane OTA, …) implement the typed
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
Aggregating collection routes call http::fan_out_collection<T>(agg, req)
to query peer gateways and merge their items arrays. The helper returns
a typed FanOutResult<T>:
template <class T>
struct FanOutResult {
std::vector<T> items; // parsed peer items
bool partial{false}; // at least one peer failed
std::vector<std::string> failed_peers;
std::vector<dto::DroppedItem> dropped_items; // items that failed JsonReader<T>
};
Each peer item is decoded via dto::JsonReader<T>. Items that fail
validation are removed from items and recorded in dropped_items with
the JsonReader error message plus a best-effort source_id extracted from
the item’s id / name / fault_id / data_id / operation_id
field. A WARN log fires for each drop, naming dto_name<T> and the
reason. The peer URL on each DroppedItem is left empty in this commit
because AggregationManager::fan_out_get coalesces all peer responses
into a single merged array without per-item provenance; per-peer attribution
is left for a future enrichment of the aggregation manager.
Handlers surface drops on the wire via the peer_dropped_items field on
every collection-level x-medkit DTO (XMedkitCollection,
FaultListXMedkit, FaultListAggXMedkit, DataListXMedkit,
LogListXMedkit, …). Previously, malformed peer items disappeared
silently into the merged items array; now they show up in
x-medkit.peer_dropped_items so clients (and fleet operators) can detect
drift between heterogeneous gateways. The legacy merge_peer_items helper
(raw-JSON mutation) is still in use on routes whose merged items are
dynamic-shaped (the fault aggregation routes, GET /health), where the
items are not addressable by a single T for JsonReader<T>; typed
collection routes (data, operations, config, logs) call
fan_out_collection<T> directly.
OpenAPI Generation Pipeline
The published openapi.json is assembled mechanically from two sources:
components/schemasis exactlycollect_component_schemas<AllDtos>()- one entry per DTO listed indto/registry.hpp, with no hand-written survivors merged in.pathsisRouteRegistry::to_openapi_paths(): every typed route contributes a path item with$refentries auto-derived from itsTResponse/TBodytemplate parameters plus any tags, summary, description,operation_id, parameter, or extra-status metadata pinned on the route via the fluentRouteEntrybuilder. The per-topic / per-service / per-action routes for genuinely dynamic ROS 2 payloads carry an inline schema built bySchemaBuilder’sfrom_ros_msg/from_ros_srv_request/from_ros_srv_response/binary_schema/generic_object_schemafactories (these feed path operations, notcomponents/schemas).
OpenApiSpecBuilder::build() then assembles info / servers /
tags / security around those two compiled blocks. There are no
hand-written paths items in the published spec, and no hand-written schema
blocks in components/schemas. Adding a route or a DTO field updates the
spec on the next process start with no schema-side edit.
Optional fields are now emitted as anyOf: [<inner>, {type: "null"}]
(OpenAPI 3.1 idiom) so generated clients see T | null rather than
T | undefined. That matches the wire reality of the gateway: optional
fields are either present-with-value or absent, never explicit null;
but the schema also accepts null so clients that prefer to emit a
nullable value on the wire round-trip cleanly through JsonReader.
Adding a New DTO
Follow these four steps when introducing a new typed payload:
Define the struct and its descriptor in the appropriate domain header under
include/ros2_medkit_gateway/dto/. Add adto_fields<T>specialization and adto_name<T>specialization in the same header.// In dto/my_domain.hpp struct MyResponse { std::string id; std::optional<std::string> label; int64_t count{0}; }; template <> inline constexpr auto dto_fields<MyResponse> = std::make_tuple( field("id", &MyResponse::id), field("label", &MyResponse::label), field("count", &MyResponse::count)); template <> inline constexpr std::string_view dto_name<MyResponse> = "MyResponse";
Register in AllDtos by adding
MyResponseto the tuple ininclude/ros2_medkit_gateway/dto/registry.hpp. Also add the include fordto/my_domain.hppat the top ofregistry.hpp.Use in the handler. Handlers never touch
httplib::Response- they returnhttp::Result<TResponse>and the framework writes the body. Entity validation is also typed:validate_entity_for_routereturnshttp::ValidatorResult<EntityInfo>; the helperflatten_validator_errorcollapses the local-error andForwardedbranches into a singleErrorInfo(theForwardedbranch becomes the framework-internal sentinel that the RouteRegistry wrapper recognises and skips error rendering for):// GET handler - typed response http::Result<dto::MyResponse> MyHandlers::handle_get(http::TypedRequest req) { auto entity = ctx_.validate_entity_for_route(req, req.path_param(0)); if (!entity) { return tl::make_unexpected(flatten_validator_error(entity.error())); } dto::MyResponse resp; resp.id = entity->id; resp.label = "example"; resp.count = 42; return resp; } // POST handler - typed request body (parsed by the framework before // the handler runs; the handler receives an already-validated TBody). http::Result<dto::MyResponse> MyHandlers::handle_post( http::TypedRequest req, dto::MyCreateRequest body) { // use body.field_name directly dto::MyResponse resp; // ... build response ... return resp; }
Register the route via the typed RouteRegistry. Because
MyResponseis now inAllDtos,collect_component_schemas()automatically includes its schema in the/docsresponse, and the typed overload wires the$refinto the path item:For built-in gateway routes, register in
rest_server.cpp::setup_routes()viareg.get<T>/reg.post<TBody, T>/ etc. The framework derives theresponse<T>(200, "")andrequest_body<TBody>("")slots from the template parameters; the call site only adds tags, summary, extra status codes, andoperation_id:reg.get<dto::MyResponse>( "/my-entity/{id}/my-resource", [this](http::TypedRequest req) -> http::Result<dto::MyResponse> { /* handler */ }) .tag("MyTag") .summary("Get my resource") .operation_id("getMyResource") .response(404, "Resource not found"); // extra non-200 status
For plugin-contributed routes, use the
RouteDescriptionBuilderAPI incore/openapi/route_descriptions.hpp. Plugin routes do not go through the typed registry (see Plugin-Owned Routes above), so the schema wire-up is explicit.
Adding a New Endpoint (Full Checklist)
A new endpoint with a typed payload follows the standard gateway handler checklist plus the DTO steps above:
Define DTO struct +
dto_fields+dto_namein a domain header.Add to
AllDtosinregistry.hpp.Implement handler in
src/http/handlers/as a typed function returninghttp::Result<TResponse>.Register route in
rest_server.cpp::setup_routes()viareg.get<T>/reg.post<TBody, T>/reg.del<T>/ the matching alternates or escape-hatch helper. Use the dual-path pattern for entity types that share the same route shape.Update
handle_rootendpoint list inhealth_handlers.cppto mirror the new route.Add URI field to entity detail response if the new route is a resource collection.
Write a unit test using
JsonWriter<T>::write()andJsonReader<T>::read()directly - no HTTP server needed.Write an integration test that calls the live endpoint.
Collection<T, XMedkitT> Parametrisation
The generic Collection<T, XMedkitT> list wrapper is parameterised over
both the item type and the collection-level x-medkit shape. Entity list
endpoints (areas, components, apps, functions) use the default
XMedkitCollection x-medkit; the domain collection endpoints specialise
XMedkitT to their richer per-domain shape (FaultListXMedkit,
FaultListAggXMedkit, ConfigListXMedkit, DataListXMedkit,
LogListXMedkit). For the config and log list routes the published schema
references the actual collection x-medkit struct directly, so generated clients
see the exact aggregation, peer-provenance, and peer_dropped_items fields
that appear on the wire.
The fault and data list routes are the exception: they publish the opaque
FaultListResult / DataListResult envelopes rather than the typed
Collection<...> schema, because plugin-owned entities can return
vendor-specific per-item shapes that the typed item schema cannot describe.
The data list handler still builds a typed
Collection<DataItem, DataListXMedkit> for runtime (ROS 2) entities and
serializes it into the envelope (so the wire shape - including
peer_dropped_items - is unchanged), but the plugin branch passes the
provider’s free-form payload through verbatim. See “Opaque Object Policy” and
the “Provider ABI” section above.
Key Files
include/ros2_medkit_gateway/dto/contract.hppField,dto_fields,dto_name,is_dto_v,for_each_field- the contract primitives.include/ros2_medkit_gateway/dto/json_writer.hppJsonWriter<T>- struct to JSON serialization.include/ros2_medkit_gateway/dto/schema_writer.hppSchemaWriter<T>andschema_of<U>- type to OpenAPI schema.include/ros2_medkit_gateway/dto/json_reader.hppJsonReader<T>andFieldError- JSON to struct with validation.include/ros2_medkit_gateway/dto/registry.hppAllDtostuple andcollect_component_schemas().src/openapi/route_registry.hppRouteRegistrytyped overloads (get<T>/post<TBody, T>/del<T>/ alternates) and named escape hatches (sse/binary_download/multipart_upload<T>/static_asset/docs_endpoint/docs_subtree), plus the wrapper-closure template implementations.include/ros2_medkit_gateway/http/response_types.hppSseStream,BinaryResponse,MultipartBody,StaticAsset- the typed response shapes consumed by the named escape hatches.include/ros2_medkit_gateway/http/handlers/handler_context.hppHandlerContext::validate_entity_for_routeand the typed validator surface (ValidatorResult<T>,flatten_validator_error).- 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-locateddto_fieldsanddto_namespecializations.dto/errors.hppholdsGenericError, the error response DTO used by every endpoint.dto/enums.hppEnum-vocabulary header. Contains the
constexpr string_viewarrays (kFaultSeverityLabelValues,kOperationExecutionStatusValues, etc.) referenced byfield_enum()descriptors in the domain headers. Does not define any DTO structs.