feat(gateway): typed DTO contract for HTTP responses and OpenAPI schema#403
feat(gateway): typed DTO contract for HTTP responses and OpenAPI schema#403bburda wants to merge 82 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Introduces a typed DTO contract for the gateway's HTTP responses so that wire JSON, OpenAPI schemas, and x-medkit extensions are all generated from a single C++17 source-of-truth, eliminating drift between handler output and the published spec. All HTTP handler domains were migrated, the legacy XMedkit fluent builder and ~50 hand-written schema factories were removed, and integration tests now validate live responses against the served spec.
Changes:
- Add Draft202012/Draft7 jsonschema validator import with Humble fallback and new skip patterns in
test_openapi_callability.test.pyfor schema limitations (Configuration/Trigger/CyclicSubscription). - Add new integration test methods to assert typed
x-medkitsub-schemas (XMedkitArea,XMedkitRos2,AreaListItem) are present and that live entity GET responses conform to the served OpenAPI spec (with $ref-inlining helper). - Delete
test_x_medkit.cppsince theXMedkitfluent builder is replaced by the DTO/visitor contract.
Reviewed changes
Copilot reviewed 70 out of 70 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| src/ros2_medkit_integration_tests/test/features/test_openapi_callability.test.py | Adds DTO/schema presence checks, live-response-vs-spec conformance test, and skip patterns for known schema-precision gaps. |
| src/ros2_medkit_gateway/test/test_x_medkit.cpp | Removed; superseded by the new DTO contract test suite (test_dto_contract). |
Note: the PR description states the change touches all handler domains, OpenAPI builder, and removes ~50 schema factories, but only two files are visible in this diff slice. The bulk of the migration (handlers, DTO/visitor framework, OpenAPI builder rewiring, test_dto_contract) is outside the reviewed hunks and cannot be assessed here.
Name the unnamed std::index_sequence tag parameters in variant_schema() and collect_impl() to satisfy readability-named-parameter. These are the only two clang-tidy findings in the new dto/ headers.
Adds entities.hpp with 8 entity DTOs (list-item + detail pairs for Area, Component, App, Function), the Collection<T> wrapper with per-instantiation dto_name specializations, and populates AllDtos in registry.hpp with all entity, x-medkit, error, and collection DTOs. Extends test_dto_contract.cpp with EveryRegisteredDtoRoundTrips, which exercises every registered DTO through SchemaWriter, JsonWriter, and JsonReader.
Include ros2_medkit_gateway/dto/registry.hpp and convert the static-const map in SchemaBuilder::component_schemas() to an IIFE so the DTO-generated schemas can be merged in at initialisation time. The DTO version wins on name collisions (currently only GenericError); hand-written factory calls are preserved intact so existing $ref targets remain valid until the per-domain migration tasks remove them. Store dto::collect_component_schemas() in a named local variable before iterating - calling .items() directly on the returned temporary creates a dangling reference inside the nlohmann iteration_proxy, which would cause invalid_iterator.214 at runtime.
Template overloads on RouteEntry delegate to the existing raw 3-arg overloads, generating a $ref to the DTO's components/schemas name via dto::dto_name<T>. Non-template calls are unaffected (non-templates win overload resolution).
Replace hand-built nlohmann::json + XMedkit fluent builder with typed dto::Collection<dto::AreaListItem> and dto::XMedkitArea structs. Adds "type": "area" field per item (deliberate per issue #338 - entities must carry their type). Wire behavior otherwise preserved field-for-field.
Migrate handle_get_area, handle_list_components, handle_get_component, handle_list_apps, handle_get_app, handle_list_functions, and handle_get_function to typed dto:: structs with send_dto(). Entity list items now carry a required "type" discriminator field per issue #338 intent. All 2169 gateway unit tests pass.
Replace all EntityList/EntityDetail $ref usages with typed DTO schema names (AreaList, ComponentList, AppList, FunctionList, AreaDetail, ComponentDetail, AppDetail, FunctionDetail). Each top-level collection, entity detail, and sub-collection route now references the concrete DTO schema that matches what the handler actually emits. Delete SchemaBuilder::entity_detail_schema() and entity_list_schema() which are fully superseded by the DTO layer. Remove their entries from component_schemas() - the DTO-generated schemas win on all collisions. Add entity_type_to_list_name/detail_name helpers in path_builder.cpp so build_entity_collection/build_entity_detail emit $ref to the correct DTO schema for each entity keyword. Update test_schema_builder and test_path_builder: remove tests for the deleted factories and replace with assertions against the DTO registry. AllRefsResolveToRegisteredSchemas passes with zero dangling $ref.
Add FaultListItem, FaultStatus, FaultItem, FaultEnvironmentData, FaultXMedkit, FaultDetail and Collection<FaultListItem> (named FaultList) DTOs matching the exact wire shapes of fault_msg_conversions.cpp and FaultHandlers::build_sovd_fault_response. Register all new types in AllDtos; EveryRegisteredDtoRoundTrips passes.
- generic_error() now delegates to SchemaWriter<dto::GenericError>::schema()
(DTO is source of truth; deleted the redundant hand-built implementation)
- build_sovd_fault_response() returns dto::FaultDetail instead of nlohmann::json;
handle_get_fault calls HandlerContext::send_dto(res, detail)
- path_builder: build_faults_collection emits ref("FaultList") instead of
inline fault_list_schema()
- Deleted fault_list_item_schema(), fault_detail_schema(), fault_list_schema()
from SchemaBuilder; FaultListItem/FaultDetail/FaultList are now emitted by
dto::collect_component_schemas() via AllDtos
- Updated test_schema_builder, test_path_builder, test_fault_handlers to
assert against DTO-generated schemas and the dto::FaultDetail struct
- AllRefsResolveToRegisteredSchemas and EveryRegisteredDtoRoundTrips pass;
91/91 gateway unit tests green
…t handlers Add FaultListXMedkit and FaultListAggXMedkit typed DTO structs covering the five fault-list response shapes (global, per-app, function/component/area aggregated). Replace all XMedkit fluent builder usages in fault_handlers.cpp with typed struct construction serialized via dto::JsonWriter; use the json-overload of merge_peer_items for fan-out partial/failed_peers injection. Remove the #include of core/http/x_medkit.hpp from fault_handlers.cpp. Register the two new structs in AllDtos so EveryRegisteredDtoRoundTrips and AllRefsResolveToRegisteredSchemas cover them.
Introduce typed DTO structs for the CONFIGURATIONS domain: - ConfigXMedkitItem: per-item x-medkit in list responses - ConfigurationMetaData: list item (id, name, type, x-medkit) - Collection<ConfigurationMetaData> named "ConfigurationList" - ConfigListXMedkit: x-medkit on list response root - ConfigValueXMedkit: x-medkit on GET/PUT value responses - ConfigurationReadValue: GET/PUT response shape - ConfigurationWriteRequest: PUT request body - ConfigurationDeleteResultItem: 207 multi-status result entry - ConfigurationDeleteMultiStatus: 207 multi-status response body Provide dto_sample specializations for the two DTOs that carry a non-optional nlohmann::json field (data), ensuring EveryRegisteredDtoRoundTrips passes. Register all new types in AllDtos in registry.hpp.
Replace all XMedkit fluent builder usages in config_handlers.cpp with typed DTO structs from dto/config.hpp: - handle_list_configurations: ConfigListXMedkit + ConfigXMedkitItem per item - handle_get_configuration: ConfigurationReadValue + ConfigValueXMedkit - handle_set_configuration: parse_body<ConfigurationWriteRequest> for body parsing; ConfigurationReadValue + ConfigValueXMedkit for response - handle_delete_all_configurations: ConfigurationDeleteMultiStatus for 207 Remove all four hand-written config schema factories from schema_builder (configuration_metadata_schema, configuration_read_value_schema, configuration_write_value_schema, configuration_delete_multi_status_schema). DTO-generated schemas in collect_component_schemas() now own these names. Retype config routes in rest_server.cpp and path_builder.cpp to use $ref to DTO names (ConfigurationList, ConfigurationReadValue, ConfigurationWriteRequest, ConfigurationDeleteMultiStatus). Update test_schema_builder.cpp and test_path_builder.cpp to assert against DTO-generated schemas via component_schemas().
Add XMedkitDataItem, DataItem, XMedkitDataList, DataWriteRequest and Collection<DataItem> (named DataList) to the DTO contract layer. All new types are registered in AllDtos and pass EveryRegisteredDtoRoundTrips.
…ting Adds write_json_body, write_generic_error, write_oauth2_error in ros2_medkit_gateway::http::detail. These are friend-gated via the FrameworkOrPluginAccess token so only HandlerContext (today) and future RouteRegistry / PluginResponse shim (later commits) can call them. HandlerContext::send_json and send_error delegate to the new primitives; their public API is unchanged. OAuth2 error renderer prepared for the auth_handlers migration in commit 21.
Adds OpaqueObjectField and the opaque_object() factory in dto::contract.
Marks a DTO field as carrying an any-JSON-object whose runtime shape
depends on context (live ROS message payloads, action results, plugin-
provided data). JsonWriter passes the value through, JsonReader requires
it to be a JSON object, SchemaWriter emits {type:object, additionalProperties:
true, x-medkit-opaque:true}.
No DTO uses opaque_object yet - introduced by later commits when DataValue,
OperationExecutionResult, and similar runtime-shape DTOs land.
Adds typed get<T> / post<TB,T> / put<TB,T> / patch<TB,T> / del<T>
overloads on RouteRegistry whose handler signature is
`Result<TResponse>(TypedRequest)`. Each typed overload auto-populates
OpenAPI metadata from the template parameters via .response<T>(200) /
.request_body<TB>(""). Attachments variants accept Result<pair<T,
ResponseAttachments>> for per-call status / header overrides
(201+Location, 204+X-Medkit-Local-Only, etc.).
Adds five named escape hatches: sse<TEvent>, binary_download,
multipart_upload<T>, static_asset, docs_endpoint, docs_subtree
(regex-pattern catch-all). Plus post_alternates<TBody, TAlt...> and
del_alternates<TAlt...> for routes that return one of several typed
shapes (200/202, 204/207).
Per-route ErrorRenderer knob: kSovdGenericError (default) or
kOAuth2Error for the auth endpoints (used in commit 21).
Existing raw get/post/put/del overloads are marked [[deprecated]];
they are removed in commit 32 after every handler migrates to a typed
variant.
…rvability fields Adds a second template parameter to dto::Collection<T> defaulting to XMedkitCollection. Per-domain XMedkit specializations (FaultListXMedkit, ConfigListXMedkit, DataListXMedkit, LogListXMedkit) can now plug their richer types directly into a Collection wrapper rather than living as separate top-level structs. OperationListXMedkit / UpdateListXMedkit are deferred to later commits where the corresponding handlers grow typed list responses. Adds three optional fan-out observability fields to all collection-level XMedkit types: partial, failed_peers, peer_dropped_items. The peer_dropped_items field carries a list of dto::DroppedItem (new typed DTO in dto/aggregation.hpp) describing items that failed to parse from peer responses - fixes the "invisible drift" gap where bad peer items silently disappeared with no observability. Wire format is unchanged for all existing Collection<T> emissions (new fields are optional and omitted when empty). Typed-fanout helper in the next commit will start populating these fields.
…e rewire Adds typed overloads of validate_entity_for_route, validate_collection_access, validate_lock_access on HandlerContext. The new signatures return tl::expected<T, ErrorInfo> (or ValidatorResult<T> for the Forwarded-capable validate_entity_for_route). They do not touch the httplib::Response; the framework owns response writing in the typed path. Legacy overloads that take (req, res) are preserved as [[deprecated]] forwarders for the migration window; commit 30 removes them after handlers migrate. PluginResponse::send_json and send_error now call the http::detail primitives directly instead of going through HandlerContext, decoupling the plugin ABI from the HandlerContext API. Wire format is unchanged.
Adds a template helper fan_out_collection<T>(agg, req) returning FanOutResult<T> with typed items (parsed via JsonReader<T>), partial status, failed_peers, and dropped_items records for malformed peer responses. This closes the "invisible drift" gap where bad peer items silently disappeared - drops are now visible in x-medkit.peer_dropped_items and logged via RCLCPP_WARN. merge_peer_items is preserved for legacy handlers; commit 30 removes it after all handlers migrate to fan_out_collection<T>.
UpdateProvider::get_update now returns tl::expected<dto::UpdateDetail, UpdateBackendErrorInfo> instead of tl::expected<nlohmann::json, ...>. Wire format unchanged. Commercial plugins implementing UpdateProvider (Uptane OTA, OTA) need parallel PRs migrating their impl to the typed return.
OperationProvider methods return typed DTOs (Collection<OperationItem>,
OperationItem, OperationExecutionResult) instead of tl::expected<json,
OperationProviderErrorInfo>. Dynamic ROS action/service result payload
flows through OperationExecutionResult, whose specialized JsonWriter
emits the bare object verbatim and whose SchemaWriter declares the wire
as {type:object, additionalProperties:true, x-medkit-opaque:true} so the
typed envelope around it stays transparent.
Wire format unchanged. The in-tree OPC-UA plugin is migrated alongside
the interface change; commercial OperationProvider implementations need
parallel PRs migrating their impls.
DataProvider::list_data/read_data/write_data return typed DTOs
(DataListResult, DataValue, DataWriteResult) instead of
tl::expected<json, DataProviderErrorInfo>. Each DTO is an opaque
envelope around a single nlohmann::json content member with
specialized JsonWriter / JsonReader / SchemaWriter, so the wire bytes
are byte-identical to the pre-typed ABI. The OpenAPI schemas declare
{type:object, additionalProperties:true, x-medkit-opaque:true} because
the payload shape is plugin-determined (live ROS message, OPC-UA value
metadata, UDS DID, ...) and cannot be statically modelled.
DataListResult is named distinctly from the existing Collection<DataItem>
registration (which already uses dto_name "DataList" for the gateway-
internal ROS data path in handle_list_data).
Wire format unchanged. The in-tree OPC-UA plugin is migrated alongside
the interface change; commercial plugins implementing DataProvider
(UDS, OPC-UA variants) need parallel PRs migrating their impls.
FaultProvider::list_faults/get_fault/clear_fault return typed DTOs
(FaultListResult, FaultDetailResult, FaultClearResult) instead of
tl::expected<json, FaultProviderErrorInfo>. Each DTO is an opaque
envelope around a single nlohmann::json content member with
specialized JsonWriter / JsonReader / SchemaWriter, so the wire bytes
are byte-identical to the pre-typed ABI. The OpenAPI schemas declare
{type:object, additionalProperties:true, x-medkit-opaque:true} because
fault payloads are plugin-determined (UDS DTC records with environment
data, OPC-UA alarm metadata, vendor extended status, ...) and cannot
be statically modelled. The existing FaultListItem / FaultDetail DTOs
remain in use on the gateway-internal ROS fault path; the opaque
envelopes only apply to plugin-routed entities.
Wire format unchanged. Commercial plugins implementing FaultProvider
(UDS, OPC-UA) need parallel PRs migrating their impls.
health_handlers now uses Result<TResponse>(TypedRequest) signatures. The 3 routes (/health, /, /version-info) register through reg.get<T>(path, ...) which auto-populates the OpenAPI response<T> schema slot. Wire format unchanged. Establishes the typed-handler migration pattern for the remaining handler files in commits 17-29: method naming get_X/post_X/etc, TypedRequest by const ref, Result<T> = tl::expected<T, ErrorInfo> return, fluent route builder drops redundant manual response<T> declarations because reg.get<T> auto-fills them.
All 18 discovery routes migrate to reg.get<T>(path, typed_handler). Handler methods now return Result<TResponse>(TypedRequest). Wire format unchanged. Extends the framework with a small forwarding-scope primitive (http/detail/forward_response_scope.hpp) so the typed validate_entity_for_route overload can stream proxied responses to peer gateways without exposing httplib::Response to handlers. The typed wrap_body_less installs the scope around every typed handler call; discovery handlers translate the validator's Forwarded variant into a framework-internal sentinel ErrorInfo (ERR_X_INTERNAL_FORWARDED) that write_typed_error skips, keeping aggregation byte-identical.
All 5 lock routes migrate to reg.get/post/put/del<T>(path, typed_handler). Handler methods now return Result<TResponse>(TypedRequest [, TBody]). Wire format unchanged. POST acquire-lock uses the attachments variant (Result<std::pair<dto::Lock, ResponseAttachments>>) so it can override the status to 201 and append a Location header without re-introducing a httplib::Response & parameter. PUT extend-lock and DELETE release-lock return Result<http::NoContent> so the framework auto-serves 204 No Content. Adds a TypedRequest::path() accessor for handlers that need to build a Location header relative to the inbound request path. This is the only new TypedRequest read shape; everything else is covered by the existing path_param / header / query_param surface. Discovery handlers' Forwarded-sentinel pattern (commit 17) is reused verbatim: validate_entity_for_route's Forwarded variant is translated into HandlerContext::forwarded_sentinel_error() so the framework's write_typed_error wrapper drops it on the floor and keeps the proxied aggregation response byte-identical.
6 trigger routes (5 CRUD + 1 SSE) migrate to typed handlers. POST uses the attachments variant so it can override the status to 201 without re-introducing httplib::Response. DELETE returns Result<http::NoContent> so the framework auto-serves 204. The SSE event-stream uses the reg.sse<TEvent> escape hatch (commit 4). The factory returns Result<SseStream> with the next_event closure carrying tracker_guard (shared_ptr deleter) so the SSE client slot is released when the framework destroys the stream - either on client disconnect or end-of-stream. Wire format unchanged. Two framework adjustments fall out of this migration: - The SSE wrapper now installs a ForwardResponseScope around the factory call so SSE routes that call validate_entity_for_route stay peer-forward-aware. The scope ends before the chunked content provider starts streaming since peer-forwarding is a synchronous decision made up-front. - The SSE wrapper now emits the SOVD proxy-friendliness headers (Cache-Control: no-cache, X-Accel-Buffering: no) before the chunked content provider takes over. Every SSE caller wanted these on the legacy code path, so the framework owns them now and individual handlers stay free of httplib state. Discovery handlers' Forwarded-sentinel pattern (commit 17) is reused verbatim. extract_entity_type now takes the path as a string so the typed handler can pass req.path() without exposing the raw httplib request.
…istry API 6 cyclic-subscription routes (5 CRUD + 1 SSE events stream) migrate to typed handlers. POST uses the attachments variant so it can override the status to 201 without re-introducing httplib::Response. DELETE returns Result<http::NoContent> so the framework auto-serves 204. The SSE event-stream uses the reg.sse<> escape hatch (commit 4). Since cyclic-subscription SSE is transport-driven, the SubscriptionTransport interface gains a make_sse_stream(sub_id) method that returns Result<SseStream>. Default impl returns 501 for non-HTTP transports (MQTT, Zenoh, ...). SseTransportProvider's legacy handle_client_connect is replaced with make_sse_stream that builds the per-tick next_event closure carrying the sampler + tracker_guard (shared_ptr deleter releases the SSE client slot when the framework destroys the stream). The framework wrapper (commit 19) installs the Cache-Control / X-Accel- Buffering headers and drives cpp-httplib's chunked content provider, so headers and chunked-content plumbing move out of the transport. The Connection: keep-alive header drops since cpp-httplib emits it for HTTP/1.1 chunked responses by default; the wire format integration tests assert on (Content-Type, Cache-Control) stays byte-identical. Discovery handlers' Forwarded-sentinel pattern (commit 17) is reused verbatim. extract_entity_type now takes the path as a string so the typed handler can pass req.path() without exposing the raw httplib request.
…error renderer
3 auth routes migrate to typed handlers. Per-route .error_renderer(
kOAuth2Error) on RouteEntry wires errors through the framework's
write_oauth2_error primitive (RFC 6749 §5.2 shape: {error, error_description})
instead of SOVD GenericError.
Adds dto::TokenRequest, TokenResponse, TokenRevokeRequest,
TokenRevokeResponse for the auth endpoints (revoke was a missed
migration in PR #403). Wire format unchanged.
8 update routes migrate to typed handlers. POST /updates uses 201 attachments variant for Location header; PUT prepare/execute/automated use 202 attachments variant. Adds dto::UpdateRegisterRequest and UpdateRegisterResponse (missed migration from earlier PR). Wire format unchanged.
3 log routes (list with typed fan-out, get config, put config) migrate to typed handlers. Wire format unchanged.
8 script routes migrate to typed handlers. HATEOAS _links becomes typed dto::HateoasLinks sub-struct. POST upload + POST start-execution use 201 attachments variant. Wire format unchanged.
11 bulkdata routes migrate to typed handlers. Binary download uses reg.binary_download escape hatch (Range support + Content-Disposition). Multipart upload uses reg.multipart_upload<BulkDataDescriptor> with 201 + Location attachments. Wire format unchanged including range-capable content delivery.
5 config routes migrate to typed handlers. List endpoint uses typed fan_out_collection<ConfigurationMetaData> - per-item wire shape now enforced by JsonReader (closes issue #338 gap on this endpoint). delete-all uses del_alternates<NoContent, ConfigurationDeleteMultiStatus> for 204/207 bimodal responses. Adds dto_alternate_status<ConfigurationDeleteMultiStatus> = 207. Wire format unchanged.
7 operation routes migrate to typed handlers. create_execution uses post_alternates<OperationExecutionResult, ExecutionCreateAsync> for sync 200 / async 202 dispatch via dto_alternate_status trait. Adds dto::ExecutionId + Collection<ExecutionId> for the previously hand-built list_executions endpoint (missed migration from PR #403). Removes the legacy pragma block now that all calls go through the typed validators. Wire format unchanged.
5 data routes migrate to typed handlers. List uses typed fan_out_collection<DataItem> with Collection<DataItem, DataListXMedkit>. Read returns DataValue with opaque_object payload for live ROS messages. Removes the legacy deprecation pragma block - all calls now use typed validators. Wire format unchanged.
…uteRegistry API 6 fault routes + SSE stream migrate to typed handlers. Per-entity-type list and detail emit the opaque FaultListResult / FaultDetailResult envelopes so the per-branch x-medkit variants (FaultListXMedkit for App / global, FaultListAggXMedkit for Function / Component / Area) stay byte-identical with the legacy wire format while the typed router owns wire framing. Plugin pass-through uses the same envelopes - the typed FaultProvider already returns them. Single-fault DELETE uses del_alternates<NoContent, FaultClearResult> so the ROS path returns 204 while the plugin path keeps its 200 + acknowledgement-body shape. Global DELETE /faults uses the typed attachments variant Result<pair<NoContent, ResponseAttachments>> to emit 204 + X-Medkit-Local-Only: true. SSE stream uses reg.sse and returns Result<SseStream>; the framework wires Cache-Control and X-Accel-Buffering automatically and renders the limit-exceeded 503 as a SOVD GenericError. Removes the legacy deprecation pragma block - this completes the typed migration of all handler files. Wire format unchanged.
…loads All handlers now use the typed RouteRegistry API; the legacy HandlerContext::send_json/send_error/send_plugin_error/send_dto/parse_body methods + the (req, res) variants of validate_entity_for_route / validate_collection_access / validate_lock_access have no production callers and are removed. The framework primitives in http/detail/primitives.hpp (write_json_body, write_generic_error, write_oauth2_error) remain and are the only response-writing path. The two remaining raw-route entries that still write httplib::Response directly - DocsHandlers (per-path /docs capability description routes whose (.+)/docs$ regex shape does not map onto the typed router's OpenAPI path-template grammar) and SSEFaultHandler::handle_stream (legacy entry retained for the in-process unit test fixture) - move to the framework primitives via the friend gate. Both classes are added to FrameworkOrPluginAccess's narrow friend list. ValidationOutcome / ValidateResult are deleted along with the legacy overload they served. The handler_context.hpp drops the std::optional and DTO json_reader/json_writer includes that were only needed by the removed send_dto<T>/parse_body<T> templates. Test cleanup: - test_handler_context: drops the SendError* / SendJson* / Legacy* suites that exercised the removed wrappers; canonical wire-format coverage lives in test_primitives.cpp. - test_trigger_handlers, test_cyclic_subscription_handlers: drop redundant error-format assertions for the same reason. - test_plugin_http_types: drops the SendPluginError* suite (the helper had no production callers; PluginResponse covers clamping). merge_peer_items is kept in fan_out_helpers.hpp - fault_handlers (global /faults) and health_handlers (version-info) still rely on its raw-JSON aggregation surface for per-item vendor extensions that the typed fan_out_collection<T> would drop on JsonReader<T> round-trip. CapabilityGenerator and SchemaBuilder::component_schemas are kept - both remain in use by docs_handlers' per-path capability discovery, which is a distinct responsibility from the typed router's root OpenAPI spec. Build green; 2382 unit tests + targeted integration suite (health, discovery, faults) pass. Wire format unchanged.
test_plugin_abi_conformance loads test_gateway_plugin.so and verifies: - The plugin ABI types (GatewayPlugin, PluginRoute, PluginRequest, PluginResponse) preserve their signatures via static_assert. - The plugin loads and registers routes through the typed router's plugin shim path. - Plugin handler emissions go through http::detail::write_* primitives via the friend gate (PluginResponse). Wire format unchanged. Locks in the plugin ABI promise so future changes that would break commercial plugins (UDS, OPC-UA, Uptane OTA, OTA) fail this test.
With every handler migrated to typed reg.get<T>/post<TB,T>/put<TB,T>/del<T>, the [[deprecated]] raw HandlerFn overloads have no callers. Removed. Any future attempt to register a raw void(req,res) lambda fails at compile time.
OpenAPI 3.1 idiom: optional<T> fields now emit
{"anyOf": [<T schema>, {"type": "null"}]} instead of unwrapped T (with
just required[] omission). Generated clients can now express T | null
rather than degrading to T | undefined. Wire format unchanged - missing
optional keys still emit/parse via JsonWriter/JsonReader.
Updates integration drift / callability tests to walk anyOf.
…resence
AllDtos round-trip test now uses double-write compare for value equality
(writer emits X, reader drops X silently caught by j1 != j2 assertion).
x-medkit presence integration test parametrized over Area/Component/App/
Function with their respective XMedkit* sub-schemas. Optional
TypedTestFixture helper for handler test boilerplate.
The strengthened round-trip exposed a lossy interaction between the
generic sample synthesizer and the optional<nlohmann::json> wire shape:
make_sample seeded those fields with nlohmann::json{} which serialises as
JSON null, and JsonReader treats null as "absent" for optional fields.
Fix sample_value to seed opaque-json optionals with an empty object so
the round-trip survives without weakening the reader's null handling.
Updates design/dto_contract.rst with: - Typed router section (reg.get<T>, Result<T>, static_assert is_dto_v) - Escape hatches enumerated (sse, binary_download, multipart_upload, static_asset, docs_endpoint, alternates, plugin passthrough) - Provider ABI typed-only policy - Fan-out observability via peer_dropped_items - Opaque object policy for runtime-shape fields - OpenAPI generation pipeline now single-source from AllDtos CHANGELOG.rst BREAKING entries: - HandlerContext::send_*/parse_body removed - Provider ABI returns typed DTOs - RouteRegistry raw overloads deleted - SchemaWriter optional fields emit anyOf+null (OpenAPI 3.1 idiom) - Plugin ABI unchanged (test_plugin_abi_conformance locks it in) - rtmaps_medkit not supported by this PR
Pull Request
Summary
The gateway's HTTP payloads were maintained as three independent, hand-written representations - the handler's JSON construction, the OpenAPI schema factories, and the
XMedkitextension builder - with nothing keeping them in sync. They drifted: entity list responses advertised a minimal schema while the gateway actually emitted richx-medkitmetadata, so generated clients could not see those fields.This introduces a typed DTO contract. Each payload is a plain C++17 struct described once by a
constexprfield list. Three visitors fold over that single description:JsonWriter- struct to wire JSONSchemaWriter- type to OpenAPI schemaJsonReader- request body to struct, with validationWire output and OpenAPI schema are therefore generated from one source and cannot drift. All HTTP handler domains (entities, faults, operations, configurations, data, locks, triggers, cyclic-subscriptions, bulk-data, logs, scripts, updates, auth, health) were migrated to the contract, and
components/schemasis now generated from the DTO registry. The legacyXMedkitfluent builder and the ~50 hand-written schema factories were removed.Client-observable changes
components/schemasis regenerated from the DTOs; several schema names changed (for example the entity list/detail schemas). Clients generated from the previous spec need to be regenerated.typediscriminator (previously absent on list items).invalid-request(HTTP status 400 is unchanged).Known limitation
The fault/config/data/log list endpoints emit a richer collection-level
x-medkitthan the genericCollection<T>wrapper schema types it as. The wire payload stays a valid instance of the published schema; this is a schema-precision gap, documented indesign/dto_contract.rst.Issue
Part of #338.
This PR makes the gateway spec accurate. #338 stays open until generated clients are regenerated against the corrected spec and downstream consumers updated, which is follow-up work in separate repositories.
Type
Testing
test_dto_contractunit suite round-trips every registered DTO through all three visitors and validates each generated schema.x-medkitsub-schemas are asserted present in the spec.ros2_medkit_gatewayandros2_medkit_integration_testssuites pass (unit, integration, linters); clang-tidy is clean.Reviewers can verify by building
ros2_medkit_gateway, runningcolcon test, and hittingGET /api/v1/docsto confirmcomponents/schemascontains the typed entity andx-medkitschemas.Checklist