Skip to content

PoC: typed REST contract with DRF + drf-spectacular, piloted on /rest/rooms - #397

Draft
jamesmulcahy wants to merge 19 commits into
NSPManager:develfrom
jamesmulcahy:pilot/drf-rooms
Draft

jamesmulcahy wants to merge 19 commits into
NSPManager:develfrom
jamesmulcahy:pilot/drf-rooms

Conversation

@jamesmulcahy

@jamesmulcahy jamesmulcahy commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Status: proof of concept, no rush to merge. This is here to evaluate the DRF + drf-spectacular approach; it isn't a priority to land. See this comment for the dependency options and the /rest vs panel endpoint question.

A proof of concept of the approach @tpanajott suggested on #388: use Django REST Framework + drf-spectacular to generate an OpenAPI schema from the Django views, and generate the React UI's TypeScript types from that. It's piloted on one endpoint, /rest/rooms, the one with the display_order bug, so we can see what it looks like before deciding whether to roll it out.

This branch is based on #388 so it can use the test harness that PR adds. Only the last two commits are specific to this PR:

  • 2c507016 Pilot Django REST Framework + drf-spectacular on /rest/rooms
  • d14c1c43 Use a generated, typed client for /rest/rooms in the React UI

Compare view of just those two commits. Until #388 is merged, the "Files changed" tab here also shows #388's changes.

How it fits together

web/serializers.py + DRF views in web/rest.py
        │  drf-spectacular
        ▼
docker/web/openapi.yaml                      (committed)
        │  openapi-typescript
        ▼
react-components/src/api/schema.d.ts         (committed)
        │  openapi-fetch
        ▼
api.GET / api.PUT / … in react-components/src/api/client.ts

After changing a serializer, run docker/web/generate_api.sh and commit both generated files. CONTRIBUTING.md describes the workflow.

What changed

Django

  • GET/PUT/POST /rest/rooms and DELETE /rest/rooms/<id> are now DRF APIViews with serializers. The URLs and the {"status": ...} envelope are unchanged, so the UI's error toasts still work.
  • display_order is mapped to the model's displayOrder once, in RoomSerializer. That fixes the room_put bug from Add a Django test harness and initial REST API tests #388: sending display_order was ignored and sending displayOrder returned a 500.
  • All errors go through one exception handler and have the shape {"status": "error", "message": ..., "errors"?: {field: [msg]}}. Bad input is now a 400 and a missing room a 404; before, most of it was a 500. PUT also now enforces the 30-character name limit.
  • Unknown request fields are logged and ignored, not rejected. Rejecting them would break saves whenever the UI and the server are different versions. The test settings set REST_REJECT_UNKNOWN_FIELDS = True, which turns them into a 400 so a test whose request body has drifted from the API fails.
  • PUT only saves and reloads MQTTManager if a value actually changed. The UI sends every field on each save, so pressing Save without editing used to reload every panel's config.
  • The schema is also served at /rest/schema.

React

  • RoomsStore, EntityStore and RoomPage use the generated client. The three hand-copied room interfaces are replaced by the generated Room type.

CI

  • web/tests/test_openapi.py fails if openapi.yaml doesn't match the views, or if drf-spectacular warns about a view it can't fully describe, which would otherwise end up as a loose type.
  • The new api-types job fails if schema.d.ts doesn't match openapi.yaml.

What it catches

Mistake in the UI Result
register("displayOrder") in the room form tsc error
Reading room.displayOrder from a response tsc error: "did you mean display_order?"
Wrong URL (/rest/room) or a PUT body missing id tsc error
Inline literal api.PUT(..., { body: { id: 1, displayOrder: 7 } }) Compiles. openapi-fetch infers body generically, which turns off TypeScript's excess-property check for inline literals. The server logs a warning. Typed bodies, such as the form data from useForm<RoomUpdateRequest>(), are checked.

Things to note

  • DRF 3.17.1, not 3.18. DRF 3.18 needs Django ≥ 5.2 and the repo pins 5.1.1.
  • Not all pure Python. drf-spectacular depends on jsonschema, which pulls in rpds-py, a Rust extension. PyPI has Python 3.12 wheels for x86_64, i386, aarch64 and armv7; only the armhf release build would compile it from source. Only manage.py spectacular (schema generation) needs it, not the running app, so it can be kept out of the image. See the linked comment for options.
  • openapi-typescript runs via pinned npx (npm run generate:api-types), not as a devDependency. It declares a typescript ^5 peer and the UI is on TypeScript 6.
  • Lockfiles. package-lock.json gains only the openapi-fetch entries; every npm version I tried rewrote the rest of the file. bun.lock was already out of sync with package.json (e.g. zod), so I left it alone.

Not in this PR

  • The other ~35 /rest/* endpoints. If we go ahead, they could be ported a few at a time; the light entity save is the obvious next one.
  • The STOMP payloads from MQTTManager. OpenAPI doesn't cover those.

Test plan

  • docker/web/run_tests.sh: 226 tests pass. The display_order @expectedFailure from Add a Django test harness and initial REST API tests #388 is gone.
  • npx tsc -b passes; the vite build succeeds.
  • Locally, the api-types check passes on this branch and fails after editing openapi.yaml without regenerating.
  • generate_api.sh reproduces the committed files exactly.
  • Click through the Room page against a running instance.
  • api-types job green in Actions.

🤖 Generated with Claude Code

jamesmulcahy and others added 19 commits September 25, 2026 20:53
Adds web/tests/ with a shared NSPMTestCase harness and 97 tests covering
rooms, entity create/edit (lights, switches, buttons, thermostats, scenes)
and entity/page layout, exercised through the same REST endpoints and
payloads the React UI uses.

The harness:
- runs each test against an isolated in-memory database
- replaces the MQTTManager process-control hooks with a recorder, so tests
  can assert the manager was told to reload and can never signal a real
  nspm_mqttmanager process on the host
- fakes Home Assistant (at the HTTP layer) and OpenHAB, and fails any other
  network request
- provides assertLoadableByManager(), which checks saved rows have the
  shape MQTTManager parses when it reads the database

Known bugs and validation gaps are pinned as @expectedfailure tests with a
KNOWN BUG / KNOWN GAP comment, so the suite passes today and flags when a
fix lands (remove the decorator then).

settings.py now reads the data directory from NSPM_DATA_DIR (default
/data, unchanged in the container) so the app can start outside Docker.

Run with docker/web/run_tests.sh; CI runs it on PRs touching docker/web.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds 113 tests (210 total) covering the rest of the web app:

- test_pages: every full page and htmx partial renders, including on a
  fresh install, and the temperature-sensor pickers route to the
  configured source
- test_settings: settings form (secret handling, trailing slashes,
  defaults), weather & time, theme, the first-run wizard, the settings REST
  endpoints and web.settings_helper
- test_panels: accept/deny/delete/unblock, the per-panel settings form and
  the legacy api/ panel endpoints
- test_relay_groups: form views, htmx endpoints and the REST listing
- test_files: firmware/LittleFS/TFT upload, checksum and download,
  including which TFT each panel type gets and a contract test for the
  exclusive Range end the panel firmware depends on
- test_misc: messages, HA/OpenHAB entity lookups, hostname lookup and
  read-only listings

New known issues are pinned with @expectedfailure as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…itive

banned_setting_keys lists secrets in upper case ("HOME_ASSISTANT_TOKEN"),
but settings are stored in lower case ("home_assistant_token") and the
comparison was case-sensitive, so /rest/mqttmanager/settings returned the
MQTT password and Home Assistant/OpenHAB tokens it was meant to withhold.

Nothing in the repo calls this endpoint (MQTTManager reads settings from
the database directly), so refusing these keys breaks no caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
These views changed config MQTTManager reads from the database but never
called send_mqttmanager_reload_command(), so panels kept the old config
until something else triggered a reload:

- DELETE /rest/scenes/<id>
- PUT /rest/entities_pages/<id> (page resize)
- htmx/nspanels/<id>/delete
- htmx relay group save and delete

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- POST /rest/rooms now creates the room's entities and scenes pages, as
  save_new_room does, instead of leaving them to the next restart. The
  per-room part of create_entity_pages_for_all_rooms() is split out as
  create_entity_pages_for_room() so both paths share it.
- The name must be 1-30 characters (Room.friendly_name's max_length,
  which SQLite does not enforce).
- DELETE /rest/rooms/<id> moves the room's panels to another room, as
  delete_room does, and tells MQTTManager to reload. Creating a room now
  reloads too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each entry was saved as it was read, so an unknown id part-way through
the list returned an error but left the entries before it already moved,
a half-applied layout. Run the updates in one transaction so a failed
request changes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The /rest/entities/* endpoints stored room_view_position and
entities_page_id as given, so the UI (or any other caller) could:

- put two entities in the same slot;
- put one in a slot past the end of its page, where the panel never
  shows it;
- put an entity on another room's page.

get_placement_error() checks all three and the endpoints return 400
with a message. Only a new or changed placement is checked, so an
entity already saved in a bad slot can still be edited in place.

PUT /rest/rooms/<id>/entities/order now also rejects slots past the end
of a page. It does not check for occupied slots, because a swap moves
two entities through each other's slot in one request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The entity endpoints stored controller and scene_type as given. MQTTManager
logs an error for an entity whose controller its class does not handle
and skips a scene with an unknown scene_type, so these saved fine but
never worked on a panel. Now rejected with 400:

- a controller the entity type does not support (e.g. an OpenHAB button);
- an unknown scene_type;
- a Home Assistant or OpenHAB scene with an empty backend_name, which
  could never be activated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rink

- Creating a page (room or global) with any other size is rejected with
  400; the panel GUI only has those three layouts.
- PUT /rest/entities_pages/<id> checks the new size the same way, and
  refuses to shrink a page while an entity or scene sits in a slot past
  the new end. Those entities stayed attached to the page but were never
  shown on the panel. The UI already only offers 4/8/12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The method check in entities_thermostats() and entities_scenes() was
commented out, so a GET, POST or DELETE was handled as a save. Restore
it, and answer unsupported methods on all five /rest/entities/* save
endpoints with 405 rather than 403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- GET /rest/settings returned 500 until the Home Assistant token, the
  OpenHAB token and the MQTT password had all been saved at least once,
  because it del'd each key without checking it existed (e.g. OpenHAB
  never configured). Missing secrets now report *_set: false.
- GET /rest/scenes?scene_id=N checked for a light_id parameter, then
  filtered on scene_id, so the filter was ignored and every scene came
  back.
- The checksum_firmware, checksum_data_file and checksum_tft_file
  endpoints returned 200 with the body "None" when no image had been
  uploaded. They now return 404 with an empty body. The panel firmware
  does not check the status code, so for a panel this only changes the
  string it compares against its stored checksum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- api/get_nspanel_config always returned 500: it read
  button1/2_detached_mode_light, which were renamed to
  button1/2_detached_mode_entity. Use the new names, and fix the else
  branch writing -1 to "button1_detached_mode_light" instead of the
  "button1_detached_light" key the other branch sets. Kept rather than
  removed because panels on older firmware may still call it.
- Remove htmx/partial/nspanel_index_view/<id>, which always raised
  ImproperlyConfigured (nspanel_status_header has no template for that
  URL name), and htmx/partial/edit_entities_page/<id>, whose template was
  removed in the move to React, along with its save/<page_type> route and
  the edit button in the old htmx entities-page component that linked to
  it. Nothing in the current UI reaches any of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The htmx first-run wizard and the index panels partial are gone; the
  React InitialSetup now saves everything with one POST /rest/settings.
  Test that endpoint (saves, only touches submitted keys, rejects bad
  payloads, reloads once) and that the index shows InitialSetup until a
  manager address is configured.
- /rest/mqttmanager/settings (POST) was removed; keep the banned-key
  tests for the per-key GET endpoint.
- GET /rest/rooms now returns friendly_name, display_order and the
  temperature sensor fields; PUT /rest/rooms updates a room. Pin the
  display_order/displayOrder mismatch in room_put as a known bug.
- Thermostats now require use_current_temperature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… install

NSPManager#391 replaced the htmx first-run wizard with the React InitialSetup and
removed its htmx_initial_setup_* URLs, but base.html still included
modals/initial_setup/welcome.html whenever manager_address is empty.
That template reverses the removed URLs, so on a fresh install every
page, including the index that shows the new wizard, raised
NoReverseMatch and returned 500.

Remove the include and the now-unreferenced modals/initial_setup
templates, and the index.html script tag for initial_setup.js, which
NSPManager#391 deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Write JUnit XML from the test run in CI (via unittest-xml-reporting, enabled
by NSPM_TEST_JUNIT_DIR) and publish it with action-junit-report. annotate_only
is used because creating a check run needs checks: write, which pull requests
from forks don't get.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The React scene picker fetches /rest/home_assistant/entities and offers
the scene.* and script.* items. Check that scripts are listed there, and
that a script can be saved as a room or global scene through
/rest/entities/scenes in a form the manager can load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Port GET/PUT/POST /rest/rooms and DELETE /rest/rooms/<id> from hand-parsed
function views to DRF APIViews with serializers, keeping the URLs and the
{"status": ...} response envelope.

- Serializers declare the wire format. RoomSerializer maps display_order to
  the model's displayOrder in one place, which fixes the room_put bug where
  display_order was ignored and displayOrder returned 500.
- Errors go through one exception handler: {"status": "error", "message",
  "errors"?: {field: [msg]}}. Validation failures are 400 and a missing room
  is 404; previously most bad input was a 500.
- Unknown request fields are logged and ignored, for compatibility between UI
  and server versions. REST_REJECT_UNKNOWN_FIELDS (on in test_settings only)
  rejects them with a 400, so tests whose bodies drift from the API fail.
- PUT only saves and reloads MQTTManager if a value actually changed; the UI
  sends every field on each save.
- drf-spectacular generates docker/web/openapi.yaml (also served at
  /rest/schema). test_openapi fails if the committed file is stale or if
  spectacular emits any warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- src/api/schema.d.ts is generated from openapi.yaml by openapi-typescript;
  openapi-fetch turns it into a client whose URLs, methods, request bodies
  and responses are type-checked. RoomsStore, EntityStore and RoomPage use
  it, and the hand-written room interfaces are replaced by the generated
  Room type, so renaming a serializer field breaks the UI build.
- docker/web/generate_api.sh regenerates openapi.yaml and schema.d.ts.
- New api-types CI job fails if schema.d.ts is stale with openapi.yaml
  (test_openapi covers openapi.yaml vs the views).
- CONTRIBUTING.md: "Changing the REST API" - the generation chain, what to
  run, what CI checks, and conventions for DRF views and UI callers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tpanajott

tpanajott commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Hey, this looks awesome but I'm currently looking through PR #388 and it's taking a while. I'll try and have a read through this when that's done. I'm not sure as to what is the best cause here since I believe there are some endpoints in /rest that the NSPanel relies on and would be better left alone but those are perhaps not affected by the change to DRF. The hold endpoints are also a bit of hit or miss regarding structure and maybe it's better to create a new rest/v2/ endpoint that follows a more logical layout as to how resources are created, updated, fetched and so on and then build out the test suite and React parts along side it.

Regarding Python version I'm not against updating, mostly I'm for it but we probably need to also update the Django version then and I've not had time to look into a for a while.

Not all pure Python. drf-spectacular depends on jsonschema, which pulls in rpds-py, a Rust extension. Wheels exist for the usual platforms; 32-bit targets would build it from source.

This may be a problem. We had this issue previously where we tried using ZeroMQ for communication between Django and MQTTManager instead of the manager having to reload everything all the time but the ZeroMQ dependency we used in Django had to have the wheel built for some ARM architectures and that caused the built to timeout after 6 hours in Github.

@jamesmulcahy

Copy link
Copy Markdown
Contributor Author

Thanks Tim, no rush at all. To be clear about where this sits: I put it up as a proof of concept to try out your DRF + drf-spectacular suggestion from #388, not as something I'm pushing to merge soon. I think it shows the approach solves the problem we were worried about. With the display_order/displayOrder kind of mismatch, the generated types make the TypeScript build (tsc) fail instead of the request failing at runtime. My near-term focus is wrapping up the media player work, so this can wait until #388 is done and you've had time to think about it. A few answers to your questions in the meantime:

Python/Django: this needs neither upgrade. It runs on the current Python 3.12 image and Django 5.1.1, which is why it pins DRF 3.17.1. DRF 3.18 needs Django 5.2, but that's optional, and Django 5.2 still supports Python 3.12. So a Django upgrade can happen separately whenever you want it.

rpds-py: I overstated this in the description. PyPI has Python 3.12 wheels for x86_64, i386, aarch64 and armv7. The armhf release job is the only build that would compile it from source, but that's the same situation as ZeroMQ, so it shouldn't go into the image. drf-spectacular only needs jsonschema/rpds-py for the manage.py spectacular command, which generates the schema. The running app doesn't use them. I confirmed that by running the test suite and /rest/schema in an environment without them. Options:

  1. drf-spectacular as a dev/CI-only dependency (my preference). Drop the /rest/schema endpoint, since the committed openapi.yaml is the source of truth. Move the spectacular settings into a schema-only settings file. Make the @extend_schema import optional, falling back to a no-op decorator. The image then only gains DRF, which is pure Python.
  2. Keep drf-spectacular in the image, but install it with pip install --no-deps. Fewer code changes, but it relies on drf-spectacular never importing jsonschema at runtime.
  3. Pin jsonschema to 4.17.3, the last release before rpds-py. It works, but we'd be stuck on a 2022 release.

Panels and /rest: I checked all the firmware branches, including the older firmware. Panels don't call /rest/*. The current firmware uses /download_*, /checksum_* and /api/get_album_cover. Older firmware also calls /api/get_nspanel_config. Those endpoints are in views.py/api.py, which this doesn't touch, and I'd treat them as frozen since deployed panels depend on them. /rest/* is only used by the React UI, which ships in the same image, so the /rest/rooms conversion here is backward compatible. If we later want a cleaner URL layout, we could change each resource's URLs in the same PR as the React code that calls them. A /rest/v2/ is a reasonable design choice too, but compatibility doesn't require it. Were there specific /rest endpoints you had in mind for the panels? If so, I may have missed something.

This branch has not been deployed

No deployments
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.

2 participants