Skip to content

refactor(api): shared response types for effects and library (spec 76 wave 3.1c) - #205

Open
hyperb1iss wants to merge 4 commits into
mainfrom
nova/s76-w3.1c-effect-library-types
Open

refactor(api): shared response types for effects and library (spec 76 wave 3.1c)#205
hyperb1iss wants to merge 4 commits into
mainfrom
nova/s76-w3.1c-effect-library-types

Conversation

@hyperb1iss

@hyperb1iss hyperb1iss commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Wave 3.1c of the Spec 76 internal API unification. The effects domain, the library domain, and the attachment-template catalog each had their REST response shapes defined on the daemon side and re-declared by hand on the client side. This moves all of them into hypercolor-types::api, so there is one definition per shape and drift between the daemon and its clients becomes a compile error instead of a runtime parse failure.

One commit per domain, plus a fourth that pulls back the four responses whose payloads carry floats. The daemon re-exports every promoted type from the module it used to own, so daemon-internal paths like api::effects::RescanResponse still resolve and no handler construction site changed shape.

💎 What moves

Effects (hypercolor-types::api::effects). RescanResponse was already a struct. The other five were anonymous serde_json::json! literals: the stop acknowledgement, all three effect-to-layout link routes (GET, PUT, DELETE), and the controls reset.

Library (hypercolor-types::api::library). Five daemon-local structs (FavoriteSummary, FavoriteListResponse, PresetListResponse, PlaylistListResponse, ActivePlaylistResponse) and eight json! literals covering both delete acknowledgements, the favorite add, and the playlist activate, active, and stop acknowledgements.

Attachments (hypercolor-types::api::attachments). The catalog listing and its summary, the single-template detail returned by GET, POST, and PUT, the delete acknowledgement, and the category and vendor facet lists.

Twenty-one shapes in total. Client mirrors deleted: four in the web UI (PresetSummary, PresetListResponse, FavoriteSummary, FavoriteListResponse), two more in its devices client (TemplateSummary, TemplateListResponse), and two in the TUI's REST client (FavoriteListResponse, FavoriteSummaryResponse).

⚡ Why key order is the whole story here

serde_json is built with the preserve_order feature in the daemon's dependency graph, which arrives transitively through servo-script. Confirm it with cargo tree -e features -p hypercolor-daemon -i serde_json. That single fact makes JSON key order part of the wire contract: a json! literal emits keys in literal order, a derived struct emits them in declaration order, and converting one to the other with the fields in a different sequence is a wire change rather than a refactor. Every replacement struct declares its fields in its literal's exact order.

The second hazard is nulls. A json! literal emits "key": null for a None, so a replacement field carrying skip_serializing_if would silently drop the key. No promoted response field carries it. The serde(default) markers throughout affect deserialization only and leave what the daemon writes untouched.

The existing suite cannot see either hazard. rest_v1_compat_tests.rs's assert_keys sorts both sides before comparing, so it freezes the key set and never the key order, and every other daemon assertion is serde_json::Value-indexed and therefore order-agnostic. Nothing in the tree compares raw JSON strings. Shape preservation was proven instead with a standalone harness that serializes each promoted type with to_string under preserve_order and compares the resulting strings against the literals reconstructed verbatim from the pre-change source: 38 checks, all passing, with three negative controls (a swapped field order, an added skip_serializing_if, and a direct preserve_order liveness probe) that all trip as required. Types that were already structs are compared against verbatim replicas of their old definitions rather than through json!, for the float reason covered in the next section.

🔥 Four responses stay literals, because floats reprint

There is a third hazard beyond key order and explicit nulls, and it is the one that nearly shipped. json! routes every value through serde_json::to_value, which stores an f32 as an f64. The widening is visible in the printed digits: 0.1f32 becomes 0.10000000149011612, while a derived struct writes the f32 straight to the output and prints 0.1. Promoting a literal that carries a float therefore changes the bytes.

Four responses carry one, so they keep their literals and are not promoted in this wave: both control PATCH bodies (/effects/active/controls and /effects/{effect_id}/controls), the control binding PUT, and the preset apply. Their payloads are ControlValue and ControlBinding, which means the reprint would land on every slider value, every color picker, every gradient stop, and all six binding bounds rather than on some rare edge case. Each site carries a comment saying why it was left alone, and the web UI keeps its narrow ControlsVersionResponse decode for the same reason.

The daemon is already inconsistent with itself here. GET /api/v1/effects/active returns a shared struct and so emits 0.1, while the PATCH route emits 0.10000000149011612 for the same stored value. Converging them is a real improvement and a real wire change, so it belongs in a proposal with a test that pins the chosen format, not in a lockstep wave.

The suite could not have caught this. Every float in the daemon's assertions for these routes is exactly representable (7.5), and f32 as f64 == f64 holds for those, so the assertions pass either way.

🦋 The attachment catalog fix, and why it is client-side

The web UI's TemplateSummary mirror had drifted from what the daemon sends. It had no image_url field at all, so catalog artwork was invisible to it. It typed origin as an Option<ComponentOrigin> where the daemon sends the value unconditionally, which left built-in and user-authored templates indistinguishable at the type level. And its TemplateListResponse dropped the pagination envelope entirely, so the UI could not page a catalog it fetches with a hardcoded limit=200.

Deleting those mirrors in favour of the daemon's real shapes hands the UI all three. This is a client capability change, not a wire change: those fields were always on the wire, only unread. The daemon's serialized bytes for the catalog routes are identical before and after, which the string harness covers directly.

The UI's create_attachment_template was also decoding the POST response, which is a full TemplateDetail, into the narrower summary. It now decodes the detail, so a newly created template's topology and LED positions survive the round trip.

🎯 Tolerance is preserved, not narrowed

Promoting a mirror must not shrink the set of JSON a client accepts, so every deleted mirror's serde(default) markers were carried onto the shared type. Two of them landed outside the API modules: EffectPreset::created_at_ms and updated_at_ms in hypercolor-types::library gain defaults because the UI mirror they replace tolerated their absence. Defaults are deserialize-only, so the daemon still writes both fields unconditionally.

That change also shrinks a blast radius. The whole library file parses in one from_str, so under the old strictness a single preset missing a timestamp failed the entire load, taking favorites and playlists with it.

The list responses mark pagination with serde(default) for the same reason, which is why Pagination now derives Default. The deleted client mirrors had no pagination field at all and ignored the envelope, so without it a body omitting pagination would newly fail to parse in both clients. Pagination's explanation sits in a line comment rather than a doc comment because utoipa publishes the doc comment as the schema description, and editing it moves the generated OpenAPI client.

Three narrowings are accepted rather than papered over, none of which breaks against the real daemon. The preset record's id and effect_id are now the PresetId and EffectId newtypes instead of bare strings, so a non-UUID id no longer parses. Its controls map is ControlValue instead of raw JSON. And TemplateSummary.origin is a bare ComponentOrigin, so a missing key still defaults to BuiltIn but an explicit null now errors where it previously yielded None. These are the daemon's wire truth, which the contract module takes as its rule.

What is deliberately unchanged

No ToSchema derives were added. None of these routes carry a utoipa::path attribute and none of their types appear in components(schemas), so a derive would emit nothing while registering them would publish orphan schemas. just python-generate-check passes with no movement in the generated client.

The UI keeps its local ActiveEffectResponse. It is a non-optional convenience projection built from the shared wire type rather than a mirror of it, and it stays documented as such.

The install route's validation-error payload stays an anonymous literal. It is a detail object inside the error envelope rather than a response body, and the error surface belongs to a different domain.

Zero edits under crates/hypercolor-daemon/tests/. The compat matrix and API pins pass as written.

🧪 Gates

Gate Result
just verify 5170 passed, 0 failed
cargo clippy --workspace --all-targets -- -D warnings exit 0
UI cargo check --all-targets exit 0
UI cargo check --target wasm32-unknown-unknown exit 0
just ui-test 332 passed, 0 failed
just python-generate-check exit 0, no diff under python/
Shape harness 38 passed, 0 failed, 3 negative controls plus an f32 screen
Daemon test edits none

render_thread_tests is flaky under a full-workspace run: the pipeline_gpu_* cases intermittently die with a SIGSEGV at process exit when every test binary is competing for the GPU. The suite passes 46 of 46 when the crate runs on its own, twice in a row, and this branch touches no render or engine code. It is the flake already tracked against the render thread, not a regression here.

Running cargo clippy -- -D warnings inside crates/hypercolor-ui fails on two lints, too_many_arguments in src/app/effect_state.rs and collapsible_if in src/pages/settings.rs. Both files are byte-identical to main on this branch and no workflow runs clippy against the UI crate, so this is standing debt rather than anything this branch introduced. It is left alone.

🔮 Proposals, not changes

Shape improvements are out of scope under the wave's lockstep doctrine, so these are written down rather than made.

Converge the float formatting. The four held-back responses should eventually be promoted, which means deciding that 0.1 is the right output and pinning it with a daemon test that asserts a raw body containing a non-representable value. That also settles the existing disagreement between GET /effects/active and the PATCH routes.

The six list endpoints with fabricated pagination (effects, favorites, presets, playlists, scenes, profiles) still hardcode offset: 0, limit: 50, has_more: false and discard any paging query parameters. The compat suite freezes that behaviour deliberately. Making them page for real is its own change with its own compatibility story.

PATCH /api/v1/effects/active/controls returns effect as a bare name string while every sibling route returns an { id, name } reference. The promoted type records the inconsistency in its doc comment rather than fixing it.

The category and vendor facet lists carry no pagination envelope while the template listing does.

Nothing in the tree compares raw JSON strings, so no in-tree test can catch a key-order, explicit-null, or float-formatting regression on the REST wire. All three hazards in this wave were found by hand-built harnesses rather than by the suite. One golden raw-body fixture per promoted route would turn the whole class into ordinary test failures, and with several promotion waves still to come it is worth its own wave.

The UI's ActiveEffectResponse projection types id and name as non-Option while the wire types them as Option for the idle body, so it cannot deserialize the daemon's own idle response. fetch_active_effect decodes the shared type first and maps idle to None, so nothing is broken today, but the projection is a decode-failure shape waiting for a caller that skips that step.

Summary by CodeRabbit

  • New Features
    • Added support for richer attachment template catalog data, including pagination, categories, vendors, compatibility, and physical details.
    • Added structured responses for effect controls, layouts, stopping effects, playlist actions, favorites, presets, and deletions.
  • Bug Fixes
    • Improved preset creation and application handling.
    • Preserved compatibility when loading presets with missing timestamp fields.
  • Improvements
    • Standardized API data across the daemon, UI, and REST client for more consistent behavior.

hyperb1iss and others added 4 commits August 17, 2026 10:28
Nine effect responses move into hypercolor-types::api::effects so the
daemon and both clients read one definition instead of a struct on the
daemon side and a hand-rolled decode on the client side. RescanResponse
was already a struct; the other eight were anonymous serde_json::json!
literals covering stop, the three effect-to-layout link routes, both
control PATCH routes, the control binding PUT, and the controls reset.

Shape-preserving, deliberately. serde_json is built with preserve_order
in the daemon's graph, so a json! literal emits keys in literal order
and a struct emits them in declaration order: every replacement struct
declares its fields in the literal's exact order. A literal also emits
an explicit null for a None, so no field carries skip_serializing_if,
which would drop the key instead. The serde(default) markers are
deserialize-only and preserve client tolerance.

The web UI's private ControlsVersionResponse decoded the effect-id
controls PATCH through a one-field projection; it now reads the shared
UpdateEffectControlsResponse, which carries the applied and rejected
control maps the projection was discarding.

Co-Authored-By: Nova (Claude Opus 5) <noreply@anthropic.com>
Fourteen favorites, presets, and playlists responses move into
hypercolor-types::api::library. Five were daemon-local structs and nine
were anonymous serde_json::json! literals: both delete acks, the
favorite add ack, the preset apply ack, and the playlist activate,
active, and stop acks. Field order in every replacement struct matches
its literal's key order, because preserve_order makes that order part
of the wire, and no field carries skip_serializing_if.

The mirrors the clients kept were narrower than what the daemon sends,
so deleting them widens what those clients can see. Both the web UI and
the TUI dropped the pagination envelope from their favorites and preset
list decodes; the TUI's favorite rows kept only effect_id, discarding
the resolved effect name and the timestamp; the UI's PresetSummary
retyped the preset record's controls map as raw JSON values rather than
the typed ControlValue the daemon serializes. The preset routes return
the stored EffectPreset verbatim, so the UI now decodes that.

EffectPreset gains serde(default) on its two timestamps. The deleted UI
mirror tolerated their absence, and promoting a shared type must not
narrow the set of JSON a client accepts. Defaults only affect
deserialization, so the daemon still serializes both unconditionally.

Co-Authored-By: Nova (Claude Opus 5) <noreply@anthropic.com>
The template catalog's eight response shapes move into
hypercolor-types::api::attachments: the listing and its summary, the
single-template detail returned by GET, POST, and PUT, the delete ack
that was a json! literal, and the category and vendor facet lists.
Declaration order matches the daemon's prior order in every case.

This closes real drift rather than only deduplicating. The web UI's
TemplateSummary mirror had no image_url field at all, so the catalog's
artwork was invisible to it, and it typed origin as an Option where the
daemon sends the value unconditionally, which made every built-in
template indistinguishable from a user-authored one at the type level.
Its TemplateListResponse dropped the pagination envelope, leaving the
UI unable to page a catalog it fetches with a hardcoded limit of 200.
Deleting those mirrors hands the UI all three. The daemon's bytes do
not move: these fields were always on the wire, only unread.

create_attachment_template also decoded the POST response, which is a
full TemplateDetail, into the narrower summary. It now decodes the
detail, so a freshly created template's topology and LED positions
survive the round trip.

Co-Authored-By: Nova (Claude Opus 5) <noreply@anthropic.com>
Adversarial review caught a wire change in the first pass. `json!` routes
every value through `serde_json::to_value`, which stores an f32 as f64,
and the widening is visible in the printed digits: 0.1f32 becomes
0.10000000149011612. A derived struct writes the f32 straight out as
0.1. So promoting a literal that carries a float reprints it, on top of
the key-order and explicit-null hazards the wave already accounted for.

Four responses carry f32 and stay literals: both control PATCH bodies,
the control binding PUT, and the preset apply. Their payloads are
`ControlValue` and `ControlBinding`, so the reprint would hit every
slider, color, gradient stop, and binding bound rather than some edge
case. Each site says why it was left alone, and the web UI keeps its
narrow controls-version decode for the same reason. The remaining
twenty-one promotions are byte-identical and unaffected.

The list responses now mark `pagination` with serde(default), which
needs Default on Pagination. The client mirrors this wave deletes had no
pagination field at all and ignored the envelope, so without the default
a body omitting it would newly fail to parse in both the web UI and the
TUI. Pagination's explanation sits in a line comment because utoipa
publishes the doc comment as the schema description, and editing it
moves the generated OpenAPI client.

Also documents why the UI keeps a local ActiveEffectResponse: it is a
projection that unwraps the wire type's Option id and name for consumers
that have already branched on `state`, not a mirror of the wire shape.

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

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 93cbcf17-133d-437f-ab08-ab0397f54718

📥 Commits

Reviewing files that changed from the base of the PR and between d4f886b and 8b35c2c.

📒 Files selected for processing (18)
  • crates/hypercolor-daemon/src/api/attachments.rs
  • crates/hypercolor-daemon/src/api/effects.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-tui/src/client/rest.rs
  • crates/hypercolor-types/src/api/attachments.rs
  • crates/hypercolor-types/src/api/common.rs
  • crates/hypercolor-types/src/api/effects.rs
  • crates/hypercolor-types/src/api/library.rs
  • crates/hypercolor-types/src/library.rs
  • crates/hypercolor-ui/src/api/devices.rs
  • crates/hypercolor-ui/src/api/effects.rs
  • crates/hypercolor-ui/src/api/library.rs
  • crates/hypercolor-ui/src/components/attachment_editor.rs
  • crates/hypercolor-ui/src/components/preset_panel.rs
  • crates/hypercolor-ui/tests/attachment_editor_tests.rs
  • crates/hypercolor-ui/tests/component_picker_tests.rs

Included review availability: Your plan includes up to 1 review per rolling hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The change centralizes API response contracts in hypercolor-types and updates daemon, TUI, and UI modules to use them. Several endpoints now return typed responses. Preset deserialization and attachment-template handling also changed.

Changes

Shared API contract unification

Layer / File(s) Summary
Shared response contracts
crates/hypercolor-types/src/api/*, crates/hypercolor-types/src/library.rs
Added shared attachment, effect, favorite, preset, and playlist response types. Added default pagination and optional preset timestamps.
Typed attachment and effect endpoints
crates/hypercolor-daemon/src/api/attachments.rs, crates/hypercolor-daemon/src/api/effects.rs
Re-exported shared contracts and replaced several anonymous JSON responses with typed values. Floating-point control responses remain JSON-shaped.
Typed library endpoints and TUI decoding
crates/hypercolor-daemon/src/api/library/*, crates/hypercolor-tui/src/client/rest.rs
Replaced local library response models and anonymous delete or runtime responses with shared contracts.
UI model adoption and behavior updates
crates/hypercolor-ui/src/api/*, crates/hypercolor-ui/src/components/*, crates/hypercolor-ui/tests/*
Re-exported shared UI models, changed preset operations to return EffectPreset, updated attachment-template checks, and aligned fixtures with ComponentOrigin.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk: ⚪ Minimal · up to 8b35c

This PR centralizes shared API response types and updates clients without introducing an evidenced correctness or deployment risk; it is merge-ready after normal checks and review, with no actionable merge-blocking risk remaining.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately identifies the main API refactor for shared response types in effects and library domains, although it omits attachment-template changes.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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.

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