Repository navigation
PoC: typed REST contract with DRF + drf-spectacular, piloted on /rest/rooms - #397
jamesmulcahy wants to merge 19 commits into
Conversation
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>
|
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.
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. |
|
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 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
Panels and |
Summary
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 thedisplay_orderbug, 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:
2c507016Pilot Django REST Framework + drf-spectacular on /rest/roomsd14c1c43Use a generated, typed client for /rest/rooms in the React UICompare view of just those two commits. Until #388 is merged, the "Files changed" tab here also shows #388's changes.
How it fits together
After changing a serializer, run
docker/web/generate_api.shand commit both generated files.CONTRIBUTING.mddescribes the workflow.What changed
Django
GET/PUT/POST /rest/roomsandDELETE /rest/rooms/<id>are now DRFAPIViews with serializers. The URLs and the{"status": ...}envelope are unchanged, so the UI's error toasts still work.display_orderis mapped to the model'sdisplayOrderonce, inRoomSerializer. That fixes theroom_putbug from Add a Django test harness and initial REST API tests #388: sendingdisplay_orderwas ignored and sendingdisplayOrderreturned a 500.{"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.REST_REJECT_UNKNOWN_FIELDS = True, which turns them into a 400 so a test whose request body has drifted from the API fails./rest/schema.React
RoomsStore,EntityStoreandRoomPageuse the generated client. The three hand-copied room interfaces are replaced by the generatedRoomtype.CI
web/tests/test_openapi.pyfails ifopenapi.yamldoesn'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.api-typesjob fails ifschema.d.tsdoesn't matchopenapi.yaml.What it catches
register("displayOrder")in the room formtscerrorroom.displayOrderfrom a responsetscerror: "did you meandisplay_order?"/rest/room) or a PUT body missingidtscerrorapi.PUT(..., { body: { id: 1, displayOrder: 7 } })bodygenerically, which turns off TypeScript's excess-property check for inline literals. The server logs a warning. Typed bodies, such as the form data fromuseForm<RoomUpdateRequest>(), are checked.Things to note
jsonschema, which pulls inrpds-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. Onlymanage.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.npx(npm run generate:api-types), not as a devDependency. It declares atypescript ^5peer and the UI is on TypeScript 6.package-lock.jsongains only theopenapi-fetchentries; every npm version I tried rewrote the rest of the file.bun.lockwas already out of sync withpackage.json(e.g. zod), so I left it alone.Not in this PR
/rest/*endpoints. If we go ahead, they could be ported a few at a time; the light entity save is the obvious next one.Test plan
docker/web/run_tests.sh: 226 tests pass. Thedisplay_order@expectedFailurefrom Add a Django test harness and initial REST API tests #388 is gone.npx tsc -bpasses; the vite build succeeds.api-typescheck passes on this branch and fails after editingopenapi.yamlwithout regenerating.generate_api.shreproduces the committed files exactly.api-typesjob green in Actions.🤖 Generated with Claude Code