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