Visitar URL original
PoC: typed REST contract with DRF + drf-spectacular, piloted on /rest/rooms by jamesmulcahy · Pull Request #397 · NSPManager/NSPanelManager · GitHub
Skip to content

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

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

jamesmulcahy wants to merge 2 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.

It builds on the test harness from #388, which has merged.

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: 223 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

@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.

jamesmulcahy and others added 2 commits October 25, 2026 07:58
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>

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