From: Tim Otten Date: Wed, 10 Apr 2019 02:16:47 +0000 (-0700) Subject: (dev/core#873) civi.api.prepare - Allow dynamic wrappers X-Git-Url: https://vcs.fsf.org/?a=commitdiff_plain;h=9abe1c3b0acd7fa42ec394ab23d689a4052474ca;p=civicrm-core.git (dev/core#873) civi.api.prepare - Allow dynamic wrappers Overview -------- Allow extensions to dynamically wrap an API. There are some existing mechanisms which sort-of allow wrapping, but this enables additional use-cases. Before ------ There are a few techniques for wrapping or overriding an API, but none is suitable to my current use-case. Limitations: * `hook_civicrm_apiWrappers` - This allows registering things before and after the API call, but it does not allow changing the underlying API call. * `civi.api.{authorize,prepare,respond}` events - Same as above. These are a bit more nuanced/fine-grained, but still does not allow changing * `civi.api.resolve` event with `AdhocProvider` - This allows you to swap an API completely, but it doesn't allow you to delegate back to the original API call (if you've got nothing to add). After ------ One may subscribe to `civi.api.prepare` and then call the `wrapApi()` helper: ```php function onPrepare($event) { if ($event->getApiRequestSig() === '3.widget.frobnicate') { $event->wrapApi(function($apiRequest, $continue){ echo "Hello\n"; $r = $continue($apiRequest); echo "Goodbye\n"; return $r; }); } } ``` Key characteristics: * The wrapper only applies if you register it specifically for the given API call. * The wrapper allows you to defer to the original implementation (`$continue`). * The wrapper allows you to perform logic before and after. * The wrapper allows you to *conditionally* replace -- you might call `$continue` or something entirely different. The style here is more event-oriented, but you can see the same concept in OOP systems, such as PHP's function-override notation. This would be analogous: ```php class MyChild extends MyParent { function frobnicate($arg1) { echo "Hello\n"; $r = parent::frobnicate($arg1); echo "Goodbye\n"; return $r; } } ``` --- diff --git a/Civi/API/Event/Event.php b/Civi/API/Event/Event.php index 7ea0d77f91..d48848da90 100644 --- a/Civi/API/Event/Event.php +++ b/Civi/API/Event/Event.php @@ -88,4 +88,17 @@ class Event extends \Symfony\Component\EventDispatcher\Event { return $this->apiRequest; } + /** + * Create a brief string identifying the entity/action. Useful for + * pithy matching/switching. + * + * Ex: if ($e->getApiRequestSig() === '3.contact.get') { ... } + * + * @return string + * Ex: '3.contact.get' + */ + public function getApiRequestSig() { + return mb_strtolower($this->apiRequest['version'] . '.' . $this->apiRequest['entity'] . '.' . $this->apiRequest['action']); + } + } diff --git a/Civi/API/Event/PrepareEvent.php b/Civi/API/Event/PrepareEvent.php index cc06e179ec..972ba4adb1 100644 --- a/Civi/API/Event/PrepareEvent.php +++ b/Civi/API/Event/PrepareEvent.php @@ -26,6 +26,7 @@ */ namespace Civi\API\Event; +use Civi\API\Provider\WrappingProvider; /** * Class PrepareEvent @@ -43,4 +44,28 @@ class PrepareEvent extends Event { return $this; } + /** + * Replace the normal implementation of an API call with some wrapper. + * + * The wrapper has discretion to call -- or not call -- or iterate with -- + * the original API implementation, with original or substituted arguments. + * + * Ex: + * + * $event->wrapApi(function($apiRequest, $continue){ + * echo "Hello\n"; + * $continue($apiRequest); + * echo "Goodbye\n"; + * }); + * + * @param callable $callback + * The custom API implementation. + * Function(array $apiRequest, callable $continue). + * @return PrepareEvent + */ + public function wrapApi($callback) { + $this->apiProvider = new WrappingProvider($callback, $this->apiProvider); + return $this; + } + } diff --git a/Civi/API/Kernel.php b/Civi/API/Kernel.php index cc0cc3805f..e5d3990217 100644 --- a/Civi/API/Kernel.php +++ b/Civi/API/Kernel.php @@ -164,7 +164,7 @@ class Kernel { list($apiProvider, $apiRequest) = $this->resolve($apiRequest); $this->authorize($apiProvider, $apiRequest); - $apiRequest = $this->prepare($apiProvider, $apiRequest); + list ($apiProvider, $apiRequest) = $this->prepare($apiProvider, $apiRequest); $result = $apiProvider->invoke($apiRequest); return $this->respond($apiProvider, $apiRequest, $result); @@ -250,12 +250,13 @@ class Kernel { * @param array $apiRequest * The full description of the API request. * @return array + * [0 => ProviderInterface $provider, 1 => array $apiRequest] * The revised API request. */ public function prepare($apiProvider, $apiRequest) { /** @var \Civi\API\Event\PrepareEvent $event */ $event = $this->dispatcher->dispatch(Events::PREPARE, new PrepareEvent($apiProvider, $apiRequest, $this)); - return $event->getApiRequest(); + return [$event->getApiProvider(), $event->getApiRequest()]; } /** diff --git a/Civi/API/Provider/WrappingProvider.php b/Civi/API/Provider/WrappingProvider.php new file mode 100644 index 0000000000..d2c08c5dd1 --- /dev/null +++ b/Civi/API/Provider/WrappingProvider.php @@ -0,0 +1,80 @@ +callback = $callback; + $this->original = $original; + } + + public function invoke($apiRequest) { + // $continue = function($a) { return $this->original->invoke($a); }; + $continue = [$this->original, 'invoke']; + return call_user_func($this->callback, $apiRequest, $continue); + } + + public function getEntityNames($version) { + // return $version == $this->version ? [$this->entity] : []; + throw new \API_Exception("Not support: WrappingProvider::getEntityNames()"); + } + + public function getActionNames($version, $entity) { + // return $version == $this->version && $this->entity == $entity ? [$this->action] : []; + throw new \API_Exception("Not support: WrappingProvider::getActionNames()"); + } + +} diff --git a/tests/phpunit/Civi/API/Event/PrepareEventTest.php b/tests/phpunit/Civi/API/Event/PrepareEventTest.php new file mode 100644 index 0000000000..824e03d59f --- /dev/null +++ b/tests/phpunit/Civi/API/Event/PrepareEventTest.php @@ -0,0 +1,103 @@ +dispatcher = new EventDispatcher(); + $this->kernel = new Kernel($this->dispatcher); + } + + public function getPrepareExamples() { + $apiCall = ['Widget', 'frobnicate', ['id' => 98, 'whimsy' => 'green', 'version' => self::MOCK_VERSION]]; + + $exs = []; + + $exs[] = ['onPrepare_null', $apiCall, [98 => 'frob[green]']]; + $exs[] = ['onPrepare_wrapApi', $apiCall, [98 => 'frob[go green] and frob[who green]']]; + + return $exs; + } + + /** + * @param string $onPrepare + * Name of a function (within this test class) to register for 'civi.api.prepare' event. + * @param array $inputApiCall + * @param array $expectResult + * @dataProvider getPrepareExamples + */ + public function testOnPrepare($onPrepare, $inputApiCall, $expectResult) { + $this->dispatcher->addListener(Events::PREPARE, [$this, $onPrepare]); + $this->kernel->registerApiProvider($this->createWidgetFrobnicateProvider()); + $result = call_user_func_array([$this->kernel, 'run'], $inputApiCall); + $this->assertEquals($expectResult, $result['values']); + } + + /** + * Create an API provider for entity "Widget" with action "frobnicate". + * + * @return \Civi\API\Provider\ProviderInterface + */ + public function createWidgetFrobnicateProvider() { + $provider = new \Civi\API\Provider\AdhocProvider(self::MOCK_VERSION, 'Widget'); + $provider->addAction('frobnicate', 'access CiviCRM', function ($apiRequest) { + return civicrm_api3_create_success([ + $apiRequest['params']['id'] => sprintf("frob[%s]", $apiRequest['params']['whimsy']), + ]); + }); + return $provider; + } + + /** + * Baseline - run API call without any manipulation of the result + * + * @param \Civi\API\Event\PrepareEvent $e + */ + public function onPrepare_null(PrepareEvent $e) { + // Nothing to do! + } + + /** + * Wrap the API call. The inputs are altered; the call is run twice; and + * the results are combined. + * + * @param \Civi\API\Event\PrepareEvent $e + */ + public function onPrepare_wrapApi(PrepareEvent $e) { + if ($e->getApiRequestSig() === '3.widget.frobnicate') { + $e->wrapApi(function($apiRequest, $continue) { + $apiRequestA = $apiRequest; + $apiRequestB = $apiRequest; + $apiRequestA['params']['whimsy'] = 'go ' . $apiRequestA['params']['whimsy']; + $apiRequestB['params']['whimsy'] = 'who ' . $apiRequestB['params']['whimsy']; + $resultA = $continue($apiRequestA); + $resultB = $continue($apiRequestB); + $result = []; + // Concatenate the separate results and form one result. + foreach (array_keys($resultA['values']) as $id) { + $result[$id] = $resultA['values'][$id] . ' and ' . $resultB['values'][$id]; + } + return civicrm_api3_create_success($result); + }); + } + } + +}