Exposing API Endpoints
Publish your module's capabilities as REST endpoints by adding route entries to one hook, with no core edit.
Overview
Authentication, per-endpoint permissions, rate limiting, CORS, idempotency and request logging already exist. You declare which address does what, and the Kernel does the rest before it calls your method.
What the installation owner sees is a checkbox: each endpoint becomes a line on the API credentials screen, and a key only reaches the endpoints its owner ticked.
Settings -> API credentials -> Create
[ ] GET /admin/mymodule/items
[x] POST /admin/mymodule/items/{id}/rebuild <- only this one was granted
[ ] DELETE /admin/mymodule/items/{id}
Prerequisites
- A module with a
hooks.phpfile; see Registering Hooks from a Module. - For write endpoints, an existing panel operation to bridge to rather than a second implementation.
- Module
src/classes are not autoloaded;include_onceanythinghooks.phpreferences.
Structure
$audience (see the surface table).
Module: and hands the request to your module instance.
Walkthrough
Declare the Surface in One Place
- Create
src/ApiSurface.phpwith aGROUPconstant and amap()listing every endpoint: verb, path, action, target. - Derive the routes and the permission catalogue from that list, so route, scope and checkbox cannot drift.
- Write literal paths before their parametric twins. The router takes the first match at a given segment count.
Register the Routes
- In
hooks.php, add a listener onfilter:api.routesand return when the audience is not yours. - Append your tuples to
$routes. Both parameters arrive by reference, so append, do not return. - Publish the permission catalogue from the body of
hooks.php, outside every listener.
Write the Handler Methods
- Add one
api_{action}method per endpoint. A hardmethod_existscheck means__callwill not answer. - Give every method the same one-line body forwarding to your bridge, and generate them from the declaration.
- Return a
Response, or a legacy envelope array that the Kernel normalises. - For a list, keep the envelope identical to the core resources:
page,limitandsearchin,meta.total,meta.page,meta.limitandmeta.next_pageout.
Bridge Writes to the Panel Operation
- Mirror the superglobals, call
op_{name}, capture what it prints, then restore them in afinallyblock. - Translate the printed envelope: drop
data.html, movemessageintometa, and turn a thrown exception into a validation error. - Let the operation's privilege gate step aside during an API call; the Kernel applied a narrower check.
Reference
The Hook and the Route Tuple
// The listener. Both arguments are references; append to $routes and return nothing.
Hook::add('filter:api.routes', 20, function (array &$routes, string &$audience): void {
if ($audience !== 'admin') return; // 'admin' | 'client' | 'module'
// ...
});
// One entry. Index 6 exists only on the free surface.
// [0] string $method GET | POST | PUT | PATCH | DELETE
// [1] string $pattern full path after the surface prefix; {x} captures a path parameter
// [2] string $group 'Module:{Type}/{Name}' routes to your module instance
// [3] string $action the permission name AND the api_{action} method suffix
// [4] bool $public false = a credential is required (default false)
// [5] bool $authOnly false = ALSO enforce the scope "Group/Action"
// default false on admin/client, TRUE on the free surface
// [6] string $audience free surface only: 'admin' (default) | 'client' | 'any'
$routes[] = ['POST', 'mymodule/items/{id}/rebuild', 'Module:Addons/MyModule', 'item_rebuild', false, false];
| Surface | Audience value | Address the entry answers on |
|---|---|---|
| credentialed admin | admin | /api/v1/admin/{pattern} |
| customer | client | /api/v1/client/{pattern} |
| free | module | /api/v1/{pattern} |
The scope a route enforces is always {group}/{action}, so the entry above is spent against Module:Addons/MyModule/item_rebuild. Appending never shadows a core address: the core entries are registered first and the router returns the first match. To take over one, edit its tuple in place.
The Handler Contract
public function api_item_rebuild(Request $request, array $match): Response;
// $request, every property public and already parsed:
// string $method 'GET', 'POST', ...
// string $audience 'admin' | 'client' | ''
// array $segments the path split on '/'
// string $resource the first segment
// array $query the query string
// array $body the decoded JSON body (or the form body)
// array $headers lower-cased header names
// string $ip the resolved client address
// ?string $token the raw credential, when one was sent
// ?string $idempotencyKey
// string $rawBody
// $match, produced by the router:
// 'group' => 'Module:Addons/MyModule'
// 'action' => 'item_rebuild'
// 'params' => ['id' => '42'] the {x} captures, keyed by name
// 'scope' => 'Module:Addons/MyModule/item_rebuild'
// 'public' => false
// 'authOnly' => false
// 'audience' => 'admin'
Building the Response
class Response
{
public function __construct(int $status = 200, array $payload = []);
public static function success($data = null, int $status = 200, array $meta = []): self;
public static function error(string $code, string $message, int $status = 400, array $details = []): self;
public static function fromLegacy(array $ret): self; // {status, message, data} envelope
public function withHeader(string $name, string $value): self;
public function withHeaders(array $headers): self;
public function getStatus(): int;
public function getPayload(): array;
public function send(): void; // the Kernel calls this, you do not
}
// Failures are thrown, not returned. Each factory carries its own HTTP status.
class ApiException extends Exception
{
public static function badRequest(string $message, string $code = 'bad_request', array $details = []);
public static function unauthorized(string $message = 'Authentication required.', string $code = 'unauthorized');
public static function forbidden(string $message = 'Insufficient scope.', string $code = 'forbidden');
public static function notFound(string $message = 'Resource not found.', string $code = 'not_found');
public static function methodNotAllowed(string $message = 'Method not allowed.', string $code = 'method_not_allowed');
public static function validation(string $message, array $details = [], string $code = 'validation_failed');
public static function rateLimited(string $message = 'Too many requests.', string $code = 'rate_limited');
public static function server(string $message = 'Internal server error.', string $code = 'server_error');
}
Publishing the Checkboxes
public static function set($key, $values, $merge = false): array|false;
// The third argument switches the merge from array_replace_recursive to array_merge, so your
// action list is written whole instead of being blended index by index into a same-named list.
// The catalogue's other groups survive either way.
Config::set('api-actions', ['Module:Addons/MyModule' => ['items_list', 'item_rebuild']], true);
Only the in-memory copy is written; Config::save() is never called here, so coremio/configuration/api-actions.php stays as the core shipped it.
How Far a Granted Scope Reaches
| Granted on the key | What it opens | Safe to recommend |
|---|---|---|
Module:Addons/MyModule/item_rebuild | that one endpoint | yes |
Module:Addons/* | every Addons module on the installation | no |
* | the whole admin API | no |
The gate cuts the required scope at the first slash and your group already contains one. So the group of Module:Addons/MyModule/x is Module:Addons, and the only wildcard matching it also matches every other Addons module.
Example
A minimal admin surface: declaration, registration, one handler and the bridge.
<?php
namespace WISECP\Modules\Addons\MyModule\Src;
use Config;
final class ApiSurface
{
public const GROUP = 'Module:Addons/MyModule';
/** [method, path, action, target], literal paths BEFORE their {id} twins. */
public static function map(): array
{
return [
['GET', 'mymodule/items', 'items_list', 'read:items_list'],
['GET', 'mymodule/items/export', 'items_export', 'read:items_export'],
['GET', 'mymodule/items/{id}', 'item_detail', 'read:item_detail'],
['POST', 'mymodule/items', 'item_save', 'op:save_item'],
['POST', 'mymodule/items/{id}/rebuild', 'item_rebuild', 'op:rebuild_item'],
['DELETE', 'mymodule/items/{id}', 'item_delete', 'op:delete_item'],
];
}
public static function routes(): array
{
$routes = [];
foreach (self::map() as [$method, $path, $action])
$routes[] = [$method, $path, self::GROUP, $action, false, false];
return $routes;
}
public static function publish_permission_catalog(): void
{
$actions = array_map(static fn (array $e): string => $e[2], self::map());
if (!$actions) return;
Config::set('api-actions', [self::GROUP => $actions], true);
}
/** @return array{0:string,1:string}|null [kind, name] for an action, e.g. ['op', 'rebuild_item'] */
public static function target(string $action): ?array
{
foreach (self::map() as $entry)
if ($entry[2] === $action) return array_pad(explode(':', $entry[3], 2), 2, '');
return null;
}
}
<?php
use WISECP\Modules\Addons\MyModule\Src\ApiSurface;
include_once __DIR__ . DS . 'src' . DS . 'ApiSurface.php';
include_once __DIR__ . DS . 'src' . DS . 'ApiBridge.php';
// The addresses: admin surface, credential required, scope enforced per endpoint.
Hook::add('filter:api.routes', 20, function (&$routes, &$audience) {
if ($audience !== 'admin') return;
foreach (ApiSurface::routes() as $route) $routes[] = $route;
});
// The checkboxes: in the FILE BODY, never inside the listener above.
ApiSurface::publish_permission_catalog();
use WISECP\Api\Core\Request;
use WISECP\Api\Core\Response;
use WISECP\Modules\Addons\MyModule\Src\ApiBridge;
class MyModule extends AddonModule
{
public function api_items_list(Request $request, array $match): Response
{
return ApiBridge::dispatch('items_list', $request, $match);
}
public function api_item_rebuild(Request $request, array $match): Response
{
return ApiBridge::dispatch('item_rebuild', $request, $match);
}
// ... one per declared action; generate them from ApiSurface::map()
}
public static function op(string $name, Request $request, array $match): Response
{
$area = self::area(); // licence gate + the AdminArea instance
$input = array_merge($request->query, $request->body, $match['params'] ?? []);
$snapshot = [
'post' => $_POST,
'get' => $_GET,
'request' => $_REQUEST,
'method' => $_SERVER['REQUEST_METHOD'] ?? '',
];
$_POST = $_REQUEST = $input;
$_GET = [];
$_SERVER['REQUEST_METHOD'] = 'POST'; // operations assume a form POST
self::$in_api = true;
$failure = null;
ob_start();
try { $area->{'op_' . $name}(new Operation($name)); }
catch (Exception $e) { $failure = $e; }
finally {
$printed = (string) ob_get_clean();
self::$in_api = false;
$_POST = $snapshot['post']; // hand the borrowed request back clean
$_GET = $snapshot['get'];
$_REQUEST = $snapshot['request'];
$_SERVER['REQUEST_METHOD'] = $snapshot['method'];
}
if ($failure) throw ApiException::validation($failure->getMessage(), [], 'operation_failed');
$envelope = Utility::jdecode($printed, true) ?: [];
$data = $envelope['data'] ?? null;
if (is_array($data)) unset($data['html']); // the panel's modal body is not API payload
return Response::success($data, 200, ['message' => $envelope['message'] ?? '']);
}
The operation's own privilege gate has to step aside for the bridge, and only for the bridge:
private function require_operation(): void
{
// No signed-in operator exists during an API call, and the Kernel already checked a
// narrower permission: this exact endpoint's scope.
if (ApiBridge::in_api()) return;
if (!\Admin::isPrivilege(['MY_MODULE_OPERATION']))
throw new Exception($this->lang['err-no-privilege'] ?? 'You do not have permission for this action.');
}
Pitfalls
With items/{id} above items/export, a request for the export answers 200 from the detail handler with id = "export". Nothing is logged and nothing fails. Feed a concrete value through the router and check the action.
The settings screen reads the catalogue before it asks for the scope map, so a listener that has not fired publishes nothing. The routes still work and a hand-written key still fails the scope check, which looks like a permissions bug. Call it from the file body.
The dispatcher tests method_exists before calling and answers 404 action_not_implemented when it fails. That is deliberate: one real method per endpoint is what makes each endpoint separately grantable.
It splits its argument at the first slash and your group already has one, so the lookup fails with endpoint_not_found. Code inside the installation calls your classes directly.
The tab label comes from a core translation key a module cannot add, so the screen shows Module:Addons/MyModule verbatim. If you rewrite the visible text, never touch the checkbox values: they are the scope strings the gate compares.
The panel dispatcher checks the licence before every operation and an API request does not pass through it. Put the same check at the top of your bridge and answer 403. On a local machine that gate is closed permanently.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.