Operations

6 views Markdown

Every change to data goes through an operation. That is why permissions, demo mode, hooks and error formatting are decided once instead of in every screen.

Overview

An operation is a method that a request can name. When a request carries an operation name, the controller builds no page. It checks the privilege attached to that operation, calls the method, and turns whatever happens into a JSON response.

The method itself is written in a trait, so a controller gains a family of related operations by using one. The controller declares which operations it accepts and what each one requires.

Structure

coremio/operations/ One trait per family. The controller uses the trait, and its methods become the controller's own.
Registration The controller lists the operations it accepts, each with the privileges it needs. Registration is what attaches a privilege; it is not what makes a method reachable.
The operation object Constructed by the dispatcher and handed to your method. It carries the operation name, the registered properties, the demo guard, the hook helper and the response.

What the Dispatcher Does For You

coremio/classes/Controllers.php, the dispatch decision
// operation(): accepted when the name is REGISTERED or simply EXISTS as a method
$isOperation = isset($operations[$name]);
$isMethod    = method_exists($this, $name);
$properties  = $operations[$name] ?? [];

if ($isOperation || $isMethod) return $this->run_operation($name, $properties);

// Neither: modules get the last word, and an array answer becomes the response.
foreach (Hook::run("register:admin.operations", Controllers::$cname, $name) as $hfn)
    if ($hfn && is_array($hfn)) { echo Utility::jencode($hfn); return true; }

// Still nothing: {"status":"error","message":"Undefined operation: <name>"}

// run_operation(), in order:
//   1. privileges from the registration -> Admin::isPrivilege(), else throw
//   2. method_exists($this, $method),                            else throw
//   3. $this->$method(new Operation($name, $properties));
//   4. catch (Exception $e) -> {"status":"error","message":$e->getMessage()}

The last step is the one that changes how you write. You do not build error responses; you throw, and the message you throw is what the caller reads.

Reference

The Operation Object

coremio/classes/Operation.php
public ?string $name = '';               // this operation's registered name
public static ?string $last = '';        // the last operation constructed in this process

public function __construct($name = '', $properties = []);
public function demo(): void;                                        // throws in demo mode
public function hook(string $name = '', array $vars = []): array;    // ['overwrite' => array] | ['output' => mixed] | []
public static function output($response): bool;                      // always returns true
public static function name(): ?string;                              // reads $last, NOT $this->name
demo() Throws when the installation runs in demo mode, so the dispatcher answers with the standard refusal. The first line of every operation that writes.
output() An array is sent as JSON with a content type header and pretty, unescaped encoding; a non-array is echoed as is. A falsy response writes nothing at all and still returns true, so output([]) answers with an empty body the caller cannot parse. Static, but called through the instance by convention, and returned so the method stops there.
hook() Fires an extension point and hands back what listeners returned. See the contract below for the accepted names and the shape of the answer.
name() Static, and it reads the process-wide $last, not the instance. Inside an operation the two agree; from a listener that runs later, prefer the name the hook already passes you.

The Hook Contract

Two names are shorthand for the shared extension points, and anything else is used verbatim. Listeners always receive three arguments: the controller name, the operation name and the variables you passed.

both sides of the same call
// Inside the operation. "before" => filter:admin.operation.before
//                       "after"  => filter:admin.operation.after
$hook = $operation->hook('before', get_defined_vars());
if ($hook && $hook["overwrite"] ?? []) extract($hook["overwrite"]);   // listener changed your locals
if ($hook && $hook["output"] ?? false) return $operation->output($hook["output"]);

// In a module's hooks.php. The listener decides by returning ONE of three shapes:
Hook::add('filter:admin.operation.before', 1, function ($controller, $operation, $vars) {
    if ($controller !== 'widgets' || $operation !== 'save_widget') return null;

    // 1. refuse:  hook() throws with this message, run_operation turns it into the error JSON
    if (!$vars["name"]) return ['status' => "error", 'message' => "Name is required"];

    // 2. rewrite: these become local variables in the operation, via extract()
    if ($vars["name"] === "x") return ['overwrite_vars' => ['name' => "X"]];

    // 3. answer:  the operation returns this instead of doing the work
    return ['output' => ['status' => "successful", 'id' => 0]];
});
filter:admin.operation.before Fired after input is read and validated, before anything is written. The usual place to refuse or to rewrite the values.
filter:admin.operation.after Fired once the work is done and the response array is built, so a listener can replace the response the caller sees.
register:admin.operations The fallback for a name that is neither registered nor a method. A listener returning an array answers the request itself, which is how a module adds an operation to a core screen.
filter:api.response Fired by output() on every array response, by reference, with the operation name as the second argument. The last chance to add or strip a field.

The Response

Success and failure share one field, so a caller can branch on status alone. The error shape is produced for you by the dispatcher. The success shape is whatever you pass; by convention it carries the same field plus what the screen needs.

the two shapes on the wire
{ "status": "successful", "id": 42, "message": "Changes saved" }

{ "status": "error", "message": "Name is required" }

Example

coremio/operations/AdminWidgets.php
namespace WISECP\Operations;

use Exception;
use Filter;
use Language;
use Operation;

trait AdminWidgets
{
    public function save_widget(Operation $operation): bool
    {
        // Demo mode refuses every write; this is always the first line.
        $operation->demo();

        $id   = (int) Filter::init("POST/id", "rnumbers");
        $name = Filter::init("POST/name", "hclear");

        if (!$name) throw new Exception(Language::gc("widgets/error-name-required"));

        $hook = $operation->hook('before', get_defined_vars());
        if ($hook && $hook["overwrite"] ?? []) extract($hook["overwrite"]);
        if ($hook && $hook["output"] ?? false) return $operation->output($hook["output"]);

        $saved = $id
            ? $this->model->update($id, ['name' => $name])
            : $this->model->add(['name' => $name]);

        if (!$saved) throw new Exception(Language::gc("widgets/error-save-failed"));

        $response = ['status' => "successful", 'id' => (int) ($id ?: $saved)];

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

        return $operation->output($response);
    }
}

The other side is an ordinary form post to the controller's own address, with the operation name as a field.

request and response
curl -X POST "$PANEL/widgets" \
     -H "X-Requested-With: XMLHttpRequest" \
     -d "operation=save_widget&id=42&name=Sidebar"

# {"status":"successful","id":42}
# {"status":"error","message":"You don't have privileges to access this operation."}

Pitfalls

The demo guard is not optional

Without it, a demo installation performs the write and only looks like it refused. It goes first, before any reading or validation, so nothing can slip past it.

A method with no registration has no privilege

The dispatcher runs a method that merely exists, with an empty property array. An empty privilege list means the check is skipped entirely. Every public method a controller gains from a trait is an entry point. Register it, or do not put it on the controller.

Throw translated messages

The message you throw is shown to whoever made the request. A literal string means the installation answers everyone in one language.

Return the output, do not only call it

output() writes the response and returns true; it does not stop the method. Anything after an unreturned call still runs and can print a second body after the JSON.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.