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