openwrt-iac / uapi / docs / 3.0.0 / Migrating from uapi v1 to v2

Migrating from uapi v1 to v2

v2 is a single, deliberate breaking release. The cost is paid once; the payoff is a wire surface that is consistent (snake_case across the board), strictly typed (real JSON integers everywhere), and significantly larger (rate limit, metrics, idempotency, batch, JSON Patch, per-resource ETags + If-Match, token expiry / IP scoping / HTTP rotation, conditional GET, diagnostics).

A given uapi installation now serves exactly one API major - there is no parallel /api/v1/ and /api/v2/ mount in the same binary. Operators who need to keep a v1 client working keep the 1.2.1 package installed. The 1.2.1 APK is preserved on the v1.2.1 GitHub Release at https://github.com/openwrt-iac/uapi/releases/tag/v1.2.1; the signed v1.2.1 git tag is the canonical v1 contract document.

The URL prefix changes from /api/v1/ to /api/v2/. Update every client-side base URL accordingly. The install hook on a fresh apk add uapi over v1.2.x automatically removes the v1 prefix from uhttpd's ucode_prefix list and adds the v2 one.

Upgrade path

  1. Read this document end-to-end before installing v2. Specifically the "Field renames", "Strict integer typing", and "Strictness sweep" sections - those break the wire.
  2. Update your client code (Terraform provider, scripts, dashboards) to the v2 expectations. Test against a staging router.
  3. apk upgrade uapi. The install hook will uhttpd reload automatically; existing tokens in /etc/config/uapi are preserved (the file is a conffile).
  4. Verify GET /api/v2/healthz returns {"status":"ok", "version":"2.0.0", ...}. The new URL prefix is /api/v2/; the install hook handles the uhttpd prefix migration. v1 clients hitting /api/v1/ get a 404 from uhttpd because the old prefix is no longer registered (this is intentional - the wire contract changed; the URL should not silently lie about that).

If anything looks wrong in production, apk downgrade uapi=1.2.1 restores v1 behavior (the v1.2.1 APK stays available on the feed for this exact reason).

Field renames (Breaking)

The v1.x curated layer accumulated 16 fields in three resources that used upstream uci's casing instead of snake_case. v2 fixes them.

dropbear/instances

v1 field v2 field
Port port
PasswordAuth password_auth
RootPasswordAuth root_password_auth
RootLogin root_login
BannerFile banner_file
Interface interface
GatewayPorts gateway_ports

snmpd/system

v1 field v2 field
sysLocation sys_location
sysContact sys_contact
sysName sys_name
sysServices sys_services
sysDescr sys_descr
sysObjectID sys_object_id

vnstat/config

v1 field v2 field
DatabaseDir database_dir
Interface5MinHours interface_5min_hours
MonthRotate month_rotate

On disk, the uci keys are unchanged (Port, sysLocation, etc.). The rename is purely on the wire: fromUci exposes snake_case; toUci translates back to the upstream uci key.

dhcp/servers runtime block

v1 field v2 field
runtime.active_leases_v4_total runtime.active_leases_v4_box_total

The counter is box-wide, not per-server: dnsmasq's /tmp/dhcp.leases does not tag entries by interface, so every dhcp/servers section's runtime block reports the same total. The new name says so explicitly. The IPv6 counter (active_leases_v6_iface) is genuinely per-interface and was unaffected.

Wire-surface renames in v2.0.0-rc4

Three top-level field renames driven by Terraform / HCL keyword collisions. uci option names on disk are unchanged; only the JSON surface renames.

Resource v2.0.0-rc3 field v2.0.0-rc4 field Why
mwan3/interfaces count probe_count count is a Terraform reserved meta-argument; the provider cannot declare it as a top-level attribute.
firewall/zones output output_policy output is an HCL block keyword and reads funny as a Terraform attribute.
firewall/defaults output output_policy Same.
unbound/server resource resource_limits resource is an HCL block keyword. resource_limits matches the field's semantic (a memory/cache sizing preset).

network/interfaces runtime address keys (rc4)

ubus emits the address-set keys as hyphenated identifiers (ipv4-address, ipv6-address, ipv6-prefix); these were the only hyphenated identifiers anywhere in the API surface. Renamed to ipv4_address, ipv6_address, ipv6_prefix so every key is a legal struct field tag / HCL identifier without quoting. The runtime block is computed (clients ignore it for drift detection) so the rename costs nothing on disk; only the wire surface changed.

Strict integer typing (Breaking)

v1.x silently accepted string-form integers ("42") for fields declared type: "integer". The toUci layer happily stringified them again on the way back to uci, but the type check was a lie: a client that sent "forty-two" would also be accepted, with int("forty-two") = 0 silently dropped into uci. v2 closes the trap.

Layer v1 behavior v2 behavior
Wire "42" accepted; 42 accepted; "forty-two" accepted (silently → 0) only real integers 42 accepted; everything else → 422 validation_failed
fromUci int(s) (returns 0 on garbage) values.as_int(s) (returns null on garbage; surfaces real ints as JSON numbers)
toUci "" + n for any value "" + n only for validated integers

This affects every field declared with type: "integer" in v2's schema_properties - at v2 launch this is ~80 fields across ~20 resources. The most common-to-touch ones:

Run your client through a staging router and capture any 422 responses on PATCH/PUT to find the call sites that need updating.

Strictness sweep (Breaking, mechanical)

Some v1.x fields had no schema_properties entry at all and reached toUci unfiltered. v2's completeness sweep added typed entries for every fromUci-surfaced field. Bodies that previously slipped past the type check (silent drops) now return 422 validation_failed.

If you have wire traffic that was relying on a field being silently dropped (unlikely but possible), the 422 will tell you which field. The fix is to either remove the field from the payload or correct its type.

New endpoints

Endpoint Scope Notes
GET /healthz (extended body) (public) Now includes checks: { ubus, uci, ... }
GET /schema (public) Lists all resources
GET /schema/<package> (public) Schemas for one package
GET /schema/<package>/<res> (public) One resource's schema_properties
GET /auth/whoami (any authed token) Current token metadata
GET /tokens uapi:tokens:ro / *:ro List tokens (no secrets surfaced)
GET /tokens/<id> uapi:tokens:ro / *:ro One token's metadata
POST /tokens uapi:tokens:rw / *:rw Mint a new bearer (returns cleartext once)
DELETE /tokens/<id> uapi:tokens:rw / *:rw Revoke
GET /metrics uapi:metrics:ro / *:ro Prometheus text
GET /diagnostics uapi:diagnostics:ro / *:rw Version + lock state + recent errors
POST /batch each sub-request scope-checked Multi-package all-or-nothing

New headers

New error codes

HTTP code When
400 invalid_cursor malformed ?cursor=
403 scope_escalation_blocked POST /tokens requested scopes beyond caller's
409 idempotency_key_conflict same key, different body
429 too_many_requests rate limit (default 100/s burst 200)
4xx batch_partial_failure (body) /batch aborted; whole batch reverted

Clients should already branch on HTTP status and treat unknown code values gracefully (per the v1 error envelope spec); new codes need no client work unless you want specific UX.

New scopes

uapi, uapi:tokens, uapi:metrics, uapi:diagnostics. Wildcard *:rw continues to cover all of them; existing admin tokens require no change.

Rate limit defaults

100 req/sec, burst 200, per-token. Returns 429 too_many_requests with Retry-After. Operators expecting more traffic add:

config ratelimit
    option rate '500'
    option burst '1000'

to /etc/config/uapi. No reload needed - the config is read on every authed request.

Token expiry, IP scoping, and HTTP rotation

If you were rotating tokens via uapi-token revoke + uapi-token create, you can now do it over HTTP:

curl -H "Authorization: Bearer $OLD" -X POST https://router/api/v2/tokens \
  -d '{ "name": "ci-rotated", "scopes": ["firewall:rw"], "expires_in_seconds": 86400 }'
# response: { "bearer": "<cleartext>", "name": "ci-rotated" }

The requested scopes must be a strict subset of the caller's. expires_at (epoch seconds) and allowed_cidrs are optional extensions to the same uapi-token create CLI.

After expiry the token returns 401 invalid_token with message: "Token expired". Subsequent calls with a fresh bearer work immediately - the token store is re-read on every request.

Idempotency and conditional GET

Two safe additions for Terraform-style and dashboard-style clients:

Dependency-aware ETags (introduced in 2.0, since removed)

2.0 mixed a hash of every firewall:zone into the ETag of a firewall/rules GET, so changing a zone invalidated the rule's ETag. That was dropped again during the 2.x line, and this section described it as current until 2.5.0. the ETag helper in src/lib/handler.uc hashes the response body with runtime stripped and nothing else, so an ETag reflects only its own resource. Cross-resource consistency is enforced at resource.validate() time on every write instead; docs/architecture.md carries the current contract.

Declared dependencies at v2.0 launch:

If your client was already correctly handling 412 on If-Match writes, this is strictly more accurate - no behavior change required.

Things that did NOT change