Skip to content

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

Draft
bburda wants to merge 51 commits into
mainfrom
feat/338-openapi-dto-contract
Draft

feat(gateway): typed DTO contract for HTTP responses and OpenAPI schema#403
bburda wants to merge 51 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 added 30 commits May 17, 2026 21:39
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.
Migrate handle_list_data and handle_put_data_item off the legacy XMedkit
fluent builder. handle_get_data_item keeps JSON passthrough since its
payload shape is only known at runtime (live ROS 2 message).

- handle_list_data: builds Collection<DataItem> via JsonWriter; per-item
  x-medkit typed as XMedkitDataItem; collection x-medkit typed as
  DataListXMedkit; fan-out uses the JSON overload of merge_peer_items
- handle_put_data_item: uses parse_body<DataWriteRequest> for the
  request body; write-response x-medkit typed as XMedkitDataItem
- Remove data_item_schema() and data_write_request_schema() factory
  methods; DataItem, DataList, DataWriteRequest now come from DTO registry
- path_builder: data collection route uses ref("DataList") instead of
  the inline items_wrapper(data_item_schema()) call
- rest_server: data collection route updated from DataItemList to DataList
- Add direction field to XMedkitRos2 for per-topic data direction
- Update test_schema_builder to assert against DTO-generated schema
Add Lock, AcquireLockRequest, ExtendLockRequest typed DTOs in
dto/locks.hpp. Register all three (plus Collection<Lock> as LockList)
in AllDtos so the EveryRegisteredDtoRoundTrips contract test covers
them.

Wire shapes match the existing lock_schema / acquire_lock_request_schema
/ extend_lock_request_schema factories exactly.
Migrate lock_handlers.cpp to typed DTOs:
- handle_acquire_lock uses parse_body<dto::AcquireLockRequest> for
  JSON body parsing; custom validation (positive expiration, valid
  scope strings) follows parse_body on the typed struct fields
- handle_extend_lock uses parse_body<dto::ExtendLockRequest>
- handle_list_locks returns dto::Collection<dto::Lock> via send_dto
- handle_get_lock and handle_acquire_lock return dto::Lock via send_dto

Remove lock_schema / acquire_lock_request_schema / extend_lock_request_schema
factory definitions and declarations; Lock, LockList, AcquireLockRequest,
ExtendLockRequest in component_schemas() now come from the DTO registry.

Update test_schema_builder to verify lock schemas via component_schemas()
instead of the removed factory calls. Update AcquireLockWithMissingExpiration
test: parse_body uses ERR_INVALID_REQUEST for missing required fields.

make format_expiration public to allow file-scope DTO helper access.
Add dto/triggers.hpp with TriggerCondition, Trigger, TriggerCreateRequest,
TriggerUpdateRequest structs and co-located dto_fields/dto_name.  The
trigger_condition wire field is a free-form merged JSON object
(condition_type + additionalProperties), so Trigger and TriggerCreateRequest
carry it as nlohmann::json with dto_sample specializations for round-trip
safety.  Register all new types and Collection<Trigger> (TriggerList) in
registry.hpp AllDtos so EveryRegisteredDtoRoundTrips passes.
Replace manual JSON body parsing and response construction in
trigger_handlers.cpp with typed DTO operations:
- handle_create uses parse_body<dto::TriggerCreateRequest>; static
  trigger_info_to_dto helper builds the response DTO; send_dto replaces
  send_json
- handle_list builds dto::Collection<dto::Trigger> and calls send_dto
- handle_get and handle_update likewise use trigger_info_to_dto + send_dto
- handle_update uses parse_body<dto::TriggerUpdateRequest>
- handle_events (SSE stream) is left untouched

Delete the 4 legacy trigger factory functions (trigger_schema,
trigger_condition_schema, trigger_create_request_schema,
trigger_update_request_schema) from schema_builder.{cpp,hpp}; their
schemas now come from the DTO registry.

Rewrite the three trigger factory assertions in test_schema_builder.cpp
against SchemaWriter<dto::Trigger*> so they verify the same wire contract
through the DTO path.
Add dto/cyclic_subscriptions.hpp with three typed structs:

- CyclicSubscription: CRUD response (id, observed_resource, event_source,
  protocol, interval enum)
- CyclicSubscriptionCreateRequest: POST body (resource, interval enum,
  duration, protocol optional)
- CyclicSubscriptionUpdateRequest: PUT body (interval optional enum,
  duration optional int)

Collection<CyclicSubscription> named "CyclicSubscriptionList".
All four types added to AllDtos in registry.hpp.
test_dto_contract EveryRegisteredDtoRoundTrips passes.
bburda added 21 commits May 18, 2026 14:16
Replace manual JSON body parsing and response construction in
cyclic_subscription_handlers.cpp with typed DTO operations:
- handle_create uses parse_body<dto::CyclicSubscriptionCreateRequest>;
  static subscription_to_dto helper builds the response DTO; send_dto
  replaces send_json
- handle_list builds dto::Collection<dto::CyclicSubscription> and calls
  send_dto
- handle_get and handle_update likewise use subscription_to_dto + send_dto
- handle_update uses parse_body<dto::CyclicSubscriptionUpdateRequest>
- handle_events (SSE stream) is left untouched
- subscription_to_json preserved as a thin wrapper for test compatibility

Delete the 2 legacy cyclic subscription factory functions
(cyclic_subscription_schema, cyclic_subscription_create_request_schema)
from schema_builder.{cpp,hpp}; their schemas now come from the DTO
registry.

Repoint path_builder.cpp to use SchemaBuilder::ref for cyclic
subscription list, create request, and create response schemas.

Fix rest_server.cpp PUT route to reference CyclicSubscriptionUpdateRequest
instead of CyclicSubscription for the request body.

Rewrite the factory assertion in test_schema_builder.cpp against
SchemaWriter<dto::CyclicSubscriptionCreateRequest> and update
test_path_builder.cpp to check $ref instead of inline properties.
Add LogContext, LogEntry, Collection<LogEntry>, LogListXMedkit, and
LogConfiguration DTO structs with co-located dto_fields/dto_name
specializations. LogListXMedkit covers all x-medkit fields emitted by
handle_get_logs across FUNCTION/AREA/COMPONENT/APP entity branches.
Register all five types in AllDtos via registry.hpp.

EveryRegisteredDtoRoundTrips: green.
Replace the legacy XMedkit fluent builder in log_handlers.cpp with
typed dto::LogListXMedkit. Migrate handle_get_logs_configuration to
send_dto<LogConfiguration> and handle_put_logs_configuration to
parse_body<LogConfiguration>, keeping semantic range validation for
max_entries and severity after parsing.

Remove log_entry_schema, log_entry_list_schema, and
log_configuration_schema factories from SchemaBuilder; LogEntry,
LogEntryList, LogContext, LogListXMedkit, and LogConfiguration are
now emitted by the DTO registry. Update path_builder to reference
LogEntryList via $ref. Update test_schema_builder and test_path_builder
to assert against the registered DTO schemas.

grep -rn "log_entry_schema|log_configuration_schema" src/ shows only
comment references, zero code invocations.
Adds dto/updates.hpp with UpdateList (items-wrapper of bare strings),
UpdateSubProgress (nested sub-step struct), XMedkitUpdate (typed x-medkit
extension with field_enum on phase), and UpdateStatus (status response
with required x-medkit). Registers all four types in AllDtos.
Migrates handle_list_updates to dto::UpdateList and handle_get_status to
dto::UpdateStatus via a new to_update_status_dto() converter. Removes
the static status_to_json() method from UpdateHandlers. Adds
update_status_to_string() helper alongside the existing
update_phase_to_string() in update_types.hpp. Deletes update_list_schema
and update_status_schema from SchemaBuilder. Schemas are now generated
from the DTO types in AllDtos.
Add AuthCredentials and AuthTokenResponse typed DTOs for the auth
domain. AuthCredentials captures the OAuth2 request body fields
(grant_type, client_id, client_secret, refresh_token, scope).
AuthTokenResponse captures the token success response fields
(access_token, token_type, expires_in, scope, refresh_token).
Both are registered in AllDtos; EveryRegisteredDtoRoundTrips passes.
Switch handle_auth_authorize and handle_auth_token to use
parse_body<AuthCredentials> for request parsing and send_dto with
AuthTokenResponse for success responses. The 401 credential-check
path (invalid_client, invalid_grant) is preserved via the existing
AuthManager result handling. The OAuth2 error format (error +
error_description) is preserved for all handler-level validations
(unsupported_grant_type, invalid_request field checks).

Delete auth_token_response_schema and auth_credentials_schema factory
functions from SchemaBuilder; both schemas now come from dto/auth.hpp
via collect_component_schemas. Zero remaining references to the deleted
factories. Routes in rest_server.cpp already use SchemaBuilder::ref()
for both auth schemas, so no route changes needed.
Add dto/health.hpp with typed DTOs for the health/root domain:
Health, HealthDiscovery, HealthDiscoveryLinking, HealthAggregationWarning
(for GET /health), VersionInfo, VersionInfoEntry, VersionInfoVendor,
XMedkitVersionInfo (for GET /version-info), RootOverview, RootCapabilities,
RootAuth, RootTls (for GET /). Register all in AllDtos.

EveryRegisteredDtoRoundTrips passes for all new types.
Migrate handle_health, handle_root, and handle_version_info to build
typed DTOs (Health, RootOverview, VersionInfo) and call send_dto instead
of constructing raw nlohmann::json. The x-medkit-data-provider and
x-medkit-subscription-executor endpoint-level vendor extension keys
are modelled as optional<nlohmann::json> fields with hyphened wire keys.
The handle_root dynamic endpoint-list computation and the handle_version_info
fan-out logic are preserved verbatim.

Remove the three legacy hand-written schema factories (health_schema,
version_info_schema, root_overview_schema) from schema_builder; the DTO
registry now generates HealthStatus, VersionInfo, and RootOverview along with
all sub-DTOs. Update test_schema_builder to assert against the DTO-generated
schema shapes (component_schemas() and $ref linkage) instead of the
removed factory methods.

health_handlers.cpp no longer includes core/http/x_medkit.hpp.
Zero code references to the three deleted factory names remain.
All 2186 gateway unit tests pass.
Delete core/http/x_medkit.hpp, src/core/http/x_medkit.cpp and their
test (test_x_medkit.cpp). All 13 handler domains now use typed
dto::XMedkit* structs + dto::JsonWriter instead of the fluent builder.

The one straggler was fan_out_helpers.hpp which kept a
merge_peer_items(XMedkit&) overload alongside the already-migrated
nlohmann::json& overload. The legacy overload is removed; the json&
overload is the only one remaining. test_fan_out_helpers.cpp is updated
to use plain json objects in place of XMedkit for the ext parameter.

component_schemas() is simplified: all domain schemas now come from
dto::collect_component_schemas(); the only surviving hand-written entry
is OperationExecutionList (a thin items-wrapper without a dedicated DTO
type). The earlier GenericError and per-domain factory calls that were
superseded by the DTO registry are gone.

send_json in handler_context.hpp is documented as an escape-hatch
for dynamic-payload, fan-out, and spec-blob callers; prefer send_dto<T>
for typed responses.

CMakeLists.txt: remove ament_add_gtest(test_x_medkit ...) block and
the test_x_medkit entry from the coverage target list.

Build: clean. Unit suite: 90 tests, 0 failures (2632 subtests).
Add two new assertions to TestOpenApiCallability:

1. test_x_medkit_sub_schemas_present_in_spec: verifies that
   components/schemas in the live spec contains the DTO-generated schemas
   XMedkitArea (with ros2 $ref to XMedkitRos2) and AreaListItem (with
   x-medkit $ref to XMedkitArea). This directly confirms the issue #338
   DTO-contract wiring to the spec builder.

2. test_live_entity_responses_conform_to_spec: fetches live responses from
   /areas, /components, /apps, /health, /version-info, /apps/{id},
   /apps/{id}/faults, /apps/{id}/operations, and /apps/{id}/data, then
   validates each against the jsonschema declared in the 200 response of
   the same path in the runtime spec. Schema drift causes test failure.

Also fix two pre-existing issues surfaced by the callability test:
- CyclicSubscriptionCreateRequest.interval used plain field() instead of
  field_enum() - the schema lacked the enum constraint, causing the
  payload generator to send "test_value" which the handler rejects.
  Fixed by using field_enum with kCyclicSubscriptionIntervalValues.
- Added ConfigurationWriteRequest and TriggerCreateRequest runtime
  validations to _KNOWN_BUSINESS_400_PATTERNS: both are genuine spec
  limitations (oneOf-across-optionals and free-form JSON sub-field
  constraints) that cannot be expressed in OpenAPI without breaking client
  ergonomics.
Add a design doc explaining the typed DTO contract introduced in the
ros2_medkit_gateway: the Field descriptor, dto_fields/dto_name constexpr
tuples, the three visitors (JsonWriter, SchemaWriter, JsonReader), the
AllDtos registry, and the workflow for adding new typed endpoints.

Wire the new design doc into the per-package design index toctree.
Update the OpenAPI tutorial to explain that schemas are generated from
the DTO registry rather than hand-written factories. Update the README
Docs row to mention the DTO-backed schema accuracy.
…ates

ExecutionUpdateRequest.capability was registered with field_enum(...,
kExecutionCapabilityValues), which caused parse_body to reject any
out-of-vocabulary value with a generic 400 ERR_INVALID_REQUEST before
the handler ran. This killed the handler's own capability-validation
path that supports custom x-* capabilities and returns the richer
ERR_INVALID_PARAMETER 'Unknown capability' response with a
supported_capabilities array.

Change the registration to plain field() so missing capability is still
caught by parse_body (ERR_INVALID_REQUEST), but an out-of-vocab value
flows to the handler's if/else chain as intended. Update the test
comment to clarify that parse_body only catches absence, not bad values.
lock_to_json and subscription_to_json had zero production callers after
the DTO migration - handlers now build responses directly via JsonWriter.
Delete both helpers (declaration + definition) and their test suites.

REQ_INTEROP_089 was tagged on two of the subscription_to_json tests;
that requirement remains covered by test_subscription_manager.cpp and
test_transport_registry.cpp.
…gregated-fault x-medkit

Add a static_assert to field_enum requiring M to be std::string or
std::optional<std::string>. JsonReader::check_enum only fires for
those types; applying field_enum to any other member type would silently
accept out-of-vocabulary values at runtime.

Also document FaultListAggXMedkit in the Known Limitations section of
dto_contract.rst - it is the fifth collection x-medkit struct registered
in AllDtos but not $ref-linked from its list response schema.
…apability field change

The capability field is now registered as plain field() rather than
field_enum(), so the generated schema no longer carries an enum array.
Update the schema builder test to assert the field is an unrestricted
string, matching the deliberate design to allow custom x-vendor-*
capabilities through to the handler.
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.

1 participant