Remote Procedure Call (RPC) Plugin

The RPC plugin provides an easy way to implement a Remote Procedure Call API. The plugin uses JSON-RPC protocol version 2. You can read the specification here.

Install

RPC Plugin is dependent on Jimbo.

  1. Check the Jimbo plugin has been installed
  2. Add the plugin:
    git submodule add [email protected]:FestiPlugins/php_festi_plugin_rpc.git plugins/Rpc
  3. Run dump from install folder
  4. Add area and rule
    INSERT INTO festi_url_areas (ident) VALUES ('rpc');
    INSERT INTO festi_url_rules (plugin, pattern, method) VALUES ('Rpc', '~^/rpc/(.*)$~', 'onJsonCallMethod');
    And add this rule to festi_url_rules2areas for area rpc
  5. Call RPC method syncRpcMethods
  6. Configure permission section
  7. Add column access_token to users table
  8. Configure index.php and .htaccess. Examples can be found in install/rpc/
  9. Add composer package festi-team/festi-framework-serialization

Authentication

Access token should be stored in the users table in the access_token column. You have two ways for authentication:

  1. Put token to GET param token:
    https://RPC_HOST/?token=XXX
  2. Put token in the X-Authorization header:
    X-Authorization: XXX

Request

You can send parameters using an array or object:

{"jsonrpc":"2.0", "method":"getScraperActions", "params":["linkedin"], "id":1}
{"jsonrpc":"2.0", "method":"getScraperActions", "params":{"ident":"linkedin"}, "id":1}

If you use an object, please ensure that the key name matches the parameter name in the RPC method annotation.

The RPC plugin supports batch requests:

[
  {"jsonrpc":"2.0","method":"getScraperActions","params":["linkedin"],"id":1},
  {"jsonrpc":"2.0","method":"getScraperActions","params":{"ident":"google"},"id":2}
]

For easy debugging, you can use the GET parameter rawRequest:

https://RPC_HOST/?rawRequest={"jsonrpc":"2.0","method":"syncRpcMethods"}

RPC Method Implementation

Define RPC methods with the #[RpcAttribute] (preferred) or the @rpc doc-tag.

Preferred — #[RpcAttribute]:

use Plugins\Rpc\Domain\Attribute\RpcAttribute;
use core\plugin\attribute\SectionAttribute;

class ScraperPlugin extends DisplayPlugin
{
    #[RpcAttribute('getScraperActions')]
    #[SectionAttribute('scraper')]
    public function getActionsByIdent(string $ident): array
    {
        ...
    }
}

RpcAttribute is non-repeatable and targets methods only. The optional constructor argument advertises the method under a custom RPC name; without it the bare attribute mirrors a bare @rpc and the real method name is used.

Legacy — @rpc doc-tag:

class ScraperPlugin extends DisplayPlugin
{
    /**
     * @rpc getScraperActions
     * @param string $ident
     * @section scraper
     * @return array
     */
    public function getActionsByIdent(string $ident): array
    {
        ...
    }
}

If @rpc doesn't have a name, the real method name will be used. Attribute form:

#[RpcAttribute]
public function getMentors($idUser = false): mixed
{
    ...
}

Doc-tag form:

/**
 * @rpc
 * @param bool $idUser
 * @return mixed
 * @throws PermissionsException
 */
public function getMentors($idUser = false)
{
    ...
}

You can use on* methods and methods with DGS as well. Attribute form combines RpcAttribute with UrlRouteAttribute, AreaAttribute, and SectionAttribute:

use core\plugin\attribute\AreaAttribute;
use core\plugin\attribute\SectionAttribute;
use core\plugin\attribute\UrlRouteAttribute;
use Plugins\Rpc\Domain\Attribute\RpcAttribute;

#[RpcAttribute('getProjects')]
#[UrlRouteAttribute('~^/projects/$~')]
#[AreaAttribute('backend')]
#[SectionAttribute('manage_projects')]
public function onDisplayList(Response &$response): bool
{
    $store = $this->createStoreInstance("projects");

    $store->onRequest($response);

    return true;
}

Doc-tag form:

/**
 * @rpc getProjects
 * @urlRule ~^/projects/$~
 * @area backend
 * @param Response $response
 * @return bool
 * @throws SystemException
 * @section manage_projects
 */
public function onDisplayList(Response &$response)
{
    $store = $this->createStoreInstance("projects");

    $store->onRequest($response);

    return true;
}

Parameters for the result will be retrieved from the Response object

RPC Methods and Inheritance

syncRpcMethods scans each plugin with reflection, which includes inherited methods. An rpc-method name is globally unique (one rpc_methods row per name), so a plugin that extends another plugin (e.g. MCPServerPlugin extends RpcPlugin) must not silently re-register and fight for its parent's rpc methods. Ownership follows one rule plus the #[RpcOverride] escape hatch.

Default — owned by the declaring class

An inherited #[RpcAttribute] method you do not redeclare is not registered under the child plugin; it stays owned by the plugin that declares it. Nothing to annotate — this is automatic. (Trait methods count as declared by the using class, so traits are unaffected.)

#[RpcOverride] — replace an inherited method

Plugins are separate instances: a call is dispatched to $this->plugin->$ownerName->$method(...), so your code only runs when your plugin owns the rpc name. To replace an inherited endpoint, redeclare the entry method with the same rpc name and mark it #[RpcOverride]. The override wins ownership deterministically, regardless of plugin scan order.

use Plugins\Rpc\Domain\Attribute\RpcAttribute;
use Plugins\Rpc\Domain\Attribute\RpcOverrideAttribute;

#[RpcAttribute('execUrl')]          // same rpc name as the parent
#[RpcOverrideAttribute]
public function onJsonExecUrl(Response &$response, string $url, ?array $data = null, ?string $area = null): bool
{
    return parent::onJsonExecUrl($response, $url, $data, $area);
}

Because the child now owns the entry method, $this is the child instance — so any protected helper the entry method calls resolves to the child's override. Extension points must be protected, not private (private methods are not polymorphic, so a parent:: call still binds the parent's privates), and the entry method must not be private.

#[RpcOverride] is attribute-only (no @rpcOverride doc-tag): the doc-tag scanner matches @rpc before a longer @rpc… name, so a doc-tag form would be misread.

Ambiguous ownership is a hard error. If two plugins claim the same rpc name and neither is marked #[RpcOverride] (or both are), syncRpcMethods throws Duplicate rpc method '<name>' declared by <A> and <B> — mark the replacing one with #[RpcOverride]. instead of silently picking an order-dependent owner.

Working with DGS over RPC

A backend page that renders one or more DGS can be driven entirely over RPC: introspect its stores with getSchemeByUrl, then read or write rows with execUrl — addressing the page by its normal @urlRule URL, with no bespoke endpoint per grid. Higher layers such as the MCP server build on this generic mechanism to expose Festi admin grids to an agent.

1. Introspect — getSchemeByUrl

Call getSchemeByUrl with a concrete page URL. It binds the page read-only and returns the schema of every store on it:

{"jsonrpc":"2.0","method":"getSchemeByUrl","params":["/company/17086/settings/evaluation-systems/2/grades/","backend"],"id":1}

Response shape (one entry per store, keyed by store name):

{
  "dgs": {
    "<storeName>": {
      "table":   {"name":"...", "primaryKey":"id"},
      "fields":  {"<field>": {"requestKey":"<field>", "type":"text", "multiple":false, "values":{"5":"Label"}}},
      "actions": {"list":{}, "insert":{}, "edit":{}, "remove":{}},
      "request": {"key":"<request.key>", "stageBlock":0, "primaryKey":"id"}
    }
  }
}
  • request.key — the identifier you address the store by in execUrl's data.
  • request.stageBlock — the StageBuilder block the store occupies; routing is automatic, you do not send it.
  • request.primaryKey — the row-id field name.
  • each field's requestKey — the key to send that field's value under in a write.
  • actions — the actions this store currently allows; a read-only grid lists only list. Custom actions appear here too — see Custom actions.

2. Read and write — execUrl

execUrl takes the same URL plus a data map keyed by request.key. Each entry names an action and, for writes, the field values. Omit data (or send only {"action":"list"}) to read.

Read:

{"jsonrpc":"2.0","method":"execUrl","params":["/company/17086/settings/evaluation-systems/2/grades/",{"<request.key>":{"action":"list"}},"backend"],"id":1}

Insert — action plus field values:

{"<request.key>":{"action":"insert","grade":"A","weight":90}}

Edit — send the uppercase row ID plus all writable fields (a full replacement, not a partial patch):

{"<request.key>":{"action":"edit","ID":42,"grade":"A","weight":95}}

Remove — only the uppercase ID:

{"<request.key>":{"action":"remove","ID":42}}

Foreign-key and many2many fields

Foreign-key and many2many fields take ids, not labels. The scheme lists the selectable ids under each field's values map ({id: label}). As with every field, send the value under the field's requestKey from the scheme — for a foreignKey that is the field name; for a many2many it is the m2m_<linkTable> key the scheme reports.

  • foreignKey — send the numeric id under its requestKey:
{"<request.key>":{"action":"insert","id_grade_group":5}}
  • many2many (multiple: true) — send an array of ids under its requestKey; send [] to clear all links:
{"<request.key>":{"action":"insert","m2m_schools_staff":[12,34]}}

If a field's values is truncated (valuesTruncated: true) or absent (autocomplete field), run a list action with a filter to find the id you need.

Custom actions

list, insert, edit, and remove are the built-in actions, but a store can declare its own. The action field is just the action name, not a fixed enum — execUrl invokes whatever the store exposes. Every action the store currently allows is listed in its actions map from getSchemeByUrl; the map key is the exact string to send as action.

Two kinds of custom action appear there:

  • Plugin action (<action type="plugin" plugin="..." method="...">) — runs a plugin method against the selected row. It is keyed as plugin in the scheme. Send it with the uppercase row ID:
{"<request.key>":{"action":"plugin","ID":42}}
  • Custom action class (<action type="<?php echo MyAction::class; ?>">, a class extending AbstractAction) — keyed under its fully-qualified class name. Send that exact string as the action, plus whatever row ID or field values the action reads:
{"<request.key>":{"action":"Plugins\\MyPlugin\\Domain\\Store\\Action\\MyAction","ID":42}}
  • Link action (<action type="..." link="...">) — a navigation action that points at another page rather than executing on this store. Its target URL is carried in the action's link attribute (exposed in the scheme), often with a %id% placeholder for the row's primary key (and %s% for other path segments). Don't drive it through execUrl on the current store; substitute the row id into the URL and treat the result as a new page — call getSchemeByUrl / execUrl against it (or just fetch it):
link="/user/review/%id%/task/"   →   /user/review/42/task/

A custom action runs inside the same API-mode bind as the built-in writes — its target store's StageBuilder block is resolved from the request.key exactly as for insert/edit, so the action executes and its JSON response is captured and returned. An action that only renders an HTML view (no API/JSON output) returns nothing useful over execUrl; have the action emit a JSON response before driving it from an agent.

The JSON-RPC transport request is a POST, but introspection and stage-block discovery are read-only binds: each store is pinned to its list action while the page binds, so a read-only grid that drops the insert action is introspected without error.

Events

The RPC plugin dispatches two events during sync (when building the list of RPC methods from plugin annotations). Other plugins can listen to extend or customize method discovery and stored values.

BeforePluginAnnotationsParseEvent::TYPE

This event is triggered once per plugin, before PluginAnnotations::parse() is called for that plugin. Listeners can extend two parallel channels:

  • Doc-tag annotation names — adds custom @… tags so they are parsed from docblocks and appear in $methodInfo (e.g. mcpReadOnly, mcpDestructive for MCP tools).
  • PHP 8 attribute classes — adds custom attribute classes mapped to annotation names, letting plugins declare their own attribute counterparts to those tags.

Event data: * getPluginContext() – plugin being scanned * getPluginAnnotations() – the PluginAnnotations instance * getAnnotationNames() / setAnnotationNames(array) – list of doc-tag annotation names to parse (listeners can add more) * getAttributeMap() / setAttributeMap(array) – map of FQN attribute class to annotation name (listeners can add more)

init.php:

$this->core->addEventListener(
    BeforePluginAnnotationsParseEvent::TYPE,
    function (BeforePluginAnnotationsParseEvent $event): void {
        $names = $event->getAnnotationNames();
        $names[] = 'mcpReadOnly';
        $names[] = 'mcpDestructive';
        $event->setAnnotationNames($names);

        $attributes = $event->getAttributeMap();
        $attributes[McpReadOnlyAttribute::class] = 'mcpReadOnly';
        $attributes[McpDestructiveAttribute::class] = 'mcpDestructive';
        $event->setAttributeMap($attributes);
    }
);

PrepareRpcMethodValuesEvent::TYPE

This event is triggered once per RPC method, after the method item (plugin, method, rpc_method, params, description, etc.) is built and before it is written to the rpc_methods table. It can be used to add or change keys in the method values (e.g. is_read_only) so they are stored and available later.

Event data: * getValues() / setValues(array) – the method row to store (mutate and set back) * getPluginContext() – plugin * getMethodName() – method name * getMethodInfo() – parsed annotations for this method (including any added via BeforePluginAnnotationsParseEvent)

init.php:

$this->core->addEventListener(
    PrepareRpcMethodValuesEvent::TYPE,
    function (PrepareRpcMethodValuesEvent $event): void {
        $values = $event->getValues();
        $methodInfo = $event->getMethodInfo();
        $values['is_read_only'] = array_key_exists('mcpDestructive', $methodInfo) ? 0 : 1;
        $event->setValues($values);
    }
);

Update RPC Methods

All RPC methods are stored in the rpc_methods table. To update them, you can call the RPC method syncRpcMethods or call the method from code:

Core::getInstance()->getPluginInstance('Rpc')->syncRpcMethods();
or
https://RPC_HOST/?rawRequest={"jsonrpc":"2.0","method":"syncRpcMethods"}

Client

  • jQuery - https://github.com/Textalk/jquery.jsonrpcclient.js
let rpc = new jQuery.JsonRpcClient({ ajaxUrl: 'https://RPC_HOST/' });

rpc.call(
    'getScraperActions', ['linkedin'],
    function (response) {
        console.log("RESULT:", response);
    },
    function (error) {
        console.error(error);
    }
);

rpc.batch(
    function (batch) {
        batch.call('getScraperActions', ['linkedin'], function (response) {
            console.log("1", response);
        }, function (error) {
            console.error(error);
        });
        batch.call('getScraperActions', { "ident": "linkedin" }, function (response) {
            console.log("2", response);
        }, function (error) {
            console.error(error);
        });
    },
    function (all_result_array) { alert('All done.'); },
    function (error_data) { alert('Error in batch response.'); }
);

Override Authorization Logic

init.php:

assert($this instanceof Core);

$this->addEventListener(Core::EVENT_ON_AFTER_INIT, function () {
    $this->addEventListener(IRpc::EVENT_ON_TOKEN_LOGIN, function (FestiEvent &$event) {
        Core::getInstance()->getPluginInstance('YourNewPlugin')->onInterceptLoginByToken($event);
    });
});

public function onInterceptLoginByToken(FestiEvent &$event): void
{
    $isAuth = &$event->getTargetValueByKey('is_auth');
    $token = &$event->getTargetValueByKey('token');

    if (mb_strlen($token) == 32) { // system access token
        $isAuth = $this->core->getSystemPlugin()->signinByToken($token);
    } else {
        $isAuth = $this->_signInByGoogleTokenID($token);
    }

     if (!$isAuth) {
         throw new PermissionsException("Undefined access token.");
     }
}

Reusable API

The RPC plugin provides reusable type handling utilities that can be used by other plugins:

ParameterProcessor

The ParameterProcessor class provides public methods for type checking and casting:

  • isSimpleType(string $typeName): bool - Checks if a type is a simple/primitive type
  • castToType(mixed $value, string $typeName): mixed - Casts a value to the specified type

These methods are used internally by RPC for parameter processing and are also available for use by dependent plugins (e.g., MCPServer) to maintain consistency in type handling across the codebase.

Error Handling

The RPC plugin follows the JSON-RPC 2.0 specification for error responses. Errors are returned in the following format:

{
  "jsonrpc": "2.0",
  "error": {
    "code": -32000,
    "message": "Server error",
    "data": {}
  },
  "id": 1
}

Common error codes: - -32700 - Parse error - -32600 - Invalid Request - -32601 - Method not found - -32602 - Invalid params - -32603 - Internal error - -32000 to -32099 - Server error (custom errors)

Permissions

RPC methods can be protected using the @section annotation. The section name should match a permission section defined in your permissions system. If a user doesn't have access to the required section, a PermissionsException will be thrown.

For detailed information about the permissions system, see the Permissions documentation.

How Permission Checking Works

The permission system uses the following database tables:

  • festi_sections - Defines permission sections (identified by the @section annotation)
  • festi_section_actions - Maps plugin methods to sections and required permission masks
  • festi_sections_user_permission - User-specific permissions (overrides user type permissions)
  • festi_sections_user_types_permission - User type/role-based permissions

When an RPC method is called:

  1. The system looks up the method in festi_section_actions to find:
  2. The associated section (id_section)
  3. The required permission mask (2 = Read, 4 = Write, 6 = Execute)

  4. The system then checks the user's permissions by querying:

  5. festi_sections_user_permission for user-specific permissions (takes priority)
  6. festi_sections_user_types_permission for role-based permissions

  7. The user's granted mask must be greater than or equal to the required mask for the action.

Example

/**
 * @rpc getProjects
 * @section manage_projects
 * @return array
 */
public function getProjects(): array
{
    // Only users with 'manage_projects' permission can call this
}

To set up permissions for this method, you need to:

  1. Create the section in festi_sections (if it doesn't exist):

    INSERT INTO festi_sections (caption, ident, mask)
    VALUES ('Project Management', 'manage_projects', '6');

  2. Register the action in festi_section_actions:

    INSERT INTO festi_section_actions (id_section, plugin, method, mask, comment)
    VALUES (
        (SELECT id FROM festi_sections WHERE ident = 'manage_projects'),
        'MyPlugin',
        'getProjects',
        '2',  -- Read permission required
        'Get projects list'
    );

  3. Assign permissions to users or user types as needed.

Built-in RPC Methods

getDataGridStoreModel

Returns the DataGrid Store (DGS) model schema for a given plugin and store.

Parameters: - pluginName (string) or [0] - Name of the plugin - storeName (string) or [1] - Name of the store

Example:

{"jsonrpc":"2.0", "method":"getDataGridStoreModel", "params":["MyPlugin", "users"], "id":1}

Based on: RpcPlugin::onJsonDataGridStore

syncRpcMethods

Scans the project and reconciles the RPC methods registry in the database so it exactly mirrors the code. Call it after adding, renaming, or removing RPC methods.

Reconciliation is keyed on a method's identity (plugin, method) — not its rpc_method alias:

  • a newly declared method is inserted;
  • an existing method is updated in place, so renaming its rpc_method alias updates the row instead of creating a duplicate;
  • a row whose (plugin, method) is no longer declared is deleted — this removes methods deleted from code and methods of plugins that are no longer active, keeping stale endpoints from remaining callable.

Parameters: None

Example:

{"jsonrpc":"2.0", "method":"syncRpcMethods", "params":[], "id":1}

Based on: RpcPlugin::syncRpcMethods

getStructureMenu

Returns the menu structure for a given area.

Parameters: - area (string) or [0] - Area identifier

Example:

{"jsonrpc":"2.0", "method":"getStructureMenu", "params":["backend"], "id":1}

Based on: Jimbo::getStructureMenu

getSchemeByUrl

Binds a backend page read-only and returns the DGS schema of every store on it (fields, actions, and the request descriptor). See Working with DGS over RPC.

Parameters: - url (string) or [0] - Concrete page URL to introspect - area (string|null) or [1] - Optional area identifier (defaults to backend)

Example:

{"jsonrpc":"2.0", "method":"getSchemeByUrl", "params":["/projects/", "backend"], "id":1}

Based on: RpcPlugin::onJsonSchemeByUrl

execUrl

Reads or writes DGS rows on a backend page. data is keyed by each store's request.key (from getSchemeByUrl) and names an action (list, insert, edit, remove) plus, for writes, the field values. Omit data to run list. See Working with DGS over RPC.

Parameters: - url (string) or [0] - Concrete page URL to execute against - data (object|null) or [1] - Request keyed by store request.key - area (string|null) or [2] - Optional area identifier (defaults to backend)

Example:

{"jsonrpc":"2.0", "method":"execUrl", "params":["/projects/", {"projects":{"action":"list"}}, "backend"], "id":1}

Based on: RpcPlugin::onJsonExecUrl