Skip to main content

REST

Konomi registers its REST endpoints through a small typed layer instead of raw register_rest_route() arrays. An endpoint is described as data — a route, a schema, a controller — and cross-cutting behavior (auth, error handling) is composed as a chain of middleware wrapped around the controller, rather than tangled into one handler.

It is a thin wrapper over WordPress' REST infrastructure: requests still flow through rest_api_init and use WP_REST_Request / WP_REST_Response / WP_Error.

Concepts​

Route. Binds a namespace and path to a Schema and a Controller, and carries the middleware to run around them. Built with Route::post(...) and finalized with register().

Schema. A JSON-Schema array. WordPress uses it to validate and sanitize request args before the controller runs and publishes it for endpoint self-description — so inside the controller, declared params are already checked.

Controller. The handler: one __invoke returning a response or an error.

Middleware chain. Each middleware receives the request and a $next callable. Call $next to continue inward; return a response or error to short-circuit. Attachment order is nesting order — the first middleware attached is the outermost wrapper. That is why error handling is attached first (it must wrap everything) and auth after it.

Using it​

Register an endpoint​

use SpaghettiDojo\Konomi\Rest;

add_action('rest_api_init', static function (): void {
Rest\Route::post('my-plugin/v1', '/ping', new PingSchema(), new PingController())
->register();
});

register() derives the validated args from the schema, publishes the schema for self-documentation, and sets the permission callback to allow-all — the route does not authenticate on its own. Gate access with the Authentication middleware or your own.

Wrap it in middleware​

Rest\Route::post('my-plugin/v1', '/ping', new PingSchema(), new PingController())
->withMiddleware($errorCatch) // outermost: wraps everything below
->withMiddleware($authentication) // runs after errorCatch, before the controller
->register();

The built-in middlewares​

Both are container services, so resolve them to get their dependencies wired:

  • Middlewares\Authentication — returns a 401 when the current user is not logged in, otherwise calls $next.
  • Middlewares\ErrorCatch — wraps $next in try/catch and turns any \Throwable into a safe 500 WP_Error. Attach it first so it is outermost.
$c = \SpaghettiDojo\Konomi\package()->container();

$route
->withMiddleware($c->get(Rest\Middlewares\ErrorCatch::class))
->withMiddleware($c->get(Rest\Middlewares\Authentication::class))
->register();

Write a middleware​

use SpaghettiDojo\Konomi\Rest;

final class RequireCapability implements Rest\Middleware
{
public function __construct(
private readonly string $capability,
private readonly Rest\ErrorFactory $errorFactory,
) {}

public function __invoke(\WP_REST_Request $request, callable $next): \WP_REST_Response|\WP_Error
{
if (!current_user_can($this->capability)) {
return $this->errorFactory->create('forbidden', 'Forbidden', ['status' => 403]);
}
return $next($request);
}
}

Write a schema and controller​

final class PingSchema implements Rest\Schema
{
public function toArray(): array
{
return [
'title' => 'ping',
'type' => 'object',
'properties' => [
'message' => ['type' => 'string', 'required' => true],
],
];
}
}

final class PingController implements Rest\Controller
{
public function __construct(private readonly Rest\ErrorFactory $errorFactory) {}

public function __invoke(\WP_REST_Request $request): \WP_REST_Response|\WP_Error
{
$message = (string) $request->get_param('message'); // already schema-validated
if ($message === '') {
return $this->errorFactory->create('bad_request', 'Empty message', ['status' => 400]);
}
return new \WP_REST_Response(['pong' => $message], 200);
}
}

Put the HTTP status in the error data (['status' => 4xx|5xx]) so WordPress sends the right code.

API​

All types are in SpaghettiDojo\Konomi\Rest (middlewares under Rest\Middlewares).

Route​

final class Route
{
public static function post(string $namespace, string $route, Schema $schema, Controller $controller): self;
public function withMiddleware(Middleware $middleware): self; // fluent; first attached = outermost
public function register(): void;
}

The constructor is private — start from Route::post(). POST is the only public factory today.

Controller​

interface Controller
{
public function __invoke(\WP_REST_Request $request): \WP_REST_Response|\WP_Error;
}

Schema​

interface Schema
{
/** @return array<mixed> JSON-Schema for the endpoint */
public function toArray(): array;
}

Middleware​

interface Middleware
{
/** @param callable(\WP_REST_Request): (\WP_REST_Response|\WP_Error) $next */
public function __invoke(\WP_REST_Request $request, callable $next): \WP_REST_Response|\WP_Error;
}

Return $next($request) to continue inward; return a response/error to short-circuit.

ErrorFactory​

final class ErrorFactory
{
public static function new(): ErrorFactory;
public function create(int|string $code = '', string $message = '', mixed $data = ''): \WP_Error;
}

Middlewares​

final class Middlewares\Authentication implements Rest\Middleware // 401 when not logged in
final class Middlewares\ErrorCatch implements Rest\Middleware // \Throwable -> 500 WP_Error

Resolve both from the container so their dependencies are injected.

  • Blocks — the front-end that calls these endpoints.
  • User — the domain the reaction/bookmark controllers drive.
  • Storage — where the saved data lands.