انتقل إلى المحتوى الرئيسي

REST API Design

Thirty-three endpoints control the device (the complete catalog with request and response shapes is the REST reference); this chapter is the design behind them, the part that transfers to any device API.

Layout: one file per domain

components/web_server/src/ holds rest_api_playback.c, rest_api_settings.c, rest_api_radio.c, and so on: each file registers its own routes and owns its own JSON. The alternative, one dispatcher with a giant table, was rejected for the usual reason: every feature merge collides in the same file. Adding a domain here means adding a file and one registration call.

Handlers follow a fixed skeleton, worth showing once because all thirty-three look like it:

static esp_err_t play_handler(httpd_req_t *req) {
if (!api_auth_require(req)) return ESP_OK; // 401 already sent
if (!rate_limit_allow(req, "playback", 20, 10000)) // 429 already sent
return ESP_OK;
// parse body (cJSON), validate every field at this boundary
// call the owning component's public API
// reply a small JSON object
}

The handler layer contains no business logic: it authenticates, validates, delegates to a component API, and serializes. Playback logic lives in the player; the API is a skin. That separation is what lets NFC, the encoder, and REST all drive the same functions with the same semantics.

Authentication

Every mutating endpoint requires Authorization: Bearer <token>. The scheme is deliberately minimal:

  • One token per device, 32 random bytes hex-encoded, generated on first boot from the hardware RNG, stored in NVS, and printed only on the serial console. Retrieving it requires physical USB access, which is exactly the property wanted: possession of the hardware is the root credential. It is never served over the network, and a factory reset mints a new one.
  • Constant-time comparison on every check (a length-then-memcmp early exit leaks timing; the classic mistake costs nothing to avoid).
  • No sessions, no cookies, no expiry. Statelessness costs the ability to revoke a single browser, and buys immunity to CSRF (no ambient credential for a hostile page to ride), zero session storage, and trivially correct multi-client behavior. For a single-owner appliance the trade is lopsidedly right; user accounts would be design theater.

The public/protected split follows one rule: reads that help a dashboard render before the user has pasted the token are public (GET /api/playback/status, redacted GET /api/settings, battery); everything that changes state or exposes secrets requires the token. The settings endpoint actively redacts (SSID yes, password never) so "public" stays honest.

Rate limiting

A small fixed table of counters (client, endpoint-class, window) enforces per-class budgets: strict on expensive or sensitive routes (OTA pull: 2 per minute; screenshots: 10 per 10 s), generous on status polls. The point on a LAN device is not brute-force defense (the token has 256 bits of entropy) but self-defense of the RAM budget: every in-flight request holds a TLS session, and a runaway dashboard loop or a misbehaving script must degrade into 429s, not heap exhaustion. Fixed table, no allocation, oldest-slot eviction.

Validation at the boundary

Every externally supplied value is validated in the handler, before it touches a component: volume clamped 0..100, seek must be a number, paths must resolve inside the SD mount (no ..), stream URLs pass the scheme/host/control-character checks described in streaming. Components trust their arguments; the API layer is the moat. One moat, consistently guarded, beats validation sprinkled through every component and is the reason fuzzing the API surface is boring (see testing).

Errors and shapes

  • JSON in, JSON out, always an object at top level ({"success":true} or {"error":"volume out of range"}), so clients never special-case scalars.
  • Status codes carry the semantics: 400 malformed, 401 missing/bad token, 404 unknown entity, 429 rate limited, 500 only for genuine device faults. Error strings are for humans and logs, not for parsing.
  • Mutations return the resulting state when it is cheap (POST volume answers with the applied volume), sparing clients a follow-up read.

Why REST plus WebSocket, not either alone

Commands want request/response semantics (did the seek apply?); state wants push (three open dashboards must agree within a beat). So commands are REST, state flows over the WebSocket, and the two never overlap in responsibility: a REST mutation's effect arrives at all clients, including the caller, as the same WebSocket event everyone else gets. Polling-only would waste radio and lag; WebSocket-only commands would reinvent request IDs and retries on top of a stream. The boring split is the correct one.