Skip to content

ADR-0005: API conventions — gating, versioning, and error contract

Status

Accepted

Date

2026-07-06

Context

ADR-0004 chose DRF as the mobile API framework and its application style (thin views, logic in services). It did not fix the cross-cutting conventions every endpoint shares: how the API is protected from open internet traffic, how the app selects an API version, and the shape of error responses the Flutter client parses. These need to be settled once, before endpoints proliferate, so the contract is uniform and the Flutter team can code against it from day one.

Firebase (ADR-0003) already owns user identity. That is a separate concern from keeping non-client traffic off the API surface.

Decision Drivers

  • Must: one uniform error shape across every endpoint; a version-selection mechanism that doesn't churn URLs; coarse protection of the API surface independent of user auth; a typed OpenAPI contract for Flutter.
  • Should: match Fueled's established backend conventions (django-init, copa-backend) so the patterns are familiar; support key rotation without downtime; fail closed in production.
  • Nice-to-have: docs/schema available in dev, off in prod by default.

Considered Options

App gate: shared API key vs none vs per-user only

  • Shared X-API-Key header (chosen): a coarse gate the app carries; a list of keys allows rotation. Keeps random traffic off the surface without coupling to user identity. Guest-open content reads still work (no user needed), yet aren't wide open to the internet.
  • No gate: simplest, but the whole API is open to scanners/scrapers.
  • Per-user auth only: can't gate guest-open endpoints (content reads have no user), so those would be fully public.

Versioning: Accept header vs URL path

  • AcceptHeaderVersioning (chosen): Accept: …; version=1.0. No version segment in the path, so URLs are stable across versions. Matches the ADR-0004 note ("no version segment in the URL; version conveyed by header/config").
  • URL path (/api/v1/…): explicit and cache-friendly, but bakes the version into every route and forces URL churn on bumps.

Error contract: django-init envelope vs DRF default

  • django-init envelope (chosen): {"error_type", "errors": [{field?, message}]}. Consistent list shape, machine-readable error_type, per-field detail. Reused across Fueled backends, so it's familiar to reviewers and clients.
  • DRF default: inconsistent between field errors ({field: [msgs]}) and detail errors ({"detail": "…"}), which pushes shape-handling onto the client.

Decision

  • App gate: a shared API key in X-API-Key, validated against API_KEYS (a list, for rotation) by a default DRF permission. Empty list allows all in DEBUG (dev convenience) and denies all otherwise (fail closed). This is app-level protection, distinct from Firebase user auth, which layers on top.
  • Versioning: DRF AcceptHeaderVersioning, default and only allowed version 1.0, no path segment. An advisory api_version is also served from GET /api/config/ for the app to read on launch (not verified per request).
  • Error contract: a single envelope {"error_type", "errors": [{field?, message}]} produced by a custom DRF EXCEPTION_HANDLER, adapted from django-init. Django Http404/PermissionDenied are normalised into it.
  • Schema: drf-spectacular at /api/schema/ + Swagger/Redoc, gated by API_DOCS_ENABLED (defaults to DEBUG); scoped to /api/ (Wagtail's admin API is excluded from the mobile contract).
  • Code organization: the API layer partitions by domain sub-package, not by splitting a single views.py per endpoint. Each domain (e.g. content, auth) is a package under interfaces/api/ owning its own views.py and urls.py; the root interfaces/api/urls.py include()s them under a path prefix. Cross-cutting concerns (errors.py, permissions.py, schema.py) stay at the api root. App-level meta endpoints that belong to no domain (e.g. /api/config/, later health/version) stay flat in the root views.py. A domain earns its own package once it carries serializers, readers, or more than a trivial endpoint — trivial meta endpoints do not.

Consequences

Positive

  • One error shape and one version mechanism across the whole API; Flutter parses one contract.
  • Key rotation without downtime; production fails closed if misconfigured.
  • Conventions match other Fueled backends, lowering onboarding cost.

Negative

  • The API key is a shared secret shipped in the app binary — it deters casual traffic but is not a strong secret (see Risks).
  • Accept-header versioning is less visible than a URL segment and slightly harder to exercise in a browser/curl.

Risks & Mitigations

  • Risk: the shipped API key is extractable from the app binary. → Mitigation: it's a coarse gate, not user auth; sensitive actions require Firebase identity. Rotate via API_KEYS if a key leaks; consider attestation later if abuse appears.
  • Risk: schema exposed in production. → Mitigation: API_DOCS_ENABLED defaults to DEBUG (off in prod) and can be toggled per environment.
  • ADR-0003 — Firebase user auth (layered on this gate).
  • ADR-0004 — DRF choice and application style.
  • Reference implementations: Fueled django-init (error envelope, versioning), copa-backend (Firebase validation, dev-login).
  • docs/api-overview.md — reader-facing description of these conventions.