openwrt-iac / uapi / docs / 2.5.1 / Adding a curated resource

Adding a curated resource

This is the workflow we used for every resource in src/resources/. Following it should take an hour for a typical config type.

The reference implementations live in src/resources/; firewall.rules.uc is the most-featured (cross-reference validation, nested match block) and system.uc is the simplest (singleton, flat fields).

1. Pick the uci package and section type

Look at /etc/config/<package> on a real router. Find the section type you want to expose. Note the option names, which are lists vs. scalars, which are uci-bools ("1"/"0"/"on"/"off"), and which reference other sections.

2. Create the resource module

src/resources/<package>.<plural-type>.uc. Schemas live inline in the resource module, not in separate JSON Schema files. schema_properties is the centrally-enforced type/enum/range/pattern/items table (handler.check_schema_types walks it on every write and returns 422 on shape mismatches before validate() runs); validate() carries the cross-field, cross-section, and format-string logic that pure JSON Schema can't express (e.g. "src_zone must reference an existing zone").

Export the uniform contract:

return {
    package: "<package>",              // uci package
    type: "<section-type>",            // uci section type
    reload: ["<service>"],             // ubus services to reload on write
    fromUci: function(section, conn) { ... }, // uci section dict -> response JSON
    toUci:   function(json) { ... },          // request JSON -> uci option dict
    validate: function(json, conn, id) { ... return []; }, // {field, code, message}[]
    schema_properties: { ... },        // type/enum/min/max/pattern/items; enforced centrally

    // OpenAPI hints (consumed by build/gen_openapi.uc, not by the runtime):
    openapi_required:    ["field1", "field2"],    // emitted as `required: [...]` on the JSON Schema
    openapi_conditional: [                        // emitted as `allOf: [...]`; if/then/required
        { if:   { properties: { proto: { const: "static" } } },
          then: { required: ["ipaddr"] } },
    ],
    openapi_runtime: {                            // replaces the opaque `runtime: {type: object}`
        type: "object",
        properties: { up: { type: "boolean" }, ... },
        description: "Populated from ubus ...",
    },

    // Optional, less common:
    merge_for_patch: function(existing_json, body) { ... },  // nested-object merge
    resolve_for_replace: function(body) { ... },   // PUT only; resolve two wire names for one uci option. See "Mirrored field pairs" below.
    type_predicate:  function(t) { ... },  // dynamic-type resources (e.g. wireguard_<iface>)
    create_type:     function(body) { ... },
    id_prefix: "x",                    // single char for generated IDs (defaults to type[0])
    id_for_create:   function(body) { ... return null; },  // runs only when body.id is unset; return a proto-specific name (e.g. wireguard's wg_<rand> short fallback) or null to fall through to ULID
    create_if_missing: true,           // singletons only; opt-in. PATCH creates the uci section if absent instead of returning 404. See "Singletons that may be wiped" below.
    singleton_section_name: "main",    // singletons only; default "main". Override only when the underlying uci convention names the section differently.
    unique_field: "name",              // optional. Value of this field must be unique among same-type sections in this package. See "Cross-section reference fields" below.
};

Section name (id) at create time

Every CRUD resource accepts an optional id field at create time as of 2.2.0; you do not need to opt in. The framework reads body.id, runs section-name validation (charset ^[A-Za-z][A-Za-z0-9_]{0,31}$, in-package uniqueness across all section types), and uses it as both the uci section name and the response id. When the caller omits id, the framework falls back to a server-emitted ULID (or the result of your module's id_for_create hook if you registered one).

id_for_create is the place to inject proto-specific fallbacks. network/interfaces uses it to emit a 14-char wg_<rand> for proto=wireguard because Linux IFNAMSIZ caps netifd's netdev name at 15 chars. Most resources don't need this hook; the default ULID is fine.

If your resource type binds the section name to something kernel- or daemon-constrained beyond the framework's 32-char default cap, tighten in your own validate() (per-resource refinement runs after the framework check).

Singletons that may be wiped (create_if_missing)

For resources whose underlying uci package can be deleted by an operator without uapi noticing (typical pattern: an extension package whose conffile is the only source of the section, like the unbound-uci-ext packages uapi 2.1.0 introduced), set create_if_missing: true on the resource module. handler.make_singleton.patch then creates the uci section on the fly instead of returning 404. The created section's name defaults to main; override via singleton_section_name if the daemon expects a different convention.

Most singletons (system, dhcp/dnsmasq, firewall/defaults, etc.) should NOT opt in: their config sections ship with the package they wrap, and a missing section there is a real problem worth surfacing as 404 so the operator notices.

Cross-section reference fields (unique_field)

If your resource has a uci option whose VALUE other sections reference (rather than referencing this section by its section id), or whose duplication would break the daemon, declare it with unique_field. The framework rejects creates and modifies whose value collides with another section's, returning 422 conflict with the offending section named in the error message.

Concrete shapes that need this flag:

What does NOT need the flag:

The flag is scope-correct: same package, same section type. A firewall.zone with name="lan" does not conflict with a firewall.rule with name="lan" because different daemons read different fields. The check runs on POST, PUT, and PATCH; PATCH and PUT exclude the section being modified, so updates that keep the value unchanged still pass.

The flag is string-only. The runtime check guards type(val) == "string" so non-string fields are silently skipped today; if your resource has a numeric or list-typed cross-reference key, the helper would need extending. Dynamic-type resources (those that declare type_predicate to match a family of section types like wireguard_<iface>) cannot declare unique_field; the framework will refuse to load such a module so the latent footgun does not ship.

Resources can keep their own per-validate uniqueness logic in place; unique_field is opt-in.

A separate question: should validate() reject a cross-resource reference (e.g. dhcp.servers.interface naming a non-existent network.interface section)? Default: no. Stock OpenWrt ships configs that reference resources absent on the running target (config dhcp wan against an absent network.wan on x86 generic), and daemons typically tolerate dangling refs (the section is silently inactive). Rejecting them would be stricter than the platform. Validate cross-refs only when the daemon itself errors on a dangling ref, not because the operator might typo; in practice no curated resource currently does.

Server-side defaults (default:) and clear-on-omit safety (x-uapi-clear-on-omit:)

If your fromUci synthesizes a value for an absent uci option (via normalize_bool(section.X, true), section.X ?? "literal", or similar), declare the same fallback as default: in the field's schema_properties entry:

auto: { type: "boolean", default: true,
        description: "Bring this interface up at boot" },

This is standard OpenAPI 3.1 / JSON Schema 2020-12 documentation. Clients (Redoc, openapi-codegen, Terraform provider) read it to understand which fields the server populates on their behalf. The runtime validator at handler.uc:_check_value does NOT apply default:; it is purely documentation. fromUci owns server-side defaults; the framework MUST NOT silently fill absent fields from the spec or PATCH-delta semantics break.

Only annotate unconditional defaults. Conditional defaults (e.g. network.interfaces.peerdns defaults to true only under proto=dhcp) stay un-annotated because the literal value misleads under other protos.

For a field that is caller-owned and safe for an IaC client to clear by omitting it from config, also add "x-uapi-clear-on-omit": true:

netmask: { type: ["string", "null"], "x-uapi-clear-on-omit": true,
           description: "IPv4 netmask (static proto)" },

A Terraform provider can read this flag and emit explicit JSON null on Update when the operator's config omits the attribute, which clears the uci option. The flag enforces two hard constraints (the framework's lint-defaults verifies both):

  1. fromUci shape: the field's assignment in fromUci's returned dict must be exactly <jsonkey>: section.<ucikey> ?? null. No as_list() (returns [] for null, not null itself), no derivation, no aliasing to another field. The Terraform plugin-framework rejects the apply with "Provider produced inconsistent result after apply" if a plain Optional attribute reads back any value for an absent uci option other than null.

  2. Nullable type: the type: declaration must include "null" (e.g. type: ["string", "null"]). The provider sends explicit JSON null to clear; a non-nullable type fails the spec itself.

Safe (passes the lint):

gateway: { type: ["string", "null"], "x-uapi-clear-on-omit": true,
           description: "IPv4 default gateway (static proto)" },
// fromUci: gateway: section.gateway ?? null

Unsafe (lint fails):

// fromUci: dns: as_list(section.dns)      <-- returns []; lint shape violation
// fromUci: ipaddr: ipaddr_first           <-- derived; lint shape violation
// schema:  dns: { type: "array", "x-uapi-clear-on-omit": true }   <-- non-nullable; lint type violation

Conservative scope: only annotate when there is evidence a field is leftover-prone (e.g. survives an adopt + proto switch). The initial set in 2.2.3 is network/interfaces netmask and gateway. The originally-considered set in 2.2.2 also included ipaddr/ipaddrs/dns, but those fail the shape/type constraints above and were dropped; see docs/deprecations.md and the openwrt-iac/uapi#3 thread for the design discussion on how to handle the aliased/array-typed cases.

A field cannot be both default: and "x-uapi-clear-on-omit": true. Defaulted fields would cause perpetual non-converging diffs if the provider treats them as clearable (apply clears uci, fromUci re-defaults, next plan diffs again). Either the field has a server-side default (sticky) or the operator fully owns it (clearable); never both.

fromUci and toUci form a (lossy) bijection: round-tripping a section through both should produce the same uci options. fromUci may take a second conn arg to read ubus state for the runtime: {...} block; resources that don't need it ignore the extra arg.

validate runs on every write inside the per-package flock. It must return an array of errors (each {field, code, message}), one per problem; the caller translates to a 422 response. Codes come from a fixed set: required, invalid_type, invalid_format, out_of_range, not_in_enum, conflict, read_only.

schema_properties is the source of truth for type/enum/min/max/pattern/items shape checks - the central handler.check_schema_types walks this on every write and 422s shape mismatches BEFORE validate() runs. Per-field constraints that fit (type, enum, range, pattern, items recursion) belong here; cross-field / cross-section / format-string logic stays in validate().

openapi_required, openapi_conditional, openapi_runtime are picked up by build/gen_openapi.uc and produce a richer machine-readable contract for code generators (Terraform provider, OpenAPI client libs). The runtime never reads them. Mirror what validate() enforces: unconditional if (json.X == null) push(errs, {required}) goes in openapi_required; if (json.proto == "static" && json.ipaddr == null) becomes an openapi_conditional if/then/required block. For resources that populate a non-empty runtime: {...}, define its sub-shape in openapi_runtime so clients can see the keys without reading source.

For cross-reference validation (e.g. "this firewall rule's src_zone must be a real zone"), use the conn argument. See firewall.rules.uc's load_zones(conn) for the pattern.

3. JSON conventions

Per CLAUDE.md:

Where snake_case stops

uapi mirrors uci option names verbatim, EXCEPT for the v2.0 rename sweep (dropbear, snmpd, vnstat) and a handful of Terraform- collision renames in rc4 (mwan3.interfaces.count -> probe_count, firewall.{zones,defaults}.output -> output_policy, unbound.server.resource -> resource_limits, network.interfaces.runtime.ipv4-address -> ipv4_address).

Smushed uci names (dynamicdhcp, commonname, expandhosts, boguspriv, readethers, domainneeded, leasefile, resolvfile, defaultroute, clientid, reqprefix, agentaddress, localservice, linklayer, zonename, etc.) are KEPT verbatim. Two reasons:

  1. uci fidelity. A field named expandhosts greps cleanly against /etc/config/dhcp; renaming to expand_hosts would split the mental model between the API surface and what an operator sees on the router.
  2. The rename cost is real. Every wire-surface rename takes a migration-guide entry, breaks downstream clients, and forces an RC cycle. The consistency gain doesn't pay for the disruption when the existing name is already canonical to uci.

Two name shapes WILL be renamed:

If you're curating a new uci option, default to the uci name. If it collides with a Terraform/HCL keyword or actively misleads, rename and document.

Write-only fields: <field> + has_<field> convention

Sensitive fields (passphrases, private keys, PSKs) follow a uniform pattern: the field is write-only on the wire, and a read-only companion has_<field>: bool indicates presence on GET responses.

Examples:

Implementation: fromUci masks the field (key: null or omit) and sets has_key: section.key != null && section.key != "". toUci passes the field through when present. The framework restores the old value after the merge, so an unrelated PATCH (e.g. changing verb on an openvpn instance) doesn't wipe the credential when the field is absent from the request body. That is carry_write_only in handler.uc, not merge_for_patch: the merge only ever sees the masked read view, which is why the hook takes no uci section.

Mark the field writeOnly: true and the companion readOnly: true in schema_properties so a generator can distinguish them statically.

Never interpolate a write-only value into a validate() message. The validation sweep behind GET /diagnostics?validate=1 restores masked secrets before validating, because otherwise every section holding one reports its own secret as missing. That makes error messages the one channel where a real secret can reach a caller who holds only :ro on the resource and can never see it through a GET. Name the field and describe the expected shape ("must be a 44-char base64 WireGuard private key"), never the value.

Mirrored field pairs

When one uci option is exposed under two wire names, every write path has to tolerate a faithful full replace that changes only one of them. Two resources do this today: network/interfaces mirrors the first entry of list ipaddr into ipaddr alongside the full ipaddrs, and dhcp/hosts splits one list mac across macs, mac and mac_aliases.

Do not add a mirror to expose a field. Add one only to remove one. The dhcp/hosts triple is the second case and exists for that reason: mac and mac_aliases were already a mirrored pair, a positional split of a list that every other resource would surface as an array, and they cannot be dropped inside a major. macs is the single writable name they collapse into, so the third name is what pays for retiring the other two. A transitional mirror is allowed when it carries a docs/deprecations.md row naming its removal target; a mirror added for a caller's convenience is not, because the cost below is permanent while the convenience is not.

Marking a field deprecated has three parts and two of them are enforced. Set deprecated: true in schema_properties, which is what codegen reads, and open its description with Deprecated, removed in vN: <why>, which is what an operator sees in a plan warning and is the only place the reason exists; lint-openapi-shape fails on the flag without a reason, and on a notice with no text after the colon. Then add the row or bullet to docs/deprecations.md and the matching entry to the "Upcoming in v3" block in build/gen_openapi.uc, which lint-doc-refs compares by count, so the two cannot drift apart. A field whose API name is not its uci option also needs an entry in tests/lint_wire_names.uc, with the reason as the value.

The read mirror puts both names into the caller's state, so a full-replace client sends both back whether or not its own config named them, with the one it did not change now stale. Rejecting the pair on that ground makes the field unwritable for every such client, which is the failure ipaddrs hit in 2.4.1.

It belongs to a wider family: a full replace destroys or blocks whatever the read view does not faithfully return. 2.4.0 fixed two other members, an unmodelled src_dip dropped from every SNAT redirect it round-tripped and masked write-only secrets erased by any PUT that omitted them.

The three methods need different answers:

Resolving silently on PUT is only safe when the winner is documented and toUci already prefers it, so the resolution changes no uci outcome. If that is not true for a new pair, fix the precedence first.

4. Add unit tests

tests/unit/<package>_<type>_test.uc. Test fromUci, toUci (including round-trip), and validate for required-missing, enum-violation, and format cases.

For cross-reference validation, use the bus stub:

let bus = require('bus');
let c = bus.stub({
    uci: { firewall: { z_lan: { '.type': 'zone', name: 'lan' } } }
});
let errs = mod.validate({ ... }, c);

5. Register the resource

src/main.uc has a RESOURCES registry (CRUD) and a SINGLETONS registry (single-section types like system). Both go through load_resource, which registers the source module into RESOURCE_SOURCES (the thing backing /schema/<...>) and derives the filename from the key by swapping : for .:

"<domain>:<plural-type>": handler.make(load_resource("<domain>:<plural-type>")),

So the module file has to be named <domain>.<plural-type>.uc, matching the key. That is not a new convention, it is what all 45 resources already do; the filename simply is not written a second time.

Use handler.make_singleton for singletons, handler.make_collection for read-only runtime lists. Writable resources also automatically become eligible for POST /batch via main.uc's BARE_RESOURCES / BARE_SINGLETONS construction loop - no extra step needed. Read-only collections are excluded from batch (they have no toUci).

6. Add an integration test

tests/integration/<NN>_<resource>_test.sh. Use the install helper:

. tests/integration/lib/install_uapi.sh
install_uapi
# ADMIN_TOKEN, RO_TOKEN, FW_RO_TOKEN are exported.
# Drive via curl; check status code and key fields in the response body.

Cover at least: POST creates, GET reads, validation failure returns 422, DELETE returns 204.

7. Regenerate OpenAPI

make openapi

This walks the resource modules and emits build/openapi.json. Add an entry for the new resource in build/gen_openapi.uc's ENDPOINTS list. The lint job in CI gates against drift, so commit the regenerated openapi.json alongside the resource.

8. Add a curl example

examples/curl/<resource>.sh. One POST + GET + PATCH cycle, ending with a "to delete: ..." reminder. The suite is a representative subset rather than one file per resource, so this is expected when the new resource shows a shape the existing fifteen do not, and optional when it is another instance of one they already cover.

9. Update docs/tokens.md

If the new resource introduces a new scope path (e.g. a new package), add it to the scope tree table.

Curation completeness

When adding or extending a curated resource, the test is: does this resource expose the options that a typical real configuration of this section actually sets? If a common real-world setup of this section requires uci options the curated resource does not surface, that is a curation gap; close it. Telling users to drop to /raw/ for a routine field is a smell.

Validation should not be stricter than the platform

uapi's validate() is for catching client mistakes early (typos, missing required fields, cross-resource references that won't resolve) and for surfacing well-formed 422 errors instead of letting uci fail mid-commit. It is NOT a venue for inventing constraints the underlying uci/netifd/daemon doesn't have.

Concrete recurring temptation: "this bridge has no ports / this firewall rule has no match / this static interface has no ipaddrs, surely that's an error?" Sometimes it is, sometimes the operator is staging an incremental configuration (Terraform's create-before-reference ordering, an empty bridge whose members get added later, an interface that's intentionally up but unconfigured). uapi 2.2.0-rc2 fixed exactly this antipattern in network.devices (a type=bridge without ports was being rejected even though uci/netifd accept it without complaint). Don't add a validate() check just because something "feels off"; first check whether uci accepts it. If uci does, uapi should too.

If a real protocol-level constraint applies (e.g. proto=wireguard genuinely cannot work without a private_key, the kernel will reject it), that's worth catching upfront. If it's just "feels incomplete to me", let the platform decide.

Things to watch for

See docs/ucode-quirks.md for the full running list of language and runtime gotchas.