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