Skip to content

feat(gateway): typed DTO contract for HTTP responses and OpenAPI schema#403

Open
bburda wants to merge 82 commits into
mainfrom
feat/338-openapi-dto-contract
Open

feat(gateway): typed DTO contract for HTTP responses and OpenAPI schema#403
bburda wants to merge 82 commits into
mainfrom
feat/338-openapi-dto-contract

Conversation

@bburda
Copy link
Copy Markdown
Collaborator

@bburda bburda commented May 18, 2026

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 XMedkit extension builder - with nothing keeping them in sync. They drifted: entity list responses advertised a minimal schema while the gateway actually emitted rich x-medkit metadata, 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 constexpr field list. Three visitors fold over that single description:

  • JsonWriter - struct to wire JSON
  • SchemaWriter - type to OpenAPI schema
  • JsonReader - request body to struct, with validation

Wire 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/schemas is now generated from the DTO registry. The legacy XMedkit fluent builder and the ~50 hand-written schema factories were removed.

Client-observable changes

  • The OpenAPI components/schemas is 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.
  • Entity responses now always include the type discriminator (previously absent on list items).
  • Optional fields previously emitted as empty strings or empty arrays are now omitted when empty.
  • Request-body validation failures on some PUT/POST endpoints now return the error code invalid-request (HTTP status 400 is unchanged).

Known limitation

The fault/config/data/log list endpoints emit a richer collection-level x-medkit than the generic Collection<T> wrapper schema types it as. The wire payload stays a valid instance of the published schema; this is a schema-precision gap, documented in design/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

  • Bug fix
  • New feature or tests
  • Breaking change
  • Documentation only

Testing

  • New test_dto_contract unit suite round-trips every registered DTO through all three visitors and validates each generated schema.
  • Integration tests extended: live endpoint responses are validated against the served OpenAPI spec, and the typed x-medkit sub-schemas are asserted present in the spec.
  • Full ros2_medkit_gateway and ros2_medkit_integration_tests suites pass (unit, integration, linters); clang-tidy is clean.

Reviewers can verify by building ros2_medkit_gateway, running colcon test, and hitting GET /api/v1/docs to confirm components/schemas contains the typed entity and x-medkit schemas.


Checklist

  • Breaking changes are clearly described (and announced in docs / changelog if needed)
  • Tests were added or updated if needed
  • Docs were updated if behavior or public API changed

@bburda bburda marked this pull request as ready for review May 19, 2026 10:15
Copilot AI review requested due to automatic review settings May 19, 2026 10:15
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.py for schema limitations (Configuration/Trigger/CyclicSubscription).
  • Add new integration test methods to assert typed x-medkit sub-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.cpp since the XMedkit fluent 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.

@bburda bburda self-assigned this May 19, 2026
@bburda bburda requested a review from mfaferek93 May 19, 2026 10:51
bburda added 24 commits May 27, 2026 14:19
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.
bburda added 30 commits May 27, 2026 18:13
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants