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