# Adding a Custom Operation

https://dev.wisecp.com/es/adding-a-custom-operation

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

| Piece | Where | What it carries |
| --- | --- | --- |
| The trait | `coremio/operations`, namespace `WISECP\operations` | One public method per operation |
| The controller | `coremio/controllers`, admin or website | `use` for the trait, plus the declaration array |
| The caller | A template, or your own JavaScript | Posts `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

```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

```php
$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

| Step | Behaviour | On failure |
| --- | --- | --- |
| Name cleanup | Filtered to a route-safe string; `main` goes to not-found | Unknown names are refused |
| Licence check | An active licence; help and polling operations exempt | An error, before privileges |
| Transport check | An asynchronous request with `X-Requested-With: XMLHttpRequest` | HTTP 403 unless navigation is allowed |
| Privilege check | Runs when the declaration lists privileges | Names the missing privilege |
| Your method | Called with a fresh operation object | Any exception becomes `{"status":"error","message":"..."}` |
| Fallback | No declaration and no method: a registration hook gets the name | Its 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

| Key | Value | What the panel does |
| --- | --- | --- |
| `status` | `successful` or `error` | Anything but success with a message is an error |
| `message` | Text already translated | A notification, or a toast if the caller asked |
| `redirect` | A URL, or `reload`, or `script` | Navigates, reloads, or evaluates `script`; with a message it waits |
| `redirect_delay` | Milliseconds | Overrides the wait: five seconds with a message, immediate without |
| `successToast` | Boolean | Forces the toast style over the caller's preference |
| `data` | Anything | Nothing automatic; your own callback reads it |

## Example

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

```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);
    }
}
```

```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.

```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.

```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.

## Related Articles

- [Operations](https://dev.wisecp.com/en/operations)
- [Controllers and Routing](https://dev.wisecp.com/en/controllers-and-routing)
- [Filtering User Input](https://dev.wisecp.com/en/filtering-user-input)
- [Admin JavaScript Library](https://dev.wisecp.com/en/admin-javascript-library)
- [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener)
- [Security Practices](https://dev.wisecp.com/en/security-practices)
