Merge pull request #13078 from agh1/contactdetail-no-or2
[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 * ```php
65 * Contact::create()
66 * ->setValue('first_name', 'Hello')
67 * ->addChain('add_a_group', GroupContact::create()
68 * ->setValue('contact_id', '$id')
69 * ->setValue('group_id', 123)
70 * )
71 * ```
72 *
73 * This will substitute the id of the newly created contact with `$id`.
74 *
75 * @var array
76 */
77 protected $chain = [];
78
79 /**
80 * Whether to enforce acl permissions based on the current user.
81 *
82 * Setting to FALSE will disable permission checks and override ACLs.
83 * In REST/javascript this cannot be disabled.
84 *
85 * @var bool
86 */
87 protected $checkPermissions = TRUE;
88
89 /**
90 * Add debugging info to the api result.
91 *
92 * When enabled, `$result->debug` will be populated with information about the api call,
93 * including sql queries executed.
94 *
95 * **Note:** with checkPermissions enabled, debug info will only be returned if the user has "view debug output" permission.
96 *
97 * @var bool
98 */
99 protected $debug = FALSE;
100
101 /**
102 * @var string
103 */
104 protected $_entityName;
105
106 /**
107 * @var string
108 */
109 protected $_actionName;
110
111 /**
112 * @var \ReflectionClass
113 */
114 private $_reflection;
115
116 /**
117 * @var array
118 */
119 private $_paramInfo;
120
121 /**
122 * @var array
123 */
124 private $_entityFields;
125
126 /**
127 * @var array
128 */
129 private $_arrayStorage = [];
130
131 /**
132 * @var int
133 * Used to identify api calls for transactions
134 * @see \Civi\Core\Transaction\Manager
135 */
136 private $_id;
137
138 public $_debugOutput = [];
139
140 /**
141 * Action constructor.
142 *
143 * @param string $entityName
144 * @param string $actionName
145 * @throws \API_Exception
146 */
147 public function __construct($entityName, $actionName) {
148 // If a namespaced class name is passed in
149 if (strpos($entityName, '\\') !== FALSE) {
150 $entityName = substr($entityName, strrpos($entityName, '\\') + 1);
151 }
152 $this->_entityName = $entityName;
153 $this->_actionName = $actionName;
154 $this->_id = \Civi\API\Request::getNextId();
155 }
156
157 /**
158 * Strictly enforce api parameters
159 * @param $name
160 * @param $value
161 * @throws \Exception
162 */
163 public function __set($name, $value) {
164 throw new \API_Exception('Unknown api parameter');
165 }
166
167 /**
168 * @param int $val
169 * @return $this
170 * @throws \API_Exception
171 */
172 public function setVersion($val) {
173 if ($val !== 4 && $val !== '4') {
174 throw new \API_Exception('Cannot modify api version');
175 }
176 return $this;
177 }
178
179 /**
180 * @param string $name
181 * Unique name for this chained request
182 * @param \Civi\Api4\Generic\AbstractAction $apiRequest
183 * @param string|int|array $index
184 * See `civicrm_api4()` for documentation of `$index` param
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 /**
193 * Magic function to provide automatic getter/setter for params.
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);
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;
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
226 * @throws \API_Exception
227 * @throws \Civi\API\Exception\UnauthorizedException
228 */
229 public function execute() {
230 /** @var \Civi\API\Kernel $kernel */
231 $kernel = \Civi::service('civi_api_kernel');
232 $result = $kernel->runRequest($this);
233 if ($this->debug && (!$this->checkPermissions || \CRM_Core_Permission::check('view debug output'))) {
234 $result->debug['actionClass'] = get_class($this);
235 $result->debug = array_merge($result->debug, $this->_debugOutput);
236 }
237 else {
238 $result->debug = NULL;
239 }
240 return $result;
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();
273 $vars = [
274 'entity' => $this->getEntityName(),
275 'action' => $this->getActionName(),
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:
279 if (substr($vars['action'], 0, 3) === 'get' && substr($vars['action'], -1) === 's') {
280 $vars['entity'] = lcfirst(substr($vars['action'], 3, -1));
281 }
282 foreach ($this->reflect()->getProperties(\ReflectionProperty::IS_PROTECTED) as $property) {
283 $name = $property->getName();
284 if ($name != 'version' && $name[0] != '_') {
285 $this->_paramInfo[$name] = ReflectionUtils::getCodeDocs($property, 'Property', $vars);
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
386 */
387 public function isAuthorized() {
388 $permissions = $this->getPermissions();
389 return \CRM_Core_Permission::check($permissions);
390 }
391
392 /**
393 * @return array
394 */
395 public function getPermissions() {
396 $permissions = call_user_func(["\\Civi\\Api4\\" . $this->_entityName, 'permissions']);
397 $permissions += [
398 // applies to getFields, getActions, etc.
399 'meta' => ['access CiviCRM'],
400 // catch-all, applies to create, get, delete, etc.
401 'default' => ['administer CiviCRM'],
402 ];
403 $action = $this->getActionName();
404 if (isset($permissions[$action])) {
405 return $permissions[$action];
406 }
407 elseif (in_array($action, ['getActions', 'getFields'])) {
408 return $permissions['meta'];
409 }
410 return $permissions['default'];
411 }
412
413 /**
414 * Returns schema fields for this entity & action.
415 *
416 * Here we bypass the api wrapper and run the getFields action directly.
417 * This is because we DON'T want the wrapper to check permissions as this is an internal op,
418 * but we DO want permissions to be checked inside the getFields request so e.g. the api_key
419 * field can be conditionally included.
420 * @see \Civi\Api4\Action\Contact\GetFields
421 *
422 * @throws \API_Exception
423 * @return array
424 */
425 public function entityFields() {
426 if (!$this->_entityFields) {
427 $getFields = ActionUtil::getAction($this->getEntityName(), 'getFields');
428 $result = new Result();
429 if (method_exists($this, 'getBaoName')) {
430 $getFields->setIncludeCustom(FALSE);
431 }
432 $getFields
433 ->setCheckPermissions($this->checkPermissions)
434 ->setAction($this->getActionName())
435 ->_run($result);
436 $this->_entityFields = (array) $result->indexBy('name');
437 }
438 return $this->_entityFields;
439 }
440
441 /**
442 * @return \ReflectionClass
443 */
444 public function reflect() {
445 if (!$this->_reflection) {
446 $this->_reflection = new \ReflectionClass($this);
447 }
448 return $this->_reflection;
449 }
450
451 /**
452 * Validates required fields for actions which create a new object.
453 *
454 * @param $values
455 * @return array
456 * @throws \API_Exception
457 */
458 protected function checkRequiredFields($values) {
459 $unmatched = [];
460 foreach ($this->entityFields() as $fieldName => $fieldInfo) {
461 if (!isset($values[$fieldName]) || $values[$fieldName] === '') {
462 if (!empty($fieldInfo['required']) && !isset($fieldInfo['default_value'])) {
463 $unmatched[] = $fieldName;
464 }
465 elseif (!empty($fieldInfo['required_if'])) {
466 if ($this->evaluateCondition($fieldInfo['required_if'], ['values' => $values])) {
467 $unmatched[] = $fieldName;
468 }
469 }
470 }
471 }
472 return $unmatched;
473 }
474
475 /**
476 * This function is used internally for evaluating field annotations.
477 *
478 * It should never be passed raw user input.
479 *
480 * @param string $expr
481 * Conditional in php format e.g. $foo > $bar
482 * @param array $vars
483 * Variable name => value
484 * @return bool
485 * @throws \API_Exception
486 * @throws \Exception
487 */
488 protected function evaluateCondition($expr, $vars) {
489 if (strpos($expr, '}') !== FALSE || strpos($expr, '{') !== FALSE) {
490 throw new \API_Exception('Illegal character in expression');
491 }
492 $tpl = "{if $expr}1{else}0{/if}";
493 return (bool) trim(\CRM_Core_Smarty::singleton()->fetchWith('string:' . $tpl, $vars));
494 }
495
496 /**
497 * When in debug mode, this logs the callback function being used by a Basic*Action class.
498 *
499 * @param callable $callable
500 */
501 protected function addCallbackToDebugOutput($callable) {
502 if ($this->debug && empty($this->_debugOutput['callback'])) {
503 if (is_scalar($callable)) {
504 $this->_debugOutput['callback'] = (string) $callable;
505 }
506 elseif (is_array($callable)) {
507 foreach ($callable as $key => $unit) {
508 $this->_debugOutput['callback'][$key] = is_object($unit) ? get_class($unit) : (string) $unit;
509 }
510 }
511 elseif (is_object($callable)) {
512 $this->_debugOutput['callback'] = get_class($callable);
513 }
514 }
515 }
516
517 }