[REF] Replace the deprecated system_rebuild_module_data function with equivilant...
[civicrm-core.git] / Civi / Api4 / Generic / AbstractAction.php
CommitLineData
19b53e5b 1<?php
380f3545
TO
2
3/*
4 +--------------------------------------------------------------------+
41498ac5 5 | Copyright CiviCRM LLC. All rights reserved. |
380f3545 6 | |
41498ac5
TO
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 |
380f3545
TO
10 +--------------------------------------------------------------------+
11 */
12
13/**
14 *
15 * @package CRM
ca5cec67 16 * @copyright CiviCRM LLC https://civicrm.org/licensing
380f3545
TO
17 */
18
19b53e5b
C
19namespace Civi\Api4\Generic;
20
961e974c 21use Civi\Api4\Utils\FormattingUtil;
19b53e5b 22use Civi\Api4\Utils\ReflectionUtils;
19b53e5b
C
23
24/**
25 * Base class for all api actions.
26 *
c2adedc1
CW
27 * An api Action object stores the parameters of the api call, and defines a _run function to execute the action.
28 *
29 * Every `protected` class var is considered a parameter (unless it starts with an underscore).
30 *
31 * Adding a `protected` var to your Action named e.g. `$thing` will automatically:
32 * - Provide a getter/setter (via `__call` MagicMethod) named `getThing()` and `setThing()`.
33 * - Expose the param in the Api Explorer (be sure to add a doc-block as it displays in the help panel).
34 * - Require a value for the param if you add the "@required" annotation.
35 *
b65fa6dc 36 * @method $this setCheckPermissions(bool $value) Enable/disable permission checks
19b53e5b 37 * @method bool getCheckPermissions()
b65fa6dc
CW
38 * @method $this setDebug(bool $value) Enable/disable debug output
39 * @method bool getDebug()
19b53e5b
C
40 * @method $this setChain(array $chain)
41 * @method array getChain()
42 */
43abstract class AbstractAction implements \ArrayAccess {
44
45 /**
46 * Api version number; cannot be changed.
47 *
48 * @var int
49 */
50 protected $version = 4;
51
52 /**
53 * Additional api requests - will be called once per result.
54 *
55 * Keys can be any string - this will be the name given to the output.
56 *
136ca5bb 57 * You can reference other values in the api results in this call by prefixing them with `$`.
19b53e5b
C
58 *
59 * For example, you could create a contact and place them in a group by chaining the
136ca5bb 60 * `GroupContact` api to the `Contact` api:
19b53e5b 61 *
136ca5bb 62 * ```php
19b53e5b
C
63 * Contact::create()
64 * ->setValue('first_name', 'Hello')
136ca5bb
CW
65 * ->addChain('add_a_group', GroupContact::create()
66 * ->setValue('contact_id', '$id')
67 * ->setValue('group_id', 123)
68 * )
69 * ```
19b53e5b 70 *
136ca5bb 71 * This will substitute the id of the newly created contact with `$id`.
19b53e5b
C
72 *
73 * @var array
74 */
75 protected $chain = [];
76
77 /**
78 * Whether to enforce acl permissions based on the current user.
79 *
80 * Setting to FALSE will disable permission checks and override ACLs.
81 * In REST/javascript this cannot be disabled.
82 *
83 * @var bool
84 */
85 protected $checkPermissions = TRUE;
86
b65fa6dc
CW
87 /**
88 * Add debugging info to the api result.
89 *
136ca5bb 90 * When enabled, `$result->debug` will be populated with information about the api call,
32f72d83
CW
91 * including sql queries executed.
92 *
136ca5bb 93 * **Note:** with checkPermissions enabled, debug info will only be returned if the user has "view debug output" permission.
32f72d83 94 *
b65fa6dc
CW
95 * @var bool
96 */
97 protected $debug = FALSE;
98
19b53e5b
C
99 /**
100 * @var string
101 */
102 protected $_entityName;
103
104 /**
105 * @var string
106 */
107 protected $_actionName;
108
109 /**
110 * @var \ReflectionClass
111 */
112 private $_reflection;
113
114 /**
115 * @var array
116 */
117 private $_paramInfo;
118
119 /**
120 * @var array
121 */
122 private $_entityFields;
123
124 /**
125 * @var array
126 */
127 private $_arrayStorage = [];
128
129 /**
130 * @var int
131 * Used to identify api calls for transactions
132 * @see \Civi\Core\Transaction\Manager
133 */
134 private $_id;
135
32f72d83 136 public $_debugOutput = [];
b65fa6dc 137
19b53e5b
C
138 /**
139 * Action constructor.
140 *
141 * @param string $entityName
142 * @param string $actionName
143 * @throws \API_Exception
144 */
145 public function __construct($entityName, $actionName) {
146 // If a namespaced class name is passed in
147 if (strpos($entityName, '\\') !== FALSE) {
148 $entityName = substr($entityName, strrpos($entityName, '\\') + 1);
149 }
150 $this->_entityName = $entityName;
151 $this->_actionName = $actionName;
152 $this->_id = \Civi\API\Request::getNextId();
153 }
154
155 /**
156 * Strictly enforce api parameters
157 * @param $name
158 * @param $value
159 * @throws \Exception
160 */
161 public function __set($name, $value) {
162 throw new \API_Exception('Unknown api parameter');
163 }
164
165 /**
166 * @param int $val
167 * @return $this
168 * @throws \API_Exception
169 */
170 public function setVersion($val) {
121ec912 171 if ($val !== 4 && $val !== '4') {
19b53e5b
C
172 throw new \API_Exception('Cannot modify api version');
173 }
174 return $this;
175 }
176
177 /**
178 * @param string $name
179 * Unique name for this chained request
180 * @param \Civi\Api4\Generic\AbstractAction $apiRequest
136ca5bb
CW
181 * @param string|int|array $index
182 * See `civicrm_api4()` for documentation of `$index` param
19b53e5b
C
183 * @return $this
184 */
185 public function addChain($name, AbstractAction $apiRequest, $index = NULL) {
186 $this->chain[$name] = [$apiRequest->getEntityName(), $apiRequest->getActionName(), $apiRequest->getParams(), $index];
187 return $this;
188 }
189
190 /**
121ec912 191 * Magic function to provide automatic getter/setter for params.
19b53e5b
C
192 *
193 * @param $name
194 * @param $arguments
195 * @return static|mixed
196 * @throws \API_Exception
197 */
198 public function __call($name, $arguments) {
199 $param = lcfirst(substr($name, 3));
200 if (!$param || $param[0] == '_') {
201 throw new \API_Exception('Unknown api parameter: ' . $name);
202 }
203 $mode = substr($name, 0, 3);
19b53e5b
C
204 if ($this->paramExists($param)) {
205 switch ($mode) {
206 case 'get':
207 return $this->$param;
208
209 case 'set':
210 $this->$param = $arguments[0];
211 return $this;
19b53e5b
C
212 }
213 }
214 throw new \API_Exception('Unknown api parameter: ' . $name);
215 }
216
217 /**
218 * Invoke api call.
219 *
220 * At this point all the params have been sent in and we initiate the api call & return the result.
221 * This is basically the outer wrapper for api v4.
222 *
223 * @return \Civi\Api4\Generic\Result
121ec912 224 * @throws \API_Exception
19b53e5b
C
225 * @throws \Civi\API\Exception\UnauthorizedException
226 */
227 public function execute() {
228 /** @var \Civi\API\Kernel $kernel */
229 $kernel = \Civi::service('civi_api_kernel');
b65fa6dc
CW
230 $result = $kernel->runRequest($this);
231 if ($this->debug && (!$this->checkPermissions || \CRM_Core_Permission::check('view debug output'))) {
32f72d83 232 $result->debug['actionClass'] = get_class($this);
b65fa6dc
CW
233 $result->debug = array_merge($result->debug, $this->_debugOutput);
234 }
235 else {
236 $result->debug = NULL;
237 }
238 return $result;
19b53e5b
C
239 }
240
241 /**
242 * @param \Civi\Api4\Generic\Result $result
243 */
244 abstract public function _run(Result $result);
245
246 /**
247 * Serialize this object's params into an array
248 * @return array
249 */
250 public function getParams() {
251 $params = [];
252 foreach ($this->reflect()->getProperties(\ReflectionProperty::IS_PROTECTED) as $property) {
253 $name = $property->getName();
254 // Skip variables starting with an underscore
255 if ($name[0] != '_') {
256 $params[$name] = $this->$name;
257 }
258 }
259 return $params;
260 }
261
262 /**
263 * Get documentation for one or all params
264 *
265 * @param string $param
266 * @return array of arrays [description, type, default, (comment)]
267 */
268 public function getParamInfo($param = NULL) {
269 if (!isset($this->_paramInfo)) {
270 $defaults = $this->getParamDefaults();
fc95d9a5 271 $vars = [
e3c6d5ff
CW
272 'entity' => $this->getEntityName(),
273 'action' => $this->getActionName(),
fc95d9a5
CW
274 ];
275 // For actions like "getFields" and "getActions" they are not getting the entity itself.
276 // So generic docs will make more sense like this:
e3c6d5ff
CW
277 if (substr($vars['action'], 0, 3) === 'get' && substr($vars['action'], -1) === 's') {
278 $vars['entity'] = lcfirst(substr($vars['action'], 3, -1));
fc95d9a5 279 }
19b53e5b
C
280 foreach ($this->reflect()->getProperties(\ReflectionProperty::IS_PROTECTED) as $property) {
281 $name = $property->getName();
282 if ($name != 'version' && $name[0] != '_') {
fc95d9a5 283 $this->_paramInfo[$name] = ReflectionUtils::getCodeDocs($property, 'Property', $vars);
19b53e5b
C
284 $this->_paramInfo[$name]['default'] = $defaults[$name];
285 }
286 }
287 }
288 return $param ? $this->_paramInfo[$param] : $this->_paramInfo;
289 }
290
291 /**
292 * @return string
293 */
294 public function getEntityName() {
295 return $this->_entityName;
296 }
297
298 /**
299 *
300 * @return string
301 */
302 public function getActionName() {
303 return $this->_actionName;
304 }
305
306 /**
307 * @param string $param
308 * @return bool
309 */
310 public function paramExists($param) {
311 return array_key_exists($param, $this->getParams());
312 }
313
314 /**
315 * @return array
316 */
317 protected function getParamDefaults() {
318 return array_intersect_key($this->reflect()->getDefaultProperties(), $this->getParams());
319 }
320
321 /**
322 * @inheritDoc
323 */
324 public function offsetExists($offset) {
325 return in_array($offset, ['entity', 'action', 'params', 'version', 'check_permissions', 'id']) || isset($this->_arrayStorage[$offset]);
326 }
327
328 /**
329 * @inheritDoc
330 */
331 public function &offsetGet($offset) {
332 $val = NULL;
333 if (in_array($offset, ['entity', 'action'])) {
334 $offset .= 'Name';
335 }
336 if (in_array($offset, ['entityName', 'actionName', 'params', 'version'])) {
337 $getter = 'get' . ucfirst($offset);
338 $val = $this->$getter();
339 return $val;
340 }
341 if ($offset == 'check_permissions') {
342 return $this->checkPermissions;
343 }
344 if ($offset == 'id') {
345 return $this->_id;
346 }
347 if (isset($this->_arrayStorage[$offset])) {
348 return $this->_arrayStorage[$offset];
349 }
350 return $val;
351 }
352
353 /**
354 * @inheritDoc
355 */
356 public function offsetSet($offset, $value) {
357 if (in_array($offset, ['entity', 'action', 'entityName', 'actionName', 'params', 'version', 'id'])) {
358 throw new \API_Exception('Cannot modify api4 state via array access');
359 }
360 if ($offset == 'check_permissions') {
361 $this->setCheckPermissions($value);
362 }
363 else {
364 $this->_arrayStorage[$offset] = $value;
365 }
366 }
367
368 /**
369 * @inheritDoc
370 */
371 public function offsetUnset($offset) {
372 if (in_array($offset, ['entity', 'action', 'entityName', 'actionName', 'params', 'check_permissions', 'version', 'id'])) {
373 throw new \API_Exception('Cannot modify api4 state via array access');
374 }
375 unset($this->_arrayStorage[$offset]);
376 }
377
378 /**
379 * Is this api call permitted?
380 *
381 * This function is called if checkPermissions is set to true.
382 *
383 * @return bool
384 */
385 public function isAuthorized() {
386 $permissions = $this->getPermissions();
387 return \CRM_Core_Permission::check($permissions);
388 }
389
390 /**
391 * @return array
392 */
393 public function getPermissions() {
394 $permissions = call_user_func(["\\Civi\\Api4\\" . $this->_entityName, 'permissions']);
395 $permissions += [
396 // applies to getFields, getActions, etc.
397 'meta' => ['access CiviCRM'],
398 // catch-all, applies to create, get, delete, etc.
399 'default' => ['administer CiviCRM'],
400 ];
401 $action = $this->getActionName();
402 if (isset($permissions[$action])) {
403 return $permissions[$action];
404 }
405 elseif (in_array($action, ['getActions', 'getFields'])) {
406 return $permissions['meta'];
407 }
408 return $permissions['default'];
409 }
410
411 /**
412 * Returns schema fields for this entity & action.
413 *
39e0f675 414 * Here we bypass the api wrapper and run the getFields action directly.
19b53e5b
C
415 * This is because we DON'T want the wrapper to check permissions as this is an internal op,
416 * but we DO want permissions to be checked inside the getFields request so e.g. the api_key
417 * field can be conditionally included.
418 * @see \Civi\Api4\Action\Contact\GetFields
419 *
39e0f675 420 * @throws \API_Exception
19b53e5b
C
421 * @return array
422 */
423 public function entityFields() {
424 if (!$this->_entityFields) {
3a8dc228
CW
425 $getFields = \Civi\API\Request::create($this->getEntityName(), 'getFields', [
426 'version' => 4,
427 'checkPermissions' => $this->checkPermissions,
428 'action' => $this->getActionName(),
429 'includeCustom' => FALSE,
430 ]);
19b53e5b 431 $result = new Result();
3a8dc228 432 $getFields->_run($result);
19b53e5b
C
433 $this->_entityFields = (array) $result->indexBy('name');
434 }
435 return $this->_entityFields;
436 }
437
438 /**
439 * @return \ReflectionClass
440 */
441 public function reflect() {
442 if (!$this->_reflection) {
443 $this->_reflection = new \ReflectionClass($this);
444 }
445 return $this->_reflection;
446 }
447
448 /**
449 * Validates required fields for actions which create a new object.
450 *
451 * @param $values
452 * @return array
453 * @throws \API_Exception
454 */
455 protected function checkRequiredFields($values) {
456 $unmatched = [];
457 foreach ($this->entityFields() as $fieldName => $fieldInfo) {
458 if (!isset($values[$fieldName]) || $values[$fieldName] === '') {
459 if (!empty($fieldInfo['required']) && !isset($fieldInfo['default_value'])) {
460 $unmatched[] = $fieldName;
461 }
462 elseif (!empty($fieldInfo['required_if'])) {
463 if ($this->evaluateCondition($fieldInfo['required_if'], ['values' => $values])) {
464 $unmatched[] = $fieldName;
465 }
466 }
467 }
468 }
469 return $unmatched;
470 }
471
961e974c
CW
472 /**
473 * Replaces pseudoconstants in input values
474 *
475 * @param array $record
476 * @throws \API_Exception
477 */
478 protected function formatWriteValues(&$record) {
479 $optionFields = [];
480 // Collect fieldnames with a :pseudoconstant suffix & remove them from $record array
481 foreach (array_keys($record) as $expr) {
482 $suffix = strrpos($expr, ':');
483 if ($suffix) {
484 $fieldName = substr($expr, 0, $suffix);
485 $field = $this->entityFields()[$fieldName] ?? NULL;
486 if ($field) {
487 $optionFields[$fieldName] = [
488 'val' => $record[$expr],
489 'name' => empty($field['custom_field_id']) ? $field['name'] : 'custom_' . $field['custom_field_id'],
490 'suffix' => substr($expr, $suffix + 1),
491 'depends' => $field['input_attrs']['controlField'] ?? NULL,
492 ];
493 unset($record[$expr]);
494 }
495 }
496 }
497 // Sort option lookups by dependency, so e.g. country_id is processed first, then state_province_id, then county_id
498 uasort($optionFields, function ($a, $b) {
499 return $a['name'] === $b['depends'] ? -1 : 1;
500 });
501 // Replace pseudoconstants. Note this is a reverse lookup as we are evaluating input not output.
502 foreach ($optionFields as $fieldName => $info) {
503 $options = FormattingUtil::getPseudoconstantList($this->_entityName, $info['name'], $info['suffix'], $record, 'create');
504 $record[$fieldName] = FormattingUtil::replacePseudoconstant($options, $info['val'], TRUE);
505 }
506 }
507
19b53e5b
C
508 /**
509 * This function is used internally for evaluating field annotations.
510 *
511 * It should never be passed raw user input.
512 *
513 * @param string $expr
514 * Conditional in php format e.g. $foo > $bar
515 * @param array $vars
516 * Variable name => value
517 * @return bool
518 * @throws \API_Exception
519 * @throws \Exception
520 */
521 protected function evaluateCondition($expr, $vars) {
522 if (strpos($expr, '}') !== FALSE || strpos($expr, '{') !== FALSE) {
523 throw new \API_Exception('Illegal character in expression');
524 }
525 $tpl = "{if $expr}1{else}0{/if}";
526 return (bool) trim(\CRM_Core_Smarty::singleton()->fetchWith('string:' . $tpl, $vars));
527 }
528
32f72d83
CW
529 /**
530 * When in debug mode, this logs the callback function being used by a Basic*Action class.
531 *
532 * @param callable $callable
533 */
534 protected function addCallbackToDebugOutput($callable) {
535 if ($this->debug && empty($this->_debugOutput['callback'])) {
536 if (is_scalar($callable)) {
537 $this->_debugOutput['callback'] = (string) $callable;
538 }
539 elseif (is_array($callable)) {
540 foreach ($callable as $key => $unit) {
541 $this->_debugOutput['callback'][$key] = is_object($unit) ? get_class($unit) : (string) $unit;
542 }
543 }
544 elseif (is_object($callable)) {
545 $this->_debugOutput['callback'] = get_class($callable);
546 }
547 }
548 }
549
19b53e5b 550}