Adding a Custom Operation

4 vues Markdown

Add a POST endpoint that changes data and answers in JSON, without a new route or error format.

Overview

An operation is a method that changes data and answers in JSON. You post an operation parameter to an existing controller: no route to register. The controller dispatches on the name, checks privileges, and turns a throw into an error.

Operations live in traits, mixed in with use, so a method reaches the controller's model and helpers.

Prerequisites

  • A controller that already answers your surface, admin or client.
  • A privilege key on the operator panel: an empty declaration lets anyone signed in call it.
  • A caller: the panel's request helper acts on your status, message and redirect keys.

Structure

PieceWhereWhat it carries
The traitcoremio/operations, namespace WISECP\operationsOne public method per operation
The controllercoremio/controllers, admin or websiteuse for the trait, plus the declaration array
The callerA template, or your own JavaScriptPosts operation plus your fields, reads the JSON

Walkthrough

Write It

  1. Add a public method to a trait: one operation-object parameter, returning bool.
  2. Call the demo guard on the first line if the method writes.
  3. Read inputs through the filter helper, never the raw request arrays; a password needs its own sanitiser.
  4. Validate by throwing; there is no error return value to build.
  5. Return the response through the output helper, from a variable the after hook can replace.

Declare It

  1. Import the trait and add it with use in the controller class.
  2. Merge it into the constructor's declaration array, keyed by the posted name.
  3. Give it the privileges the surface uses; the check runs first.
  4. Confirm the entry point forwards to the dispatcher; a client controller does it explicitly.

Call It

  1. Post to the controller's own address with operation set to your name.
  2. Send it as an asynchronous call, or an admin operation refuses it.
  3. Standard keys: a message notifies, a redirect navigates, reload reloads.
  4. Use the request helper's callbacks only for what those keys cannot do.

Reference

The Operation Object

coremio/classes/Operation.php
class Operation
{
    public ?string $name = '';          // the posted operation name
    public static ?string $last = '';   // the last name constructed, per request

    // Built by the dispatcher: $properties is the declaration array entry.
    public function __construct($name = '', $properties = []);

    // Throws on a demonstration system. First line of every mutating operation.
    public function demo(): void;

    // Fires a hook and normalises the answers. 'before' and 'after' are shorthands
    // for the two generic admin-operation filters; any other string is used as-is.
    // Returns [] or ['overwrite' => array] or ['output' => mixed].
    public function hook(string $name = '', array $vars = []): array;

    // Sends the response. An array is JSON encoded with the JSON header set;
    // anything else is echoed as-is. Always returns true, so `return $op->output(...)`
    // satisfies the bool return type.
    public static function output($response): bool;

    public static function name(): ?string;
    public function assertContext(): void;
    public static function resolveContext(): bool;
}
$operation->demo() Only on operations that write; a read-only one must not carry it.
Operation::output() Sets the JSON content type and runs the API response filter.
$operation->hook() Passes the controller name, the operation name and your variables. An error status throws.
The bool return Nothing reads it; it does not signal success.

The Declaration Array

in the controller constructor
$this->operations = array_merge($this->operations, [

    // The common shape: posted name => the privileges required to run it.
    'save_acme_settings' => ['privileges' => ['SETTINGS_OPERATION']],

    // 'method' points the posted name at a differently named method, which is how
    // two posted names share one implementation.
    'acme_retry'         => ['privileges' => ['SETTINGS_OPERATION'], 'method' => 'acme_run'],

    // 'allow_navigation' drops the asynchronous-request requirement. Only for
    // operations meant to be opened as a URL, such as a file download, and only
    // together with a one-time token of the operation's own.
    'download_acme_log'  => ['privileges' => ['SETTINGS_OPERATION'], 'allow_navigation' => true],

    // A client-side operation usually declares no privileges: it gates itself on
    // the member session inside the method instead.
    'acme_client_action' => [],
]);
privileges An array of keys; the caller needs any one. A refusal is a message, not a redirect.
method The method actually called; defaults to the posted name.
allow_navigation Exempts the operation from the transport check. Downloads only, with a token.
Undeclared but present A method on the controller is dispatchable without a declaration entry, with no privileges.

What the Dispatcher Does

StepBehaviourOn failure
Name cleanupFiltered to a route-safe string; main goes to not-foundUnknown names are refused
Licence checkAn active licence; help and polling operations exemptAn error, before privileges
Transport checkAn asynchronous request with X-Requested-With: XMLHttpRequestHTTP 403 unless navigation is allowed
Privilege checkRuns when the declaration lists privilegesNames the missing privilege
Your methodCalled with a fresh operation objectAny exception becomes {"status":"error","message":"..."}
FallbackNo declaration and no method: a registration hook gets the nameIts array answer is sent
register:admin.operations The last chance to answer an operation nobody claimed; an array return is sent.
filter:admin.operation.before Fired by the before shorthand. A listener returns an array: error blocks, overwrite_vars replaces variables, output answers instead. Empty continues.
filter:admin.operation.after The after shorthand, once the response variable exists. Same return shape, but error throws.
filter:api.response Runs in the output helper on every array response, with the operation name. Exceptions never reach it; changes are by reference, the return ignored.

The Response the Caller Reads

KeyValueWhat the panel does
statussuccessful or errorAnything but success with a message is an error
messageText already translatedA notification, or a toast if the caller asked
redirectA URL, or reload, or scriptNavigates, reloads, or evaluates script; with a message it waits
redirect_delayMillisecondsOverrides the wait: five seconds with a message, immediate without
successToastBooleanForces the toast style over the caller's preference
dataAnythingNothing automatic; your own callback reads it

Example

An operation that rotates an integration key, and the code that calls it.

coremio/operations/AcmeSettings.php
namespace WISECP\operations;

use Operation;
use Filter;
use Exception;

trait AcmeSettings
{
    public function rotate_acme_key(Operation $operation): bool
    {
        /** @var \WISECP\controllers\admin\settings $this */

        // 1. Refuse on a demonstration system, before anything is read.
        $operation->demo();

        // 2. Read input. Never $_POST: the filter type is chosen per field, and a
        //    secret must go through 'password' so its punctuation survives intact.
        $id      = (int) Filter::init('POST/id', 'numbers');
        $label   = (string) Filter::init('POST/label', 'hclear');
        $confirm = (string) Filter::init('POST/confirm_password', 'password');

        // 3. BEFORE HOOKS: after reading, before validating, so a listener can
        //    substitute the inputs or answer instead of us.
        # BEFORE HOOKS
        $hook = $operation->hook('before', get_defined_vars());
        if ($hook && $hook['overwrite'] ?? []) extract($hook['overwrite']);
        if ($hook && $hook['output'] ?? false) return $operation->output($hook['output']);

        // 4. Validate by throwing. The dispatcher turns each of these into
        //    {"status":"error","message":"..."} with no work on your side.
        if (!$id) throw new Exception(\Language::gc('admin/settings/error-missing-id'));
        if ($label === '') throw new Exception(\Language::gc('admin/settings/error-label-empty'));

        $adata = \UserManager::LoginData('admin');
        if (!\User::_password_verify('admin', $confirm, $adata['password']))
            throw new Exception(\Language::g('needs/permission-delete-item-invalid-password'));

        // 5. The work itself, through the model the trait inherits from the controller.
        $key = \Utility::generate_hash(48);
        $this->model->set_acme_credentials($id, ['label' => $label, 'api_key' => $key]);

        // 6. Audit trail. The third argument is a key in the actions language file.
        \User::addAction((int) $adata['id'], 'alteration', 'changed-acme-key', ['id' => $id]);

        // 7. The response goes into a variable so the after hook can replace it.
        $response = [
            'status'   => 'successful',
            'message'  => \Language::gc('admin/settings/success-acme-key-rotated'),
            'data'     => ['masked' => substr($key, 0, 6) . str_repeat('*', 10)],
        ];

        # AFTER HOOKS
        $hook = $operation->hook('after', get_defined_vars());
        if ($hook && $hook['overwrite'] ?? []) extract($hook['overwrite']);
        if ($hook && $hook['output'] ?? false) return $operation->output($hook['output']);

        // 8. One exit. output() encodes, sets the header and returns true.
        return $operation->output($response);
    }
}
coremio/controllers/admin/settings.php
namespace WISECP\controllers\admin;

use WISECP\operations\AcmeSettings;
use Controllers;
use Filter;

class settings extends Controllers
{
    use AcmeSettings;

    public function __construct()
    {
        parent::__construct();
        $this->checkLogin();

        // Registration and access control in one place. Without the entry the
        // method would still be reachable, and it would run unprivileged.
        $this->operations = array_merge($this->operations, [
            'rotate_acme_key' => ['privileges' => ['SETTINGS_OPERATION']],
        ]);
    }

    public function main()
    {
        // The fork: a posted operation never reaches the page methods below.
        if ($operation = Filter::init('REQUEST/operation')) return $this->operation($operation);

        // ... page_* dispatch continues here
        return '';
    }
}

The calling side.

the panel JavaScript
function rotateAcmeKey(btn) {
    WcpRequest(CONTROLLER_LINK, {
        method: 'POST',
        button: btn,                       // disabled and given a spinner for the round trip
        buttonLoader: saving_loader,
        options: { headers: { 'X-Requested-With': 'XMLHttpRequest' } },
        data: {
            operation: 'rotate_acme_key',  // the declared name, exactly
            id: ACME_ID,
            label: document.getElementById('acmeLabel').value,
            confirm_password: document.getElementById('acmeConfirm').value,
        },
        // status/message/redirect are handled for us; this is only for the extra.
        afterDone: (res) => {
            if (res.status !== 'successful') return;
            document.getElementById('acmeKeyMasked').textContent = res.data.masked;
        },
    });
}

A client-area operation has no generic before and after hook.

coremio/operations/ClientAcme.php
namespace WISECP\operations;

use Operation;
use Filter;
use Exception;

trait ClientAcme
{
    public function acme_disconnect(Operation $operation): bool
    {
        $operation->demo();

        // Context is resolved INSIDE the operation: the declaration array is
        // registration, not a gate, and a method is dispatchable without an entry.
        $member = \UserManager::LoginData('member');
        if (!$member) throw new Exception(\Language::gc('website/account/err-auth'));

        $uid = (int) ($member['id'] ?? 0);
        $sid = (int) Filter::init('POST/service_id', 'numbers');

        $service = \Services::get($sid);
        if (!$service || (int) ($service['owner_id'] ?? 0) !== $uid)
            throw new Exception(\Language::gc('website/account/err-not-found'));

        // Your own domain hooks, at the point where they belong. A gate refuses by
        // returning a non-empty reason, and the operation turns that into an error.
        // Both names live in your module's namespace: never fire a core hook, and
        // never invent one that the catalogue does not carry.
        foreach (\Hook::run('gate:service.acme_disconnect', $sid, $uid) as $veto)
            if ($veto) throw new Exception((string) $veto);

        // There is no set_options() helper. Options round-trip through Services::set(),
        // which JSON encodes the array for you, so read, merge, write the whole map.
        $options = $service['options'] ?? [];
        $options['acme_linked'] = 0;
        \Services::set($sid, ['options' => $options]);

        \Hook::run('action:service.acme_disconnected', $sid, $uid);

        return $operation->output([
            'status'  => 'successful',
            'message' => \Language::gc('website/account/acme-disconnected'),
        ]);
    }
}

Pitfalls

A public method is already an endpoint

Dispatch accepts any name matching a declaration entry or an existing method, so keep helpers private.

The API needs the same rule

API resources reach the helper or the model directly, so a rule added only to an operation stays bypassable over the token.

Encoding by hand skips the output filter

Echoing your own encoding bypasses the response filter and its flags. Return through the output helper, hook branches included.

An operation is not reachable as a link

An admin operation is refused unless the request identifies itself as an asynchronous call. For a download, declare the exemption and add a one-time token.

The demo guard belongs only on writes

On a listing or a preview it breaks the demonstration system for nothing. If the method writes, the guard is its first line.

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.