Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Building JSON APIs

Overview

Sukarix includes a small set of building blocks for machine-facing JSON endpoints, as opposed to the session-backed, form-submitting web actions covered elsewhere in these docs:

  • Stateless routing — opt a path prefix out of session creation entirely.
  • Sukarix\Actions\ApiAction — a CSRF-exempt base action answering every error path with a JSON envelope.
  • Sukarix\Http\Cors — origin allow-listing and preflight handling.
  • Sukarix\Actions\Health\Probe — liveness/readiness endpoints for orchestrators and load balancers.
  • Sukarix\Security\CapabilityToken — HMAC-signed, short-lived tokens for machine-to-machine calls.

None of this replaces session-based authentication for browser-facing routes; it is meant for endpoints called by other services.

Stateless Routes

By default every request boots a session. ApiAction is normally paired with a route under SECURITY.stateless.prefixes (/api by default), which skips that step entirely — see Session-Free Routes for how to configure it, including how to turn it off for an API that authenticates through the session itself.

Note

$this->session becomes nullable on a stateless route

Boot::$session is null for the lifetime of a stateless request. Action::beforeroute() and getRole() already guard every access, but any code you write that reaches for $this->session on such a route must check for null first.

The ApiAction Base Class

Sukarix\Actions\ApiAction extends Action with defaults suited to a JSON API:

use Sukarix\Actions\ApiAction;

class Users extends ApiAction
{
    public function show($f3, $params): void
    {
        // ...
        $this->json(['id' => $params['id'], 'name' => $user->name]);
    }
}
  • $csrfExempt is true by default — a machine caller holds no session token to send.
  • onAccessAuthorizeDeny() answers a denied route with a JSON 403 instead of the HTML flow Action uses.
  • onError() is a static handler you can register as ONERROR for API routes; it turns any uncaught error into the same JSON envelope, using ERROR.code and ERROR.text from the hive:
{ "success": false, "message": "Not Found", "status": 404 }

Wire it up for your API prefix from your own Bootstrap, for example by checking PATH before calling Debugger::enable() in handleException().

CORS

Sukarix\Http\Cors::handle() answers a preflight OPTIONS request for an allow-listed origin and tags every response Vary: Origin:

[api.cors]
origins[] = https://app.example.com
origins[] = https://admin.example.com

ApiAction::options() calls it automatically whenever api.cors.origins is configured, and falls back to a plain 204 otherwise:

Cors::handle(
    $f3,
    $allowedOrigins,
    allowedMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
    allowedHeaders: ['Content-Type', 'Authorization']
);

An origin not on the allow list is simply ignored — no CORS headers are sent, and the browser enforces the same-origin policy as usual.

Health Probes

Sukarix\Actions\Health\Probe exposes liveness and readiness checks, both CSRF-exempt and, when reached under a stateless prefix, session-free:

[routes]
GET @health_live  : /api/health/live  = Sukarix\Actions\Health\Probe->liveness
GET @health_ready : /api/health/ready = Sukarix\Actions\Health\Probe->readiness
  • liveness() answers 200 with { "status": "ok", "request_id": "..." } — use it to know the process is up.
  • readiness() additionally pings Redis when CACHE is Redis-backed (redis=...), answering 503 with "status": "degraded" when a dependency is unreachable.

Capability Tokens

Sukarix\Security\CapabilityToken issues and verifies compact, HMAC-signed tokens for a machine caller to present as a Bearer token — no session, no database round trip to verify:

use Sukarix\Security\CapabilityToken;

// Issuing side
$token = CapabilityToken::issue($secret, ['iss' => 'billing', 'aud' => 'jobs'], ttl: 3600);

// Verifying side
$claims = CapabilityToken::verify($secret, $token, audience: 'jobs');
if (null === $claims) {
    $this->error('Unauthorized', 401);
}

A token is a base64url payload and signature joined by a dot (payload.signature), signed with HMAC-SHA256. verify() returns null — never throws — when the signature does not match, the token is malformed, it has expired (exp claim), or it was issued for a different audience.

Warning

The signing secret is the only thing standing between “verified” and “forged”

Keep it out of version control (an untracked .ini file, per Configuration) and rotate it if it ever leaks. CapabilityToken does not manage secret storage or rotation for you.

Bearer Authentication

Action::parseHeaderAuthorization() strips both the Basic and Bearer schemes from the Authorization header. isApiUserVerified() still assumes Basic-encoded user:password credentials (see Authentication); for a Bearer-scheme token, read HEADERS.Authorization directly in your action and hand the token to CapabilityToken::verify().

Request Bodies

Action::getDecodedBody() accepts a JSON body, a form-encoded body, or nothing at all — a form-encoded body is detected by inspecting the first byte, and an empty body falls back to POST. This keeps data validation endpoints working the same way whether the caller is a browser form, a JSON API client, or a Statera test.

Testing JSON Responses

Response::json() calls exit after writing the body, which is normally what you want — nothing accidentally runs after a response has already been sent. Under Environment::isTest() that exit is skipped, so a Statera test can call an action and make assertions afterward.

When an action needs to compose the body itself — wrapping it, streaming it, or handing it to something else — Response::renderJson() behaves like json() but returns the encoded string instead of echoing and exiting:

$body = $this->response->renderJson(['status' => 'ok']);

Request Correlation

Every request is stamped with a request id that flows into your logs; see Logging for how to read and forward it.