4 +--------------------------------------------------------------------+
5 | Copyright CiviCRM LLC. All rights reserved. |
7 | This work is published under the GNU AGPLv3 license with some |
8 | permitted exceptions and without any warranty. For full license |
9 | and copyright information, see https://civicrm.org/licensing |
10 +--------------------------------------------------------------------+
12 namespace Civi\Api4\Generic
;
14 use Civi\Api4\Utils\CoreUtil
;
15 use Civi\Api4\Utils\FormattingUtil
;
16 use Civi\Api4\Utils\ReflectionUtils
;
19 * Base class for all api actions.
21 * An api Action object stores the parameters of the api call, and defines a _run function to execute the action.
23 * Every `protected` class var is considered a parameter (unless it starts with an underscore).
25 * Adding a `protected` var to your Action named e.g. `$thing` will automatically:
26 * - Provide a getter/setter (via `__call` MagicMethod) named `getThing()` and `setThing()`.
27 * - Expose the param in the Api Explorer (be sure to add a doc-block as it displays in the help panel).
28 * - Require a value for the param if you add the "@required" annotation.
30 * @method bool getCheckPermissions()
31 * @method $this setDebug(bool $value) Enable/disable debug output
32 * @method bool getDebug()
33 * @method $this setChain(array $chain)
34 * @method array getChain()
36 abstract class AbstractAction
implements \ArrayAccess
{
38 use \Civi\Schema\Traits\MagicGetterSetterTrait
;
41 * Api version number; cannot be changed.
45 protected $version = 4;
48 * Additional api requests - will be called once per result.
50 * Keys can be any string - this will be the name given to the output.
52 * You can reference other values in the api results in this call by prefixing them with `$`.
54 * For example, you could create a contact and place them in a group by chaining the
55 * `GroupContact` api to the `Contact` api:
59 * ->setValue('first_name', 'Hello')
60 * ->addChain('add_a_group', GroupContact::create()
61 * ->setValue('contact_id', '$id')
62 * ->setValue('group_id', 123)
66 * This will substitute the id of the newly created contact with `$id`.
70 protected $chain = [];
73 * Whether to enforce acl permissions based on the current user.
75 * Setting to FALSE will disable permission checks and override ACLs.
76 * In REST/javascript this cannot be disabled.
80 protected $checkPermissions = TRUE;
83 * Add debugging info to the api result.
85 * When enabled, `$result->debug` will be populated with information about the api call,
86 * including sql queries executed.
88 * **Note:** with checkPermissions enabled, debug info will only be returned if the user has "view debug output" permission.
92 protected $debug = FALSE;
97 protected $_entityName;
102 protected $_actionName;
105 * @var \ReflectionClass
107 private $_reflection;
117 private $_entityFields;
122 private $_arrayStorage = [];
126 * Used to identify api calls for transactions
127 * @see \Civi\Core\Transaction\Manager
131 public $_debugOutput = [];
134 * Action constructor.
136 * @param string $entityName
137 * @param string $actionName
138 * @throws \API_Exception
140 public function __construct($entityName, $actionName) {
141 // If a namespaced class name is passed in
142 if (strpos($entityName, '\\') !== FALSE) {
143 $entityName = substr($entityName, strrpos($entityName, '\\') +
1);
145 $this->_entityName
= $entityName;
146 $this->_actionName
= $actionName;
147 $this->_id
= \Civi\API\Request
::getNextId();
151 * Strictly enforce api parameters
156 public function __set($name, $value) {
157 throw new \
API_Exception('Unknown api parameter');
163 * @throws \API_Exception
165 public function setVersion($val) {
166 if ($val !== 4 && $val !== '4') {
167 throw new \
API_Exception('Cannot modify api version');
173 * @param bool $checkPermissions
176 public function setCheckPermissions(bool $checkPermissions) {
177 $this->checkPermissions
= $checkPermissions;
182 * @param string $name
183 * Unique name for this chained request
184 * @param \Civi\Api4\Generic\AbstractAction $apiRequest
185 * @param string|int|array $index
186 * See `civicrm_api4()` for documentation of `$index` param
189 public function addChain($name, AbstractAction
$apiRequest, $index = NULL) {
190 $this->chain
[$name] = [$apiRequest->getEntityName(), $apiRequest->getActionName(), $apiRequest->getParams(), $index];
197 * At this point all the params have been sent in and we initiate the api call & return the result.
198 * This is basically the outer wrapper for api v4.
200 * @return \Civi\Api4\Generic\Result
201 * @throws \API_Exception
202 * @throws \Civi\API\Exception\UnauthorizedException
204 public function execute() {
205 /** @var \Civi\API\Kernel $kernel */
206 $kernel = \Civi
::service('civi_api_kernel');
207 $result = $kernel->runRequest($this);
208 if ($this->debug
&& (!$this->checkPermissions || \CRM_Core_Permission
::check('view debug output'))) {
209 $result->debug
['actionClass'] = get_class($this);
210 $result->debug
= array_merge($result->debug
, $this->_debugOutput
);
213 $result->debug
= NULL;
219 * @param \Civi\Api4\Generic\Result $result
221 abstract public function _run(Result
$result);
224 * Serialize this object's params into an array
227 public function getParams() {
229 $magicProperties = $this->getMagicProperties();
230 foreach ($magicProperties as $name => $bool) {
231 $params[$name] = $this->$name;
237 * Get documentation for one or all params
239 * @param string $param
240 * @return array of arrays [description, type, default, (comment)]
242 public function getParamInfo($param = NULL) {
243 if (!isset($this->_paramInfo
)) {
244 $defaults = $this->getParamDefaults();
246 'entity' => $this->getEntityName(),
247 'action' => $this->getActionName(),
249 // For actions like "getFields" and "getActions" they are not getting the entity itself.
250 // So generic docs will make more sense like this:
251 if (substr($vars['action'], 0, 3) === 'get' && substr($vars['action'], -1) === 's') {
252 $vars['entity'] = lcfirst(substr($vars['action'], 3, -1));
254 foreach ($this->reflect()->getProperties(\ReflectionProperty
::IS_PROTECTED
) as $property) {
255 $name = $property->getName();
256 if ($name != 'version' && $name[0] != '_') {
257 $this->_paramInfo
[$name] = ReflectionUtils
::getCodeDocs($property, 'Property', $vars);
258 $this->_paramInfo
[$name]['default'] = $defaults[$name];
262 return $param ?
$this->_paramInfo
[$param] : $this->_paramInfo
;
268 public function getEntityName() {
269 return $this->_entityName
;
276 public function getActionName() {
277 return $this->_actionName
;
281 * @param string $param
284 public function paramExists($param) {
285 return array_key_exists($param, $this->getMagicProperties());
291 protected function getParamDefaults() {
292 return array_intersect_key($this->reflect()->getDefaultProperties(), $this->getMagicProperties());
298 public function offsetExists($offset) {
299 return in_array($offset, ['entity', 'action', 'params', 'version', 'check_permissions', 'id']) ||
isset($this->_arrayStorage
[$offset]);
305 public function &offsetGet($offset) {
307 if (in_array($offset, ['entity', 'action'])) {
310 if (in_array($offset, ['entityName', 'actionName', 'params', 'version'])) {
311 $getter = 'get' . ucfirst($offset);
312 $val = $this->$getter();
315 if ($offset == 'check_permissions') {
316 return $this->checkPermissions
;
318 if ($offset == 'id') {
321 if (isset($this->_arrayStorage
[$offset])) {
322 return $this->_arrayStorage
[$offset];
330 public function offsetSet($offset, $value) {
331 if (in_array($offset, ['entity', 'action', 'entityName', 'actionName', 'params', 'version', 'id'])) {
332 throw new \
API_Exception('Cannot modify api4 state via array access');
334 if ($offset == 'check_permissions') {
335 $this->setCheckPermissions($value);
338 $this->_arrayStorage
[$offset] = $value;
345 public function offsetUnset($offset) {
346 if (in_array($offset, ['entity', 'action', 'entityName', 'actionName', 'params', 'check_permissions', 'version', 'id'])) {
347 throw new \
API_Exception('Cannot modify api4 state via array access');
349 unset($this->_arrayStorage
[$offset]);
353 * Is this api call permitted?
355 * This function is called if checkPermissions is set to true.
358 * @internal Implement/override in civicrm-core.git only. Signature may evolve.
360 public function isAuthorized(): bool {
361 $permissions = $this->getPermissions();
362 return \CRM_Core_Permission
::check($permissions);
368 public function getPermissions() {
369 $permissions = call_user_func([CoreUtil
::getApiClass($this->_entityName
), 'permissions']);
371 // applies to getFields, getActions, etc.
372 'meta' => ['access CiviCRM'],
373 // catch-all, applies to create, get, delete, etc.
374 'default' => ['administer CiviCRM'],
376 $action = $this->getActionName();
377 // Map specific action names to more generic versions
379 'getActions' => 'meta',
380 'getFields' => 'meta',
381 'replace' => 'delete',
384 $generic = $map[$action] ??
'default';
385 return $permissions[$action] ??
$permissions[$generic] ??
$permissions['default'];
389 * Returns schema fields for this entity & action.
391 * Here we bypass the api wrapper and run the getFields action directly.
392 * This is because we DON'T want the wrapper to check permissions as this is an internal op.
393 * @see \Civi\Api4\Action\Contact\GetFields
395 * @throws \API_Exception
398 public function entityFields() {
399 if (!$this->_entityFields
) {
400 $allowedTypes = ['Field', 'Filter', 'Extra'];
401 if (method_exists($this, 'getCustomGroup')) {
402 $allowedTypes[] = 'Custom';
404 $getFields = \Civi\API\Request
::create($this->getEntityName(), 'getFields', [
406 'checkPermissions' => FALSE,
407 'action' => $this->getActionName(),
408 'where' => [['type', 'IN', $allowedTypes]],
410 $result = new Result();
411 // Pass TRUE for the private $isInternal param
412 $getFields->_run($result, TRUE);
413 $this->_entityFields
= (array) $result->indexBy('name');
415 return $this->_entityFields
;
419 * @return \ReflectionClass
421 public function reflect() {
422 if (!$this->_reflection
) {
423 $this->_reflection
= new \
ReflectionClass($this);
425 return $this->_reflection
;
429 * Validates required fields for actions which create a new object.
433 * @throws \API_Exception
435 protected function checkRequiredFields($values) {
437 foreach ($this->entityFields() as $fieldName => $fieldInfo) {
438 if (!isset($values[$fieldName]) ||
$values[$fieldName] === '') {
439 if (!empty($fieldInfo['required']) && !isset($fieldInfo['default_value'])) {
440 $unmatched[] = $fieldName;
442 elseif (!empty($fieldInfo['required_if'])) {
443 if ($this->evaluateCondition($fieldInfo['required_if'], ['values' => $values])) {
444 $unmatched[] = $fieldName;
453 * Replaces pseudoconstants in input values
455 * @param array $record
456 * @throws \API_Exception
458 protected function formatWriteValues(&$record) {
460 // Collect fieldnames with a :pseudoconstant suffix & remove them from $record array
461 foreach (array_keys($record) as $expr) {
462 $suffix = strrpos($expr, ':');
464 $fieldName = substr($expr, 0, $suffix);
465 $field = $this->entityFields()[$fieldName] ??
NULL;
467 $optionFields[$fieldName] = [
468 'val' => $record[$expr],
471 'suffix' => substr($expr, $suffix +
1),
472 'depends' => $field['input_attrs']['control_field'] ??
NULL,
474 unset($record[$expr]);
478 // Sort option lookups by dependency, so e.g. country_id is processed first, then state_province_id, then county_id
479 uasort($optionFields, function ($a, $b) {
480 return $a['field']['name'] === $b['depends'] ?
-1 : 1;
482 // Replace pseudoconstants. Note this is a reverse lookup as we are evaluating input not output.
483 foreach ($optionFields as $fieldName => $info) {
484 $options = FormattingUtil
::getPseudoconstantList($info['field'], $info['expr'], $record, 'create');
485 $record[$fieldName] = FormattingUtil
::replacePseudoconstant($options, $info['val'], TRUE);
490 * This function is used internally for evaluating field annotations.
492 * It should never be passed raw user input.
494 * @param string $expr
495 * Conditional in php format e.g. $foo > $bar
497 * Variable name => value
499 * @throws \API_Exception
502 protected function evaluateCondition($expr, $vars) {
503 if (strpos($expr, '}') !== FALSE ||
strpos($expr, '{') !== FALSE) {
504 throw new \
API_Exception('Illegal character in expression');
506 $tpl = "{if $expr}1{else}0{/if}";
507 return (bool) trim(\CRM_Core_Smarty
::singleton()->fetchWith('string:' . $tpl, $vars));
511 * When in debug mode, this logs the callback function being used by a Basic*Action class.
513 * @param callable $callable
515 protected function addCallbackToDebugOutput($callable) {
516 if ($this->debug
&& empty($this->_debugOutput
['callback'])) {
517 if (is_scalar($callable)) {
518 $this->_debugOutput
['callback'] = (string) $callable;
520 elseif (is_array($callable)) {
521 foreach ($callable as $key => $unit) {
522 $this->_debugOutput
['callback'][$key] = is_object($unit) ?
get_class($unit) : (string) $unit;
525 elseif (is_object($callable)) {
526 $this->_debugOutput
['callback'] = get_class($callable);