Skip to content

Emit camelCase keys in as_json() to match OTBR REST API spec#239

Closed
jnilo1 wants to merge 2 commits into
home-assistant-libs:mainfrom
jnilo1:fix/as-json-camelcase
Closed

Emit camelCase keys in as_json() to match OTBR REST API spec#239
jnilo1 wants to merge 2 commits into
home-assistant-libs:mainfrom
jnilo1:fix/as-json-camelcase

Conversation

@jnilo1

@jnilo1 jnilo1 commented Apr 4, 2026

Copy link
Copy Markdown

Summary

  • Add _to_camel_keys() (inverse of _normalize_keys()) and apply it in as_json() for Timestamp, SecurityPolicy, ActiveDataSet and PendingDataSet
  • PUT/POST requests now emit camelCase keys matching the OTBR REST OpenAPI spec
  • GET parsing unchanged — both camelCase and PascalCase still accepted via _normalize_keys()

Context

Commit 5e5197a (shipped in v2.9.0) fixed from_json() to accept camelCase GET responses, but as_json() still emitted PascalCase in PUT requests. Upstream ot-br-posix uses cJSON_GetObjectItemCaseSensitive to parse incoming payloads, so PascalCase keys are silently rejected — causing "Failed to call OTBR API" errors in Home Assistant when changing Thread channel or dataset.

Fixes #238

Test plan

  • All existing tests updated and passing (51/51)
  • New tests for as_json() camelCase serialization
  • Roundtrip test: from_json(as_json(x)) == x

Commit 5e5197a added _normalize_keys() to accept camelCase in GET
responses, but as_json() still emitted PascalCase in PUT requests.
The upstream ot-br-posix REST API uses cJSON_GetObjectItemCaseSensitive
to parse incoming payloads, so PascalCase keys are silently rejected.

Add _to_camel_keys() (inverse of _normalize_keys) and apply it in
as_json() for Timestamp, SecurityPolicy, ActiveDataSet and
PendingDataSet. GET parsing is unchanged (both cases still accepted).

Fixes #238

@agners agners left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, so this essentially breaks then older OpenThread border routers no?

From looking into the OTBR code, it seems that the (new and old) REST API simply ignores unknown keys. So we can make the library to work for both OTBR versions by simply emitting both capitalization format.

I'd rename _to_camel_keys to something like _to_dual_keys, which then emits both.

Ideally also add unit tests making sure both capitalization formats are emitted.

Small sidenote: routers is actually emitted upper case in OTBR, but it accepts lower case. Since we accept already both, this isn't a problem.

Address review feedback: emit BOTH capitalizations in as_json() so the
library works with both older ot-br-posix (PascalCase) and newer versions
(camelCase per the OpenAPI spec). Both REST API versions parse payloads
case-sensitively and silently ignore unknown keys, so each version reads
its preferred capitalization.

- Rename _to_camel_keys → _to_dual_keys, emit both variants per known key
- Update tests to assert both capitalizations are present at every level
- Add roundtrip test covering dual-key input to from_json()
@jnilo1

jnilo1 commented Apr 16, 2026

Copy link
Copy Markdown
Author

Thanks for the thoughtful review! You're absolutely right — emitting only camelCase would break older routers, and the dual-emit approach is a much better fit for a client library that has to talk to both old and new deployments.

I've pushed a new commit implementing this:

  • Renamed _to_camel_keys_to_dual_keys. For every known key, we now emit both PascalCase and camelCase variants pointing to the same value. Unknown keys pass through unchanged.
  • Both old and new ot-br-posix versions use cJSON_GetObjectItemCaseSensitive to parse incoming payloads and silently ignore unknown keys, so each version reads its preferred capitalization and ignores the other — single payload, zero server-side changes, works with both.
  • The helper is non-recursive: nested dicts come from nested as_json() calls that already return dual-key dicts, so we don't need to walk them again.
  • _normalize_keys() (used by from_json) handles dual-key input correctly — when both networkName and NetworkName are present with the same value, either one ends up as NetworkName after normalization.

Tests

  • Updated existing test_init.py serialization assertions via a small _dual() helper that expands a camelCase expected payload to the dual-key format, keeping the tests readable while exercising the real output.
  • Rewrote the as_json() tests in test_models.py to assert that both capitalizations are present at every level (top-level and nested) and that both point to the same value.
  • Added a roundtrip test (from_json(as_json(x)) == x) to confirm _normalize_keys handles dual-key input.

All 71 tests pass locally.

On Routers / routers

Good catch on the upstream Routers uppercase quirk — since _normalize_keys() already accepts both, and our dual emit puts Routers and routers both in the payload, we're covered on both sides of the wire regardless of which one upstream actually emits or expects.

@jnilo1 jnilo1 closed this Apr 19, 2026
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.

as_json() still emits PascalCase keys in PUT requests (counterpart of #227)

2 participants