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