(dev/core#873) civi.api.prepare - Allow dynamic wrappers
authorTim Otten <totten@civicrm.org>
Wed, 10 Apr 2019 02:16:47 +0000 (19:16 -0700)
committerTim Otten <totten@civicrm.org>
Sat, 13 Apr 2019 22:26:56 +0000 (15:26 -0700)
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;
  }
}
```

Civi/API/Event/Event.php
Civi/API/Event/PrepareEvent.php
Civi/API/Kernel.php
Civi/API/Provider/WrappingProvider.php [new file with mode: 0644]
tests/phpunit/Civi/API/Event/PrepareEventTest.php [new file with mode: 0644]

index 7ea0d77f91ede5eb1cfb42e36172089e916064bb..d48848da90adb0d321cbad3bea1f90e1def78492 100644 (file)
@@ -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']);
+  }
+
 }
index cc06e179ec1fe5729f4c2cea248e099c5ba5f66c..972ba4adb169975c5e865bf08d98c975bbbdf099 100644 (file)
@@ -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;
+  }
+
 }
index cc0cc3805f2c1934e0eb6baf2b071a78a21727e9..e5d3990217456ed73d3ca25ad1178a8661e24bb8 100644 (file)
@@ -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 (file)
index 0000000..d2c08c5
--- /dev/null
@@ -0,0 +1,80 @@
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | CiviCRM version 5                                                  |
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC (c) 2004-2018                                |
+ +--------------------------------------------------------------------+
+ | This file is a part of CiviCRM.                                    |
+ |                                                                    |
+ | CiviCRM is free software; you can copy, modify, and distribute it  |
+ | under the terms of the GNU Affero General Public License           |
+ | Version 3, 19 November 2007 and the CiviCRM Licensing Exception.   |
+ |                                                                    |
+ | CiviCRM is distributed in the hope that it will be useful, but     |
+ | WITHOUT ANY WARRANTY; without even the implied warranty of         |
+ | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.               |
+ | See the GNU Affero General Public License for more details.        |
+ |                                                                    |
+ | You should have received a copy of the GNU Affero General Public   |
+ | License and the CiviCRM Licensing Exception along                  |
+ | with this program; if not, contact CiviCRM LLC                     |
+ | at info[AT]civicrm[DOT]org. If you have questions about the        |
+ | GNU Affero General Public License or the licensing of CiviCRM,     |
+ | see the CiviCRM license FAQ at http://civicrm.org/licensing        |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\API\Provider;
+
+use Civi\API\Events;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * A wrapping provider overrides an existing API. It has discretion to pass-through
+ * to the original API (0 or many times) or to substitute with entirely different
+ * behavior.
+ *
+ * The WrappingProvider does yield any metadata of its own. It's primarily
+ * intended for dynamically decorating an existing API.
+ */
+class WrappingProvider implements ProviderInterface {
+
+  /**
+   * @var callable
+   *   Function($apiRequest, callable $continue)
+   */
+  protected $callback;
+
+  /**
+   * @var ProviderInterface
+   */
+  protected $original;
+
+  /**
+   * WrappingProvider constructor.
+   * @param callable $callback
+   * @param \Civi\API\Provider\ProviderInterface $original
+   */
+  public function __construct($callback, \Civi\API\Provider\ProviderInterface $original) {
+    $this->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 (file)
index 0000000..824e03d
--- /dev/null
@@ -0,0 +1,103 @@
+<?php
+namespace Civi\API\Event;
+
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use Civi\API\Events;
+use Civi\API\Kernel;
+
+/**
+ */
+class PrepareEventTest extends \CiviUnitTestCase {
+  const MOCK_VERSION = 3;
+
+  /**
+   * @var \Symfony\Component\EventDispatcher\EventDispatcher
+   */
+  public $dispatcher;
+
+  /**
+   * @var \Civi\API\Kernel
+   */
+  public $kernel;
+
+  protected function setUp() {
+    parent::setUp();
+    $this->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);
+      });
+    }
+  }
+
+}