Merge in 5.47
[civicrm-core.git] / Civi / API / Provider / MagicFunctionProvider.php
CommitLineData
787604ff
TO
1<?php
2/*
3 +--------------------------------------------------------------------+
41498ac5 4 | Copyright CiviCRM LLC. All rights reserved. |
787604ff 5 | |
41498ac5
TO
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
787604ff 9 +--------------------------------------------------------------------+
d25dd0ee 10 */
787604ff
TO
11
12namespace Civi\API\Provider;
8882ff5c 13
787604ff
TO
14use Civi\API\Events;
15use Symfony\Component\EventDispatcher\EventSubscriberInterface;
16
17/**
18 * This class manages the loading of API's using strict file+function naming
19 * conventions.
20 */
21class MagicFunctionProvider implements EventSubscriberInterface, ProviderInterface {
34f3bbd9 22
6550386a
EM
23 /**
24 * @return array
25 */
787604ff 26 public static function getSubscribedEvents() {
c64f69d9 27 return [
39b870b8 28 'civi.api.resolve' => [
c64f69d9
CW
29 ['onApiResolve', Events::W_MIDDLE],
30 ],
31 ];
787604ff
TO
32 }
33
c65db512 34 /**
f33d2b8c
TO
35 * Local cache of function-mappings.
36 *
37 * array(string $cacheKey => array('function' => string, 'is_generic' => bool))
38 *
39 * @var array
c65db512
TO
40 */
41 private $cache;
42
6550386a 43 /**
6550386a 44 */
8882ff5c 45 public function __construct() {
c64f69d9 46 $this->cache = [];
c65db512
TO
47 }
48
6550386a
EM
49 /**
50 * @param \Civi\API\Event\ResolveEvent $event
8882ff5c 51 * API resolution event.
6550386a 52 */
787604ff
TO
53 public function onApiResolve(\Civi\API\Event\ResolveEvent $event) {
54 $apiRequest = $event->getApiRequest();
c65db512 55 $resolved = $this->resolve($apiRequest);
787604ff
TO
56 if ($resolved['function']) {
57 $apiRequest += $resolved;
58 $event->setApiRequest($apiRequest);
59 $event->setApiProvider($this);
60 $event->stopPropagation();
61 }
62 }
63
82376c19 64 /**
3dde2c6c 65 * @inheritDoc
257e7666
EM
66 * @param array $apiRequest
67 * @return array
82376c19 68 */
787604ff
TO
69 public function invoke($apiRequest) {
70 $function = $apiRequest['function'];
71 if ($apiRequest['function'] && $apiRequest['is_generic']) {
72 // Unlike normal API implementations, generic implementations require explicit
73 // knowledge of the entity and action (as well as $params). Bundle up these bits
74 // into a convenient data structure.
d6f190fb 75 if ($apiRequest['action'] === 'getsingle') {
76 // strip any api nested parts here as otherwise chaining may happen twice
77 // see https://lab.civicrm.org/dev/core/issues/643
78 // testCreateBAODefaults fails without this.
79 foreach ($apiRequest['params'] as $key => $param) {
80 if ($key !== 'api.has_parent' && substr($key, 0, 4) === 'api.' || substr($key, 0, 4) === 'api_') {
81 unset($apiRequest['params'][$key]);
82 }
83 }
84 }
787604ff 85 $result = $function($apiRequest);
d6f190fb 86
787604ff
TO
87 }
88 elseif ($apiRequest['function'] && !$apiRequest['is_generic']) {
17617b96 89 $result = $function($apiRequest['params']);
787604ff
TO
90 }
91 return $result;
92 }
93
82376c19 94 /**
3dde2c6c 95 * @inheritDoc
257e7666
EM
96 * @param int $version
97 * @return array
82376c19 98 */
8882ff5c 99 public function getEntityNames($version) {
c64f69d9 100 $entities = [];
82376c19 101 $include_dirs = array_unique(explode(PATH_SEPARATOR, get_include_path()));
82376c19 102 foreach ($include_dirs as $include_dir) {
8882ff5c 103 $api_dir = implode(DIRECTORY_SEPARATOR,
c64f69d9 104 [$include_dir, 'api', 'v' . $version]);
5ecffebd 105 // While it seems pointless to have a folder that's outside open_basedir
106 // listed in include_path and that seems more like a configuration issue,
107 // not everyone has control over the hosting provider's include_path and
108 // this does happen out in the wild, so use our wrapper to avoid flooding
109 // logs.
110 if (!\CRM_Utils_File::isDir($api_dir)) {
82376c19
TO
111 continue;
112 }
113 $iterator = new \DirectoryIterator($api_dir);
114 foreach ($iterator as $fileinfo) {
115 $file = $fileinfo->getFilename();
116
117 // Check for entities with a master file ("api/v3/MyEntity.php")
118 $parts = explode(".", $file);
8882ff5c 119 if (end($parts) == "php" && $file != "utils.php" && !preg_match('/Tests?.php$/', $file)) {
82376c19
TO
120 // without the ".php"
121 $entities[] = substr($file, 0, -4);
122 }
123
8882ff5c 124 // Check for entities with standalone action files (eg "api/v3/MyEntity/MyAction.php").
82376c19
TO
125 $action_dir = $api_dir . DIRECTORY_SEPARATOR . $file;
126 if (preg_match('/^[A-Z][A-Za-z0-9]*$/', $file) && is_dir($action_dir)) {
127 if (count(glob("$action_dir/[A-Z]*.php")) > 0) {
128 $entities[] = $file;
129 }
130 }
131 }
132 }
c64f69d9 133 $entities = array_diff($entities, ['Generic']);
82376c19
TO
134 $entities = array_unique($entities);
135 sort($entities);
136
137 return $entities;
138 }
139
140 /**
3dde2c6c 141 * @inheritDoc
257e7666
EM
142 * @param int $version
143 * @param string $entity
144 * @return array
82376c19
TO
145 */
146 public function getActionNames($version, $entity) {
92776611 147 $entity = _civicrm_api_get_camel_name($entity);
82376c19
TO
148 $entities = $this->getEntityNames($version);
149 if (!in_array($entity, $entities)) {
c64f69d9 150 return [];
c65db512
TO
151 }
152 $this->loadEntity($entity, $version);
153
154 $functions = get_defined_functions();
c64f69d9 155 $actions = [];
6cbff284 156 $prefix = 'civicrm_api' . $version . '_' . _civicrm_api_get_entity_name_from_camel($entity) . '_';
c65db512
TO
157 $prefixGeneric = 'civicrm_api' . $version . '_generic_';
158 foreach ($functions['user'] as $fct) {
159 if (strpos($fct, $prefix) === 0) {
160 $actions[] = substr($fct, strlen($prefix));
161 }
162 elseif (strpos($fct, $prefixGeneric) === 0) {
163 $actions[] = substr($fct, strlen($prefixGeneric));
164 }
165 }
166 return $actions;
167 }
168
169 /**
fe482240 170 * Look up the implementation for a given API request.
c65db512 171 *
8882ff5c
TO
172 * @param array $apiRequest
173 * Array with keys:
174 * - entity: string, required.
175 * - action: string, required.
176 * - params: array.
177 * - version: scalar, required.
c65db512 178 *
8882ff5c
TO
179 * @return array
180 * Array with keys:
181 * - function: callback (mixed)
182 * - is_generic: boolean
c65db512
TO
183 */
184 protected function resolve($apiRequest) {
185 $cachekey = strtolower($apiRequest['entity']) . ':' . strtolower($apiRequest['action']) . ':' . $apiRequest['version'];
186 if (isset($this->cache[$cachekey])) {
187 return $this->cache[$cachekey];
188 }
189
190 $camelName = _civicrm_api_get_camel_name($apiRequest['entity'], $apiRequest['version']);
191 $actionCamelName = _civicrm_api_get_camel_name($apiRequest['action']);
192
193 // Determine if there is an entity-specific implementation of the action
194 $stdFunction = $this->getFunctionName($apiRequest['entity'], $apiRequest['action'], $apiRequest['version']);
195 if (function_exists($stdFunction)) {
196 // someone already loaded the appropriate file
8882ff5c
TO
197 // FIXME: This has the affect of masking bugs in load order; this is
198 // included to provide bug-compatibility.
c64f69d9 199 $this->cache[$cachekey] = ['function' => $stdFunction, 'is_generic' => FALSE];
c65db512
TO
200 return $this->cache[$cachekey];
201 }
202
c64f69d9 203 $stdFiles = [
8882ff5c
TO
204 // By convention, the $camelName.php is more likely to contain the
205 // function, so test it first
c65db512
TO
206 'api/v' . $apiRequest['version'] . '/' . $camelName . '.php',
207 'api/v' . $apiRequest['version'] . '/' . $camelName . '/' . $actionCamelName . '.php',
c64f69d9 208 ];
c65db512
TO
209 foreach ($stdFiles as $stdFile) {
210 if (\CRM_Utils_File::isIncludable($stdFile)) {
211 require_once $stdFile;
212 if (function_exists($stdFunction)) {
c64f69d9 213 $this->cache[$cachekey] = ['function' => $stdFunction, 'is_generic' => FALSE];
c65db512
TO
214 return $this->cache[$cachekey];
215 }
216 }
217 }
218
219 // Determine if there is a generic implementation of the action
220 require_once 'api/v3/Generic.php';
221 # $genericFunction = 'civicrm_api3_generic_' . $apiRequest['action'];
222 $genericFunction = $this->getFunctionName('generic', $apiRequest['action'], $apiRequest['version']);
c64f69d9 223 $genericFiles = [
8882ff5c
TO
224 // By convention, the Generic.php is more likely to contain the
225 // function, so test it first
c65db512
TO
226 'api/v' . $apiRequest['version'] . '/Generic.php',
227 'api/v' . $apiRequest['version'] . '/Generic/' . $actionCamelName . '.php',
c64f69d9 228 ];
c65db512
TO
229 foreach ($genericFiles as $genericFile) {
230 if (\CRM_Utils_File::isIncludable($genericFile)) {
231 require_once $genericFile;
232 if (function_exists($genericFunction)) {
c64f69d9 233 $this->cache[$cachekey] = ['function' => $genericFunction, 'is_generic' => TRUE];
c65db512
TO
234 return $this->cache[$cachekey];
235 }
236 }
237 }
238
c64f69d9 239 $this->cache[$cachekey] = ['function' => FALSE, 'is_generic' => FALSE];
c65db512
TO
240 return $this->cache[$cachekey];
241 }
242
243 /**
8882ff5c
TO
244 * Determine the function name for a given API request.
245 *
c65db512 246 * @param string $entity
8882ff5c 247 * API entity name.
c65db512 248 * @param string $action
8882ff5c
TO
249 * API action name.
250 * @param int $version
251 * API version.
2a6da8d7 252 *
c65db512
TO
253 * @return string
254 */
255 protected function getFunctionName($entity, $action, $version) {
256 $entity = _civicrm_api_get_entity_name_from_camel($entity);
257 return 'civicrm_api' . $version . '_' . $entity . '_' . $action;
258 }
259
260 /**
261 * Load/require all files related to an entity.
262 *
263 * This should not normally be called because it's does a file-system scan; it's
264 * only appropriate when introspection is really required (eg for "getActions").
265 *
266 * @param string $entity
8882ff5c 267 * API entity name.
c65db512 268 * @param int $version
8882ff5c 269 * API version.
c65db512
TO
270 */
271 protected function loadEntity($entity, $version) {
272 $camelName = _civicrm_api_get_camel_name($entity, $version);
273
274 // Check for master entity file; to match _civicrm_api_resolve(), only load the first one
275 $stdFile = 'api/v' . $version . '/' . $camelName . '.php';
276 if (\CRM_Utils_File::isIncludable($stdFile)) {
277 require_once $stdFile;
278 }
279
280 // Check for standalone action files; to match _civicrm_api_resolve(), only load the first one
34f3bbd9
SL
281 // array($relativeFilePath => TRUE)
282 $loaded_files = [];
c65db512
TO
283 $include_dirs = array_unique(explode(PATH_SEPARATOR, get_include_path()));
284 foreach ($include_dirs as $include_dir) {
c64f69d9 285 foreach ([$camelName, 'Generic'] as $name) {
8882ff5c 286 $action_dir = implode(DIRECTORY_SEPARATOR,
c64f69d9 287 [$include_dir, 'api', "v${version}", $name]);
5ecffebd 288 // see note above in getEntityNames about open_basedir
289 if (!\CRM_Utils_File::isDir($action_dir)) {
07007b7a 290 continue;
c65db512
TO
291 }
292
07007b7a
CW
293 $iterator = new \DirectoryIterator($action_dir);
294 foreach ($iterator as $fileinfo) {
295 $file = $fileinfo->getFilename();
296 if (array_key_exists($file, $loaded_files)) {
34f3bbd9
SL
297 // action provided by an earlier item on include_path
298 continue;
07007b7a
CW
299 }
300
301 $parts = explode(".", $file);
302 if (end($parts) == "php" && !preg_match('/Tests?\.php$/', $file)) {
303 require_once $action_dir . DIRECTORY_SEPARATOR . $file;
304 $loaded_files[$file] = TRUE;
305 }
c65db512
TO
306 }
307 }
308 }
309 }
310
2a6da8d7 311}