APIv4 - Silently ignore non-permissioned fields instead of throwing exceptions
[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
38 /**
39 * Api version number; cannot be changed.
40 *
41 * @var int
42 */
43 protected $version = 4;
44
45 /**
46 * Additional api requests - will be called once per result.
47 *
48 * Keys can be any string - this will be the name given to the output.
49 *
136ca5bb 50 * You can reference other values in the api results in this call by prefixing them with `$`.
19b53e5b
C
51 *
52 * For example, you could create a contact and place them in a group by chaining the
136ca5bb 53 * `GroupContact` api to the `Contact` api:
19b53e5b 54 *
136ca5bb 55 * ```php
19b53e5b
C
56 * Contact::create()
57 * ->setValue('first_name', 'Hello')
136ca5bb
CW
58 * ->addChain('add_a_group', GroupContact::create()
59 * ->setValue('contact_id', '$id')
60 * ->setValue('group_id', 123)
61 * )
62 * ```
19b53e5b 63 *
136ca5bb 64 * This will substitute the id of the newly created contact with `$id`.
19b53e5b
C
65 *
66 * @var array
67 */
68 protected $chain = [];
69
70 /**
71 * Whether to enforce acl permissions based on the current user.
72 *
73 * Setting to FALSE will disable permission checks and override ACLs.
74 * In REST/javascript this cannot be disabled.
75 *
76 * @var bool
77 */
78 protected $checkPermissions = TRUE;
79
b65fa6dc
CW
80 /**
81 * Add debugging info to the api result.
82 *
136ca5bb 83 * When enabled, `$result->debug` will be populated with information about the api call,
32f72d83
CW
84 * including sql queries executed.
85 *
136ca5bb 86 * **Note:** with checkPermissions enabled, debug info will only be returned if the user has "view debug output" permission.
32f72d83 87 *
b65fa6dc
CW
88 * @var bool
89 */
90 protected $debug = FALSE;
91
19b53e5b
C
92 /**
93 * @var string
94 */
95 protected $_entityName;
96
97 /**
98 * @var string
99 */
100 protected $_actionName;
101
102 /**
103 * @var \ReflectionClass
104 */
105 private $_reflection;
106
107 /**
108 * @var array
109 */
110 private $_paramInfo;
111
112 /**
113 * @var array
114 */
115 private $_entityFields;
116
117 /**
118 * @var array
119 */
120 private $_arrayStorage = [];
121
122 /**
123 * @var int
124 * Used to identify api calls for transactions
125 * @see \Civi\Core\Transaction\Manager
126 */
127 private $_id;
128
32f72d83 129 public $_debugOutput = [];
b65fa6dc 130
19b53e5b
C
131 /**
132 * Action constructor.
133 *
134 * @param string $entityName
135 * @param string $actionName
136 * @throws \API_Exception
137 */
138 public function __construct($entityName, $actionName) {
139 // If a namespaced class name is passed in
140 if (strpos($entityName, '\\') !== FALSE) {
141 $entityName = substr($entityName, strrpos($entityName, '\\') + 1);
142 }
143 $this->_entityName = $entityName;
144 $this->_actionName = $actionName;
145 $this->_id = \Civi\API\Request::getNextId();
146 }
147
148 /**
149 * Strictly enforce api parameters
150 * @param $name
151 * @param $value
152 * @throws \Exception
153 */
154 public function __set($name, $value) {
155 throw new \API_Exception('Unknown api parameter');
156 }
157
158 /**
159 * @param int $val
160 * @return $this
161 * @throws \API_Exception
162 */
163 public function setVersion($val) {
121ec912 164 if ($val !== 4 && $val !== '4') {
19b53e5b
C
165 throw new \API_Exception('Cannot modify api version');
166 }
167 return $this;
168 }
169
6764a9d3
CW
170 /**
171 * @param bool $checkPermissions
172 * @return $this
173 */
174 public function setCheckPermissions(bool $checkPermissions) {
175 $this->checkPermissions = $checkPermissions;
176 return $this;
177 }
178
19b53e5b
C
179 /**
180 * @param string $name
181 * Unique name for this chained request
182 * @param \Civi\Api4\Generic\AbstractAction $apiRequest
136ca5bb
CW
183 * @param string|int|array $index
184 * See `civicrm_api4()` for documentation of `$index` param
19b53e5b
C
185 * @return $this
186 */
187 public function addChain($name, AbstractAction $apiRequest, $index = NULL) {
188 $this->chain[$name] = [$apiRequest->getEntityName(), $apiRequest->getActionName(), $apiRequest->getParams(), $index];
189 return $this;
190 }
191
192 /**
121ec912 193 * Magic function to provide automatic getter/setter for params.
19b53e5b
C
194 *
195 * @param $name
196 * @param $arguments
197 * @return static|mixed
198 * @throws \API_Exception
199 */
200 public function __call($name, $arguments) {
201 $param = lcfirst(substr($name, 3));
202 if (!$param || $param[0] == '_') {
203 throw new \API_Exception('Unknown api parameter: ' . $name);
204 }
205 $mode = substr($name, 0, 3);
19b53e5b
C
206 if ($this->paramExists($param)) {
207 switch ($mode) {
208 case 'get':
209 return $this->$param;
210
211 case 'set':
212 $this->$param = $arguments[0];
213 return $this;
19b53e5b
C
214 }
215 }
216 throw new \API_Exception('Unknown api parameter: ' . $name);
217 }
218
219 /**
220 * Invoke api call.
221 *
222 * At this point all the params have been sent in and we initiate the api call & return the result.
223 * This is basically the outer wrapper for api v4.
224 *
225 * @return \Civi\Api4\Generic\Result
121ec912 226 * @throws \API_Exception
19b53e5b
C
227 * @throws \Civi\API\Exception\UnauthorizedException
228 */
229 public function execute() {
230 /** @var \Civi\API\Kernel $kernel */
231 $kernel = \Civi::service('civi_api_kernel');
b65fa6dc
CW
232 $result = $kernel->runRequest($this);
233 if ($this->debug && (!$this->checkPermissions || \CRM_Core_Permission::check('view debug output'))) {
32f72d83 234 $result->debug['actionClass'] = get_class($this);
b65fa6dc
CW
235 $result->debug = array_merge($result->debug, $this->_debugOutput);
236 }
237 else {
238 $result->debug = NULL;
239 }
240 return $result;
19b53e5b
C
241 }
242
243 /**
244 * @param \Civi\Api4\Generic\Result $result
245 */
246 abstract public function _run(Result $result);
247
248 /**
249 * Serialize this object's params into an array
250 * @return array
251 */
252 public function getParams() {
253 $params = [];
254 foreach ($this->reflect()->getProperties(\ReflectionProperty::IS_PROTECTED) as $property) {
255 $name = $property->getName();
256 // Skip variables starting with an underscore
257 if ($name[0] != '_') {
258 $params[$name] = $this->$name;
259 }
260 }
261 return $params;
262 }
263
264 /**
265 * Get documentation for one or all params
266 *
267 * @param string $param
268 * @return array of arrays [description, type, default, (comment)]
269 */
270 public function getParamInfo($param = NULL) {
271 if (!isset($this->_paramInfo)) {
272 $defaults = $this->getParamDefaults();
fc95d9a5 273 $vars = [
e3c6d5ff
CW
274 'entity' => $this->getEntityName(),
275 'action' => $this->getActionName(),
fc95d9a5
CW
276 ];
277 // For actions like "getFields" and "getActions" they are not getting the entity itself.
278 // So generic docs will make more sense like this:
e3c6d5ff
CW
279 if (substr($vars['action'], 0, 3) === 'get' && substr($vars['action'], -1) === 's') {
280 $vars['entity'] = lcfirst(substr($vars['action'], 3, -1));
fc95d9a5 281 }
19b53e5b
C
282 foreach ($this->reflect()->getProperties(\ReflectionProperty::IS_PROTECTED) as $property) {
283 $name = $property->getName();
284 if ($name != 'version' && $name[0] != '_') {
fc95d9a5 285 $this->_paramInfo[$name] = ReflectionUtils::getCodeDocs($property, 'Property', $vars);
19b53e5b
C
286 $this->_paramInfo[$name]['default'] = $defaults[$name];
287 }
288 }
289 }
290 return $param ? $this->_paramInfo[$param] : $this->_paramInfo;
291 }
292
293 /**
294 * @return string
295 */
296 public function getEntityName() {
297 return $this->_entityName;
298 }
299
300 /**
301 *
302 * @return string
303 */
304 public function getActionName() {
305 return $this->_actionName;
306 }
307
308 /**
309 * @param string $param
310 * @return bool
311 */
312 public function paramExists($param) {
313 return array_key_exists($param, $this->getParams());
314 }
315
316 /**
317 * @return array
318 */
319 protected function getParamDefaults() {
320 return array_intersect_key($this->reflect()->getDefaultProperties(), $this->getParams());
321 }
322
323 /**
324 * @inheritDoc
325 */
326 public function offsetExists($offset) {
327 return in_array($offset, ['entity', 'action', 'params', 'version', 'check_permissions', 'id']) || isset($this->_arrayStorage[$offset]);
328 }
329
330 /**
331 * @inheritDoc
332 */
333 public function &offsetGet($offset) {
334 $val = NULL;
335 if (in_array($offset, ['entity', 'action'])) {
336 $offset .= 'Name';
337 }
338 if (in_array($offset, ['entityName', 'actionName', 'params', 'version'])) {
339 $getter = 'get' . ucfirst($offset);
340 $val = $this->$getter();
341 return $val;
342 }
343 if ($offset == 'check_permissions') {
344 return $this->checkPermissions;
345 }
346 if ($offset == 'id') {
347 return $this->_id;
348 }
349 if (isset($this->_arrayStorage[$offset])) {
350 return $this->_arrayStorage[$offset];
351 }
352 return $val;
353 }
354
355 /**
356 * @inheritDoc
357 */
358 public function offsetSet($offset, $value) {
359 if (in_array($offset, ['entity', 'action', 'entityName', 'actionName', 'params', 'version', 'id'])) {
360 throw new \API_Exception('Cannot modify api4 state via array access');
361 }
362 if ($offset == 'check_permissions') {
363 $this->setCheckPermissions($value);
364 }
365 else {
366 $this->_arrayStorage[$offset] = $value;
367 }
368 }
369
370 /**
371 * @inheritDoc
372 */
373 public function offsetUnset($offset) {
374 if (in_array($offset, ['entity', 'action', 'entityName', 'actionName', 'params', 'check_permissions', 'version', 'id'])) {
375 throw new \API_Exception('Cannot modify api4 state via array access');
376 }
377 unset($this->_arrayStorage[$offset]);
378 }
379
380 /**
381 * Is this api call permitted?
382 *
383 * This function is called if checkPermissions is set to true.
384 *
385 * @return bool
70da3927 386 * @internal Implement/override in civicrm-core.git only. Signature may evolve.
19b53e5b 387 */
70da3927 388 public function isAuthorized(): bool {
19b53e5b 389 $permissions = $this->getPermissions();
70da3927 390 return \CRM_Core_Permission::check($permissions);
19b53e5b
C
391 }
392
393 /**
394 * @return array
395 */
396 public function getPermissions() {
eb378b8a 397 $permissions = call_user_func([CoreUtil::getApiClass($this->_entityName), 'permissions']);
19b53e5b
C
398 $permissions += [
399 // applies to getFields, getActions, etc.
400 'meta' => ['access CiviCRM'],
401 // catch-all, applies to create, get, delete, etc.
402 'default' => ['administer CiviCRM'],
403 ];
404 $action = $this->getActionName();
d1961207
CW
405 // Map specific action names to more generic versions
406 $map = [
407 'getActions' => 'meta',
408 'getFields' => 'meta',
409 'replace' => 'delete',
410 'save' => 'create',
411 ];
412 $generic = $map[$action] ?? 'default';
413 return $permissions[$action] ?? $permissions[$generic] ?? $permissions['default'];
19b53e5b
C
414 }
415
416 /**
417 * Returns schema fields for this entity & action.
418 *
39e0f675 419 * Here we bypass the api wrapper and run the getFields action directly.
80aabc65 420 * This is because we DON'T want the wrapper to check permissions as this is an internal op.
19b53e5b
C
421 * @see \Civi\Api4\Action\Contact\GetFields
422 *
39e0f675 423 * @throws \API_Exception
19b53e5b
C
424 * @return array
425 */
426 public function entityFields() {
427 if (!$this->_entityFields) {
a1415a02
CW
428 $allowedTypes = ['Field', 'Filter', 'Extra'];
429 if (method_exists($this, 'getCustomGroup')) {
430 $allowedTypes[] = 'Custom';
431 }
3a8dc228
CW
432 $getFields = \Civi\API\Request::create($this->getEntityName(), 'getFields', [
433 'version' => 4,
80aabc65 434 'checkPermissions' => FALSE,
3a8dc228 435 'action' => $this->getActionName(),
a1415a02 436 'where' => [['type', 'IN', $allowedTypes]],
3a8dc228 437 ]);
19b53e5b 438 $result = new Result();
4b3b32e5
CW
439 // Pass TRUE for the private $isInternal param
440 $getFields->_run($result, TRUE);
19b53e5b
C
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
961e974c
CW
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],
721c9da1 497 'field' => $field,
961e974c 498 'suffix' => substr($expr, $suffix + 1),
0dcd942c 499 'depends' => $field['input_attrs']['control_field'] ?? NULL,
961e974c
CW
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) {
721c9da1 507 return $a['field']['name'] === $b['depends'] ? -1 : 1;
961e974c
CW
508 });
509 // Replace pseudoconstants. Note this is a reverse lookup as we are evaluating input not output.
510 foreach ($optionFields as $fieldName => $info) {
721c9da1 511 $options = FormattingUtil::getPseudoconstantList($info['field'], $info['suffix'], $record, 'create');
961e974c
CW
512 $record[$fieldName] = FormattingUtil::replacePseudoconstant($options, $info['val'], TRUE);
513 }
514 }
515
19b53e5b
C
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
32f72d83
CW
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
19b53e5b 558}