Exposing API Endpoints

4 vues Markdown

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.

the credential screen
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.php file; 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_once anything hooks.php references.

Structure

filter:api.routes The single connection point, fired once per audience with the route list by reference. Append; the return is ignored.
Routes / ClientRoutes / ModuleRoutes The three registries; each runs the hook with its own $audience (see the surface table).
Kernel::dispatch() Sees a group beginning with Module: and hands the request to your module instance.
Config::set() Publishes your checkboxes into the in-memory permission catalogue.

Walkthrough

Declare the Surface in One Place

  1. Create src/ApiSurface.php with a GROUP constant and a map() listing every endpoint: verb, path, action, target.
  2. Derive the routes and the permission catalogue from that list, so route, scope and checkbox cannot drift.
  3. Write literal paths before their parametric twins. The router takes the first match at a given segment count.

Register the Routes

  1. In hooks.php, add a listener on filter:api.routes and return when the audience is not yours.
  2. Append your tuples to $routes. Both parameters arrive by reference, so append, do not return.
  3. Publish the permission catalogue from the body of hooks.php, outside every listener.

Write the Handler Methods

  1. Add one api_{action} method per endpoint. A hard method_exists check means __call will not answer.
  2. Give every method the same one-line body forwarding to your bridge, and generate them from the declaration.
  3. Return a Response, or a legacy envelope array that the Kernel normalises.
  4. For a list, keep the envelope identical to the core resources: page, limit and search in, meta.total, meta.page, meta.limit and meta.next_page out.

Bridge Writes to the Panel Operation

  1. Mirror the superglobals, call op_{name}, capture what it prints, then restore them in a finally block.
  2. Translate the printed envelope: drop data.html, move message into meta, and turn a thrown exception into a validation error.
  3. 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

signature
// 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];
SurfaceAudience valueAddress the entry answers on
credentialed adminadmin/api/v1/admin/{pattern}
customerclient/api/v1/client/{pattern}
freemodule/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

signature
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

exact signatures
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

signature
public static function set($key, $values, $merge = false): array|false;
the call
// 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 keyWhat it opensSafe to recommend
Module:Addons/MyModule/item_rebuildthat one endpointyes
Module:Addons/*every Addons module on the installationno
*the whole admin APIno
There is no wildcard for one module

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.

src/ApiSurface.php
<?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;
    }
}
hooks.php
<?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();
MyModule.php
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()
}
src/ApiBridge.php
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:

AdminArea.php
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

A parametric path declared first swallows its literal twin

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.

Publishing the catalogue inside the listener hides every checkbox

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.

A magic __call will not answer

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.

Kernel::internal cannot reach these endpoints

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 category label prints the raw group name

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.

A licensed module has to repeat its own licence gate

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.

Cet article vous a-t-il été utile ?

Merci pour votre retour !

Besoin d'aide supplémentaire ?

Notre équipe d'assistance est disponible 24h/24 pour tout ce que vous ne trouvez pas ci-dessus.