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