Commit | Line | Data |
---|---|---|
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 | |
12 | namespace Civi\API\Provider; | |
8882ff5c | 13 | |
787604ff TO |
14 | use Civi\API\Events; |
15 | use Symfony\Component\EventDispatcher\EventSubscriberInterface; | |
16 | ||
17 | /** | |
18 | * This class manages the loading of API's using strict file+function naming | |
19 | * conventions. | |
20 | */ | |
21 | class 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 | } |