Skip to content

refactor(api): name the REST request payloads into shared types (spec 76 wave 2.4) - #200

Merged
hyperb1iss merged 6 commits into
mainfrom
nova/s76-w2.4-typed-payloads
Aug 17, 2026
Merged

refactor(api): name the REST request payloads into shared types (spec 76 wave 2.4)#200
hyperb1iss merged 6 commits into
mainfrom
nova/s76-w2.4-typed-payloads

Conversation

@hyperb1iss

@hyperb1iss hyperb1iss commented Aug 17, 2026

Copy link
Copy Markdown
Owner

💜 What this does

Spec 76 wave 2.4. Forty-two REST request and query shapes lived as anonymous structs inside daemon route modules. Nothing outside the daemon could name them, so every client rebuilt each shape by hand: the CLI assembled bodies with serde_json::json! against field names copied by eye, and the web UI and TUI carried twelve mirror structs between them. A daemon-side field rename compiled clean on all four sides and failed at runtime.

All forty-two now live in hypercolor-types::api under their domain module, and the daemon, CLI, web UI, and TUI consume the single definition. That class of drift is now a compile error.

🔮 The shared types

Twelve new modules join the four that already existed:

Module Types
assets AssetUploadQuery, AssetUpdateRequest
attachments ListTemplatesQuery
config ConfigApplyQuery
controls ControlSurfaceListQuery, InvokeControlActionRequest
devices (extended) ListDevicesQuery, IdentifyAttachmentRequest, UpdateAttachmentsRequest, DiscoverRequest, ListLogicalDevicesQuery, CreateLogicalDeviceRequest, UpdateLogicalDeviceRequest
diagnose DiagnoseRequest
displays DisplayFaceScope, SetDisplayFaceRequest, DisplayFaceScopeQuery, UpdateDisplayFaceControlsRequest, UpdateDisplayFaceCompositionRequest
effects (extended) SetEffectLayoutRequest
layers CreateLayerQuery, CreateLayerRequest, UpdateLayerRequest, LayerOrderRequest, PatchLayerControlsRequest, BroadcastMediaLayerTarget, BroadcastMediaLayerRequest
layouts LayoutListQuery, CreateLayoutRequest, UpdateLayoutRequest
library AddFavoriteRequest, PlaylistTargetRequest, PlaylistItemRequest, SavePlaylistRequest, SavePresetRequest, ApplyPresetRequest
profiles CreateProfileRequest, UpdateProfileRequest, ApplyProfileRequest
settings SetBrightnessRequest
simulators CreateSimulatedDisplayRequest, UpdateSimulatedDisplayRequest

Each one matches the shape it replaced field for field, including the serde attributes that govern what the daemon accepts. The daemon modules re-export them, so route handlers, the OpenAPI catalog, and the MCP adapters keep the paths they already used.

⚡ Clients

The CLI gains a hypercolor-types dependency and builds real structs for brightness, diagnostics, discovery, identify, pairing, control-surface values and actions, effect apply, output power, active controls, control reset, effect layout, favorites, presets, playlists, profiles, and scene creation.

The largest single win is control values. The CLI hand-wrote the driver algebra's kind/value tagging across fourteen match arms; parse_control_value now returns a real ControlValue and serde emits the same tagging, so a new variant is a compile error rather than a typo nobody notices.

The web UI drops eleven mirror structs and the TUI one, and fourteen hand-rolled bodies across both become typed. Three mirrors had drifted in name only and are renamed at their call sites: UpdateLayoutApiRequest becomes UpdateLayoutRequest, CreatePresetRequest becomes SavePresetRequest, and the UI's ComponentBindingRequest becomes the ComponentBinding it duplicated.

💎 Wire preservation

This wave changes no wire shape. The fence is that every pinned suite passes with its assertions untouched: the REST v1 compat matrix, api_tests, the OpenAPI catalog test, and the CLI's request_shape_tests, which drives the real binary against a capturing server and asserts exact JSON bodies.

What clients put on the wire does shift, in both directions, wherever a hand-built body spelled an unset optional differently from the shared type. Every such flip is listed below.

Eight request shapes now omit a key they used to send unconditionally, because serde_json::json! renders a None as null and the replaced UI mirrors declared these fields without skip_serializing_if:

Client Request Fields
CLI CreateProfileRequest description
CLI CreateSceneRequest description
CLI SavePresetRequest description
CLI SavePlaylistRequest description
CLI PlaylistItemRequest duration_ms, transition_ms
UI CreateLayerRequest, UpdateLayerRequest name
UI AssetUpdateRequest name, tags
UI SavePresetRequest controls

Two move the other way and gain a key their predecessor omitted, because the shared field carries serde(default) without skip_serializing_if:

Client Request Field
CLI PairDeviceRequest values, now stated as {}
UI ComponentBinding inside UpdateAttachmentsRequest name, now stated as null

The equivalence argument is the same for all of them: every field involved is an Option or carries serde(default) on the daemon, so absent, explicit null, and empty all deserialize to the identical Rust value, and no handler branches on which spelling arrived. That is now a fence rather than an argument. absent_and_explicit_null_optional_fields_decode_alike decodes each affected type both ways and asserts equality, taking its field names as identifiers so a rename or typo is a compile error rather than a key serde would silently ignore, absent_and_empty_pairing_values_decode_alike does the same for the pairing map, and component_binding_accepts_absent_and_explicit_null_names covers the binding. The suite also pins the identify request's flattening, the driver control-value tagging, the display-face scope spellings, and the playlist target tagging.

Two of those are type-level only, listed for completeness rather than because any request changes. AssetUpdateRequest's sole caller at hypercolor-ui/src/pages/media.rs:474 always supplies Some for both fields. SavePresetRequest.controls was a bare serde_json::Value on the UI mirror, so the key was unconditional; the shared type wraps it in Option, and all three construction sites in components/preset_panel.rs pass Some. Everything else on the list is reachable, including the layer name flip: hypercolor-ui/src/app/effect_state.rs:113 creates layers with name: None.

The regenerated Python client carries sixteen added description strings and nothing else. No required array, property, type, or enum moved. PatchLayerControlsRequest.controls deliberately keeps no serde(default) so the published schema still marks it required, matching what ships; serde admits an absent field through Option regardless.

🎯 One pinned assertion changed

crates/hypercolor-ui/tests/display_api_tests.rs::attachment_binding_request_keeps_explicit_defaults_on_wire asserted that an unset name is omitted from the wire. That was true of the deleted UI mirror, which carried skip_serializing_if, and is not true of the shared ComponentBinding, which emits an explicit null.

The daemon reads both forms to None, proven by the new component_binding_accepts_absent_and_explicit_null_names test, so nothing the daemon accepts has changed. The pin now states the shared type's emission and keeps its original point, which is that the UI sends defaults explicitly instead of letting the daemon reconstruct them. hypercolor_types::attachment::ComponentBinding itself is untouched, so the persisted and response forms of that type are unaffected.

🦋 Two defects this surfaced

Typing the CLI's bodies exposed two commands that post payloads no daemon contract accepts. Both keep an untyped body with a comment saying why, because fixing either is a contract decision rather than a naming one.

hypercolor devices set-color posts {"color": ...} to PUT /devices/{id}, which deserializes UpdateDeviceRequest. That type carries name, enabled, and brightness and does not reject unknown fields, so color is dropped silently; the handler then answers 422 from its own guard at devices/mod.rs:277 for having received none of the three fields it accepts. The command cannot have worked against this handler.

hypercolor scenes activate --transition posts {"transition_ms": ...} to POST /scenes/{id}/activate, which takes no request body at all. Axum reads and discards it. The profiles equivalent does accept transition_ms; the scenes route never grew it.

🧪 Verification

just verify is green apart from one GPU-timing test in render_thread_tests that fails under parallel load and reproduces on the base commit; the suite is 46/46 with --test-threads=1. Workspace clippy is clean, the out-of-workspace UI crate checks and tests separately, and generate_openapi_client.py --check exits 0.

🤖 Generated with Claude Code

hyperb1iss and others added 4 commits August 17, 2026 02:05
Forty-two request and query shapes lived as anonymous structs inside
daemon route modules, so no client could reference them and every
consumer re-derived the shape by hand. They now live in
hypercolor-types::api under their domain module, matching the wire
field-for-field: same serde attributes, same defaults, same optionality.
The daemon modules re-export them, so route handlers, the OpenAPI
catalog, and the MCP adapters keep their existing paths.

New api modules: assets, attachments, config, controls, diagnose,
displays, layers, layouts, library, profiles, settings, simulators.
The devices and effects modules grow their missing request types.

Two structural notes. The layer request conversions move with their
types, except the broadcast expansion, which reaches into
hypercolor-core's SceneGroupLayerInsert and stays daemon-local as a free
function. PatchLayerControlsRequest deliberately keeps its controls
field free of serde(default) because the published schema marks it
required and Option already admits an absent field.

The config write body stays an untyped serde_json::Value: on that route
the value itself is the body, so there is no shape to name.

Every pinned suite passes unedited, which is the fence this wave was
supposed to hold. The regenerated Python client carries only new
description strings from the doc comments; no schema shape moved.

Co-Authored-By: Nova (Claude Opus 5) <noreply@anthropic.com>
The CLI had no dependency on hypercolor-types, so every request body was
assembled with serde_json::json! against field names copied from the
daemon by eye. It now depends on the types crate and constructs the
shared request structs directly, which puts the daemon's contract behind
the compiler for brightness, diagnose, discovery, identify, pairing,
control-surface values and actions, effect apply, output power, active
controls, control reset, effect layout, favorites, presets, playlists,
profiles, and scene creation.

Control values get the largest win. The CLI hand-wrote the driver
algebra's kind/value tagging in fourteen match arms; parse_control_value
now returns a real ControlValue and serde emits the same tagging, so a
new variant is a compile error instead of a typo waiting to happen.

Emitted bodies stay semantically identical. The one visible difference
is that omitted optional fields are now absent rather than explicitly
null, which every daemon type already accepted through Option. The
pinned request-shape suite passes unedited.

Two call sites keep an untyped body because no daemon contract accepts
what they send, each now carrying a comment saying so: devices set-color
posts a color field the device update contract does not define, and
scenes activate posts transition_ms to a route with no request body.
Both are pre-existing defects that need a contract decision.

Co-Authored-By: Nova (Claude Opus 5) <noreply@anthropic.com>
The web UI carried eleven hand-copied request structs and the TUI a
twelfth, none of them fenced by anything: a daemon-side field rename
would have compiled clean on both sides and failed at runtime. They now
import the shared definitions, which deletes the copies and turns that
class of drift into a compile error.

Three of the copies had drifted in name only and are renamed at their
call sites: UpdateLayoutApiRequest to UpdateLayoutRequest,
CreatePresetRequest to SavePresetRequest, and the UI's
ComponentBindingRequest to the ComponentBinding it duplicated. A fourth,
SavePresetRequest, gains the daemon's Option around its controls field.

Eight sites that hand-rolled bodies with serde_json become typed:
identify, attachment identify, brightness, favorites, layer controls,
display-face controls, output power, and active controls. The UI's
From<&SceneLayer> impl becomes update_request_from_layer, since an
inherent trait impl cannot follow the type into another crate.

A new hypercolor-types suite fences the properties clients depend on:
unset optionals serialize as absent, the identify attachment request
flattens its base, control values carry the driver kind tagging, and
playlist targets stay internally tagged.

One pinned assertion changed. The UI's attachment binding test asserted
that an unset name is omitted, which was true of the deleted mirror but
not of the shared ComponentBinding, which emits an explicit null. The
daemon reads both to None, proven by the new
component_binding_accepts_absent_and_explicit_null_names test, so the
pin now states the shared type's emission and keeps its original point
that the UI sends defaults explicitly.

Co-Authored-By: Nova (Claude Opus 5) <noreply@anthropic.com>
The favorites POST was the last UI body still built with serde_json;
it now constructs AddFavoriteRequest like every other call in the
module. Regenerating the Python client picks up the layer route doc
comments, which move only description strings.

Co-Authored-By: Nova (Claude Opus 5) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@hyperb1iss, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 minutes

Limit details: You’ve used all 1 included review currently available under your plan.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 94329e79-5154-41c7-b3e5-64058ece6c45

📥 Commits

Reviewing files that changed from the base of the PR and between f8eeeb1 and 9581c58.

⛔ Files ignored due to path filters (20)
  • Cargo.lock is excluded by !**/*.lock
  • python/src/hypercolor/_generated/api/controls/invoke_control_surface_action.py is excluded by !**/_generated/**
  • python/src/hypercolor/_generated/api/devices/discover_devices.py is excluded by !**/_generated/**
  • python/src/hypercolor/_generated/api/profiles/apply_profile.py is excluded by !**/_generated/**
  • python/src/hypercolor/_generated/api/scenes/broadcast_media_layer.py is excluded by !**/_generated/**
  • python/src/hypercolor/_generated/api/scenes/create_layer.py is excluded by !**/_generated/**
  • python/src/hypercolor/_generated/api/scenes/patch_layer_controls.py is excluded by !**/_generated/**
  • python/src/hypercolor/_generated/api/scenes/reorder_layers.py is excluded by !**/_generated/**
  • python/src/hypercolor/_generated/api/scenes/update_layer.py is excluded by !**/_generated/**
  • python/src/hypercolor/_generated/api/settings/set_brightness.py is excluded by !**/_generated/**
  • python/src/hypercolor/_generated/models/apply_profile_request.py is excluded by !**/_generated/**
  • python/src/hypercolor/_generated/models/broadcast_media_layer_request.py is excluded by !**/_generated/**
  • python/src/hypercolor/_generated/models/broadcast_media_layer_target.py is excluded by !**/_generated/**
  • python/src/hypercolor/_generated/models/create_layer_request.py is excluded by !**/_generated/**
  • python/src/hypercolor/_generated/models/discover_request.py is excluded by !**/_generated/**
  • python/src/hypercolor/_generated/models/invoke_control_action_request.py is excluded by !**/_generated/**
  • python/src/hypercolor/_generated/models/layer_order_request.py is excluded by !**/_generated/**
  • python/src/hypercolor/_generated/models/patch_layer_controls_request.py is excluded by !**/_generated/**
  • python/src/hypercolor/_generated/models/set_brightness_request.py is excluded by !**/_generated/**
  • python/src/hypercolor/_generated/models/update_layer_request.py is excluded by !**/_generated/**
📒 Files selected for processing (62)
  • crates/hypercolor-cli/Cargo.toml
  • crates/hypercolor-cli/src/commands/brightness.rs
  • crates/hypercolor-cli/src/commands/controls.rs
  • crates/hypercolor-cli/src/commands/devices.rs
  • crates/hypercolor-cli/src/commands/diagnose.rs
  • crates/hypercolor-cli/src/commands/drivers.rs
  • crates/hypercolor-cli/src/commands/effects.rs
  • crates/hypercolor-cli/src/commands/library.rs
  • crates/hypercolor-cli/src/commands/profiles.rs
  • crates/hypercolor-cli/src/commands/scenes.rs
  • crates/hypercolor-daemon/src/api/assets.rs
  • crates/hypercolor-daemon/src/api/attachments.rs
  • crates/hypercolor-daemon/src/api/config.rs
  • crates/hypercolor-daemon/src/api/controls.rs
  • crates/hypercolor-daemon/src/api/devices/attachments.rs
  • crates/hypercolor-daemon/src/api/devices/discovery.rs
  • crates/hypercolor-daemon/src/api/devices/logical.rs
  • crates/hypercolor-daemon/src/api/devices/mod.rs
  • crates/hypercolor-daemon/src/api/diagnose.rs
  • crates/hypercolor-daemon/src/api/displays.rs
  • crates/hypercolor-daemon/src/api/effects.rs
  • crates/hypercolor-daemon/src/api/layers.rs
  • crates/hypercolor-daemon/src/api/layouts.rs
  • crates/hypercolor-daemon/src/api/library/favorites.rs
  • crates/hypercolor-daemon/src/api/library/playlists.rs
  • crates/hypercolor-daemon/src/api/library/presets.rs
  • crates/hypercolor-daemon/src/api/profiles.rs
  • crates/hypercolor-daemon/src/api/settings.rs
  • crates/hypercolor-daemon/src/api/simulators.rs
  • crates/hypercolor-tui/src/client/rest.rs
  • crates/hypercolor-types/src/api/assets.rs
  • crates/hypercolor-types/src/api/attachments.rs
  • crates/hypercolor-types/src/api/config.rs
  • crates/hypercolor-types/src/api/controls.rs
  • crates/hypercolor-types/src/api/devices.rs
  • crates/hypercolor-types/src/api/diagnose.rs
  • crates/hypercolor-types/src/api/displays.rs
  • crates/hypercolor-types/src/api/effects.rs
  • crates/hypercolor-types/src/api/layers.rs
  • crates/hypercolor-types/src/api/layouts.rs
  • crates/hypercolor-types/src/api/library.rs
  • crates/hypercolor-types/src/api/mod.rs
  • crates/hypercolor-types/src/api/profiles.rs
  • crates/hypercolor-types/src/api/settings.rs
  • crates/hypercolor-types/src/api/simulators.rs
  • crates/hypercolor-types/src/layer.rs
  • crates/hypercolor-types/tests/api_request_tests.rs
  • crates/hypercolor-ui/src/api/assets.rs
  • crates/hypercolor-ui/src/api/controls.rs
  • crates/hypercolor-ui/src/api/devices.rs
  • crates/hypercolor-ui/src/api/displays.rs
  • crates/hypercolor-ui/src/api/effects.rs
  • crates/hypercolor-ui/src/api/layers.rs
  • crates/hypercolor-ui/src/api/layouts.rs
  • crates/hypercolor-ui/src/api/library.rs
  • crates/hypercolor-ui/src/app/effect_state.rs
  • crates/hypercolor-ui/src/components/attachment_panel.rs
  • crates/hypercolor-ui/src/components/device_detail.rs
  • crates/hypercolor-ui/src/components/layer_panel/mod.rs
  • crates/hypercolor-ui/src/components/layout_builder/library_provider.rs
  • crates/hypercolor-ui/src/components/preset_panel.rs
  • crates/hypercolor-ui/tests/display_api_tests.rs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

hyperb1iss and others added 2 commits August 17, 2026 03:30
Naming the payloads into shared types changed what several clients put
on the wire wherever a hand-built body spelled an unset optional
differently from the shared struct. Nine bodies now omit a key they used
to send as an explicit null, because serde_json::json! renders None as
null while the shared types carry skip_serializing_if. Two move the
other way and state a key their predecessor omitted, because the shared
field carries serde(default) without skip_serializing_if.

Every field involved is an Option or carries serde(default), so absent,
explicit null, and empty all deserialize to the same Rust value and no
handler branches on which spelling arrived. That was an argument in the
PR body and is now a test: a macro decodes each affected type with the
fields absent and again with explicit nulls, then asserts the two agree.
Coverage spans the asset update, profile and scene creation, preset and
playlist saves, playlist items, and both layer requests, with the
pairing values map checked for the absent-versus-empty pair.

Co-Authored-By: Nova (Claude Opus 5) <noreply@anthropic.com>
The equivalence macro took field names as string literals, and no type
in the crate declares deny_unknown_fields, so serde silently ignored a
name that no longer matched the struct and the assertion held over
nothing. A verifier demonstrated it three ways: a field that never
existed, a misspelling, and a wrong name all passed green.

Fields are now identifiers. The macro binds each one through a closure
before building the payload, so a rename or a typo is a compile error,
and the JSON key comes from the same identifier via stringify. All three
attacks now fail to compile with E0609. The fixture also has to differ
from the null-bearing payload, which catches a base that already carries
the fields.

Co-Authored-By: Nova (Claude Opus 5) <noreply@anthropic.com>
@hyperb1iss
hyperb1iss merged commit 710f784 into main Aug 17, 2026
32 checks passed
@hyperb1iss
hyperb1iss deleted the nova/s76-w2.4-typed-payloads branch August 17, 2026 13:03
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