flush
authorMichael Wallner <mike@php.net>
Tue, 19 Sep 2017 13:44:18 +0000 (15:44 +0200)
committerMichael Wallner <mike@php.net>
Tue, 19 Sep 2017 13:44:18 +0000 (15:44 +0200)
28 files changed:
composer.json
examples/cache.php [changed mode: 0644->0755]
examples/cli.php [changed mode: 0644->0755]
examples/generator.php
examples/gistlog.php
examples/hooks.php
examples/promise.php
examples/readme.php
lib/API.php
lib/API/Call.php
lib/API/Call/Deferred.php
lib/API/Call/Result.php
lib/API/Consumer.php
lib/API/ContentType.php
lib/API/Future.php
lib/API/Future/functions.php
lib/API/Links/functions.php
lib/Exception/RequestException.php
lib/Exception/functions.php
peridot.php
tests/APITest.php [new file with mode: 0644]
tests/CacheTest.php [new file with mode: 0644]
tests/CallTest.php [new file with mode: 0644]
tests/ContentTypeTest.php [new file with mode: 0644]
tests/ErrorsTest.php [new file with mode: 0644]
tests/GeneratorTest.php [new file with mode: 0644]
tests/api.php [deleted file]
tests/bootstrap.php [new file with mode: 0644]

index c734070b34eb4f0bb2ca796d32ef6f7cbd643709..49a40af8bb650a10ef5bf75ec50d543336ec9dba 100644 (file)
         },
         "files": [
             "lib/functions.php",
-            "lib/Exception/functions.php",
+            "lib/API/functions.php",
             "lib/API/Future/functions.php",
-            "lib/API/Links/functions.php"
+            "lib/API/Links/functions.php",
+            "lib/Exception/functions.php"
         ]
     },
-    "minimum-stability": "beta",
+    "minimum-stability": "dev",
     "prefer-stable": true,
     "require": {
         "php": "^7.0",
@@ -35,9 +36,7 @@
         "react/promise": "dev-async-interop",
         "amphp/amp": "dev-master",
         "amphp/loop": "dev-master",
-        "peridot-php/peridot": "^1.15",
         "monolog/monolog": "^1.19",
-        "peridot-php/leo": "^1.5",
-        "peridot-php/peridot-code-coverage-reporters": "^2.0"
+        "phpunit/phpunit": "^5"
     }
 }
old mode 100644 (file)
new mode 100755 (executable)
index 87c0aef..797fe60
@@ -34,7 +34,7 @@ $cache = new class($redis) implements \seekat\API\Call\Cache\Service {
        }
 };
 
-$api = new seekat\API([
+$api = new seekat\API(seekat\API\Future\react(), [
        "Authorization" => "token ".getenv("GITHUB_TOKEN")
 ], null, null, $log, $cache);
 
old mode 100644 (file)
new mode 100755 (executable)
index 650ca85..fc72130
@@ -1,24 +1,25 @@
+#!/usr/bin/env php
 <?php
 
 require_once __DIR__."/../vendor/autoload.php";
 
-$api = new seekat\API([
+$api = new seekat\API(seekat\API\Future\react(), [
        "Authorization" => "token ".getenv("GITHUB_TOKEN")
 ]);
 array_shift($argv);
 
-($self = function($api) use(&$self) {
+($self = function($error, $api) use(&$self) {
        global $argv;
 
        while (null !== ($arg = array_shift($argv))) {
                if ("." === $arg) {
-                       $api->then($self);
+                       $api->when($self);
                        return;
                }
                $api = $api->$arg;
        }
 
        echo $api, "\n";
-})($api);
+})(null, $api);
 
 $api->send();
index acee8a5f48870e1d6a393464d1af60d782dcc73f..1fd81e60e1edf0296e241bf9556a7101ca26c28a 100755 (executable)
@@ -11,13 +11,13 @@ $log->pushHandler((new Monolog\Handler\StreamHandler(STDERR))->setLevel(Monolog\
 
 $cli = new http\Client("curl", "seekat");
 
-$api = new API([
+$api = new API(API\Future\react(), [
        "Authorization" => "token ".getenv("GITHUB_TOKEN")
 ], null, $cli, $log);
 
 $api(function($api) {
        $count = 0;
-       $events = yield $api->repos->m6w6->{"ext-http"}->issues->events();
+       $events = yield $api->repos->m6w6->{"ext-pq"}->issues->events();
        while ($events) {
                /* pro-actively queue the next request */
                $next = Links\next($events);
@@ -38,7 +38,7 @@ $api(function($api) {
                $events = yield $next;
        }
        return $count;
-})->done(function($count) {
+})->when(function($error, $count) {
        printf("Listed %d events\n", $count);
 });
 
index 723cc0c6f72d7fc1eb693c3e8545c42dc657c0ed..3a1b8ec7922f5b351f303249bf20e6ced6611718 100755 (executable)
@@ -3,9 +3,15 @@
 
 require __DIR__."/../vendor/autoload.php";
 
-$api = new seekat\API([
-       "Authorization" => "token ".getenv("GITHUB_TOKEN")
-]);
+
+$log = new Monolog\Logger("seekat");
+$log->pushHandler(new Monolog\Handler\StreamHandler(STDERR, Monolog\Logger::DEBUG));
+
+$api = new seekat\API(
+       seekat\API\Future\react(),
+       seekat\API\auth("token", getenv("GITHUB_TOKEN")),
+       null, null, $log
+);
 
 $api(function($api) {
        $gists = yield $api->users->m6w6->gists();
@@ -15,7 +21,7 @@ $api(function($api) {
                foreach ($gists as $gist) {
                        foreach ($gist->files as $name => $file) {
                                if ($name == "blog.md") {
-                                       $text = $file->as("raw")->raw();;
+                                       $text = $file->raw();
                                        $head = $gist->description." ".$gist->created_at;
                                        echo "$head\n";
                                        echo str_repeat("=", strlen($head))."\n\n";
index 37cb8e0ed2f895f9e58d3d4dbcf6b6f11ea4d0de..dfcb6359d51d19bf154570cb8e761ab90cae90a8 100755 (executable)
@@ -3,21 +3,20 @@
 
 require_once __DIR__."/../vendor/autoload.php";
 
-use seekat\API;
+use seekat\{API, API\Future, API\Links};
+use Monolog\{Logger, Handler};
 
 $cli = new http\Client("curl", "seekat");
 $cli->configure([
        "max_host_connections" => 10,
        "max_total_connections" => 50,
-       "use_eventloop" => false,
+       "use_eventloop" => true,
 ]);
 
-$log = new Monolog\Logger("seekat");
-$log->pushHandler((new Monolog\Handler\StreamHandler(STDERR))->setLevel(Monolog\Logger::WARNING));
+$log = new Logger("seekat");
+$log->pushHandler(new Handler\StreamHandler(STDERR, Logger::NOTICE));
 
-$api = new API([
-       "Authorization" => "token ".getenv("GITHUB_TOKEN")
-], null, $cli, $log);
+$api = new API(Future\react(), API\auth("token", getenv("GITHUB_TOKEN")), null, $cli, $log);
 
 $api(function() use($api) {
        $repos = yield $api->users->m6w6->repos([
@@ -25,7 +24,7 @@ $api(function() use($api) {
                "affiliation" => "owner"
        ]);
        while ($repos) {
-               $next = next($repos);
+               $next = Links\next($repos);
 
                $batch = [];
                foreach ($repos as $repo) {
index c26e91131fee05377d99e8cf6ab74b8b159d3901..cee5c74a9652bb2d66392a8f3d6f6e3afc3845fe 100755 (executable)
@@ -4,16 +4,14 @@
 require_once __DIR__."/../vendor/autoload.php";
 
 use seekat\API;
-use seekat\API\Future;
 
-$log = new Monolog\Logger("seekat");
-$log->pushHandler((new Monolog\Handler\StreamHandler(STDERR))->setLevel(Monolog\Logger::NOTICE));
-
-$api = new API(Future\amp(), API\auth("token", getenv("GITHUB_TOKEN")), null, null, $log);
+$api = new API(API\Future\amp(), API\auth("token", getenv("GITHUB_TOKEN")));
 
 $api->users->m6w6->gists()->when(function($error, $gists) {
+       $error and die($error);
        foreach ($gists as $gist) {
                $gist->commits()->when(function($error, $commits) use($gist) {
+                       $error and die($error);
                        foreach ($commits as $i => $commit) {
                                if (!$i) {
                                        printf("\nGist %s, %s:\n", $gist->id, $gist->description ?: "<no title>");
index 93cc464dae889dc50942a9e3473386cfecd8b59b..f7b6b57500e2d2c8d16fc8641b01011b71a99a2c 100755 (executable)
@@ -3,6 +3,6 @@
 
 require_once __DIR__."/../vendor/autoload.php";
 
-(new seekat\API)(function($api) {
+(new seekat\API(seekat\API\Future\amp()))(function($api) {
        echo yield $api->repos->m6w6->seekat->readme->as("raw")->get();
 });
index 546dd88da0441c35332c504e5eba3158899c684a..a0474122cd78e67b0d24df24c72ced3032e5e7ce 100644 (file)
@@ -25,52 +25,52 @@ class API implements IteratorAggregate, Countable {
        private $url;
 
        /**
-        * Logger
-        * @var LoggerInterface
+        * Default headers to send out to the API endpoint
+        * @var array
         */
-       private $logger;
+       private $headers;
 
        /**
-        * Cache
-        * @var Call\Cache\Service
+        * Current endpoints links
+        * @var Links
         */
-       private $cache;
+       private $links;
 
        /**
-        * Promisor
-        * @var Future
+        * Current endpoint data's Content-Type
+        * @var API\ContentType
         */
-       private $future;
+       private $type;
 
        /**
-        * The HTTP client
-        * @var Client
+        * Current endpoint's data
+        * @var array|object
         */
-       private $client;
+       private $data;
 
        /**
-        * Default headers to send out to the API endpoint
-        * @var array
+        * Logger
+        * @var LoggerInterface
         */
-       private $headers;
+       private $logger;
 
        /**
-        * Current endpoint data's Content-Type
-        * @var API\ContentType
+        * Cache
+        * @var Call\Cache\Service
         */
-       private $type;
+       private $cache;
 
        /**
-        * Current endpoint's data
-        * @var array|object
+        * Promisor
+        * @var Future
         */
-       private $data;
+       private $future;
 
        /**
-        * Current endpoints links
-        * @var Links
+        * The HTTP client
+        * @var Client
         */
-       private $links;
+       private $client;
 
        /**
         * Create a new API endpoint root
@@ -165,7 +165,9 @@ class API implements IteratorAggregate, Countable {
        function __invoke($cbg) : Promise {
                $this->logger->debug(__FUNCTION__);
 
-               $consumer = new Consumer($this->client);
+               $consumer = new Consumer($this->getFuture(), function() {
+                       $this->client->send();
+               });
 
                invoke:
                if ($cbg instanceof Generator) {
@@ -273,7 +275,7 @@ class API implements IteratorAggregate, Countable {
        function export() : array {
                $data = $this->data;
                $url = clone $this->url;
-               $type = clone $this->type;
+               $type = $this->type ? clone $this->type : null;
                $links = $this->links ? clone $this->links : null;
                $headers = $this->headers;
                return compact("url", "data", "type", "links", "headers");
@@ -355,6 +357,17 @@ class API implements IteratorAggregate, Countable {
                return $that;
        }
 
+       /**
+        * Perform a HEAD request against the endpoint's underlying URL
+        *
+        * @param mixed $args The HTTP query string parameters
+        * @param array $headers The request's additional HTTP headers
+        * @return Promise
+        */
+       function head($args = null, array $headers = null, $cache = null) : Promise {
+               return $this->request("HEAD", $args, null, $headers, $cache);
+       }
+
        /**
         * Perform a GET request against the endpoint's underlying URL
         *
index ba51ca3fe23dbe2d74ef60a2b3bc9afe6974bf00..7eba2a80b7fbfb76f6b7748c728c06797410c357 100644 (file)
@@ -25,26 +25,11 @@ final class Call
        }
 
        function __invoke(array $args) : Promise {
-               $promise = $this->api->{$this->call}->get(...$args);
-
-               /* fetch resource, unless already localized, and try for {$method}_url */
-               if (!$this->api->exists($this->call)) {
-                       $promise->when(function($error, $value) use($args) {
-                               if (!isset($error)) {
-                                       return $value;
-                               }
-                               if ($this->api->exists($this->call."_url", $url)) {
-                                       $url = new Url(uri_template($url, (array)current($args)));
-                                       return $this->api->withUrl($url)->get(...$args);
-                               }
-
-                               $message = Exception\message($error);
-                               $this->api->getLogger()->error("call($this->call): " . $message, [
-                                       "url" => (string) $this->api->getUrl()
-                               ]);
-
-                               throw $error;
-                       });
+               if ($this->api->exists($this->call."_url", $url)) {
+                       $url = new Url(uri_template($url, (array)current($args)));
+                       $promise = $this->api->withUrl($url)->get(...$args);
+               } else {
+                       $promise = $this->api->{$this->call}->get(...$args);
                }
 
                return $promise;
index 9bdc030ba955e07c562b20f6913c8d88b6c62287..562978c79ba44ad012f9a4c795576983d9039e5e 100644 (file)
@@ -3,16 +3,13 @@
 namespace seekat\API\Call;
 
 use AsyncInterop\Promise;
-use Exception;
 use http\{
        Client, Client\Request, Client\Response
 };
 use Psr\Log\LoggerInterface;
 use seekat\API;
-use SplObserver;
-use SplSubject;
 
-final class Deferred implements SplObserver
+final class Deferred
 {
        /**
         * The response importer
@@ -94,7 +91,6 @@ final class Deferred implements SplObserver
                                /* we did finish in the meantime */
                                $this->complete();
                        } else {
-                               $this->client->detach($this);
                                $this->client->dequeue($this->request);
                                ($this->reject)("Cancelled");
                        }
@@ -102,7 +98,6 @@ final class Deferred implements SplObserver
                $this->promise = $future->getPromise($context);
                $this->resolve = API\Future\resolver($future, $context);
                $this->reject = API\Future\rejecter($future, $context);
-               $this->update = API\Future\updater($future, $context);
        }
 
        function __invoke() : Promise {
@@ -115,7 +110,6 @@ final class Deferred implements SplObserver
                        $this->response = $cached;
                        $this->complete();
                } else {
-                       $this->client->attach($this);
                        $this->client->enqueue($this->request, function(Response $response) use($cached) {
                                if ($response->getResponseCode() == 304) {
                                        $this->response = $cached;
@@ -136,31 +130,10 @@ final class Deferred implements SplObserver
                return $this->promise;
        }
 
-       /**
-        * Progress observer
-        *
-        * Import the response's data on success and resolve the promise.
-        *
-        * @param SplSubject $client The observed HTTP client
-        * @param Request $request The request which generated the update
-        * @param object $progress The progress information
-        */
-       function update(SplSubject $client, Request $request = null, $progress = null) {
-               if ($request !== $this->request) {
-                       return;
-               }
-
-               ($this->update)((object) compact("client", "request", "progress"));
-       }
-
        /**
         * Completion callback
-        * @param callable $resolve
-        * @param callable $reject
         */
        private function complete() {
-               $this->client->detach($this);
-
                if ($this->response) {
                        try {
                                $api = ($this->result)($this->response);
@@ -168,7 +141,7 @@ final class Deferred implements SplObserver
                                $this->cache->save($this->request, $this->response);
 
                                ($this->resolve)($api);
-                       } catch (Exception $e) {
+                       } catch (\Throwable $e) {
                                ($this->reject)($e);
                        }
                } else {
index 22ce2efb29dbc4cf1ca17505107c88f00d8c7231..6e1d8f818f0251196d7d6cf158b6a76917ee485a 100644 (file)
@@ -24,16 +24,7 @@ final class Result
 
                $links = $this->checkResponseMeta($response);
                $type = $this->checkResponseType($response);
-
-               try {
-                       $data = $type->parseBody($response->getBody());
-               } catch (\Exception $e) {
-                       $this->api->getLogger()->error("response -> error: ".$e->getMessage(), [
-                               "url" => (string) $this->api->getUrl(),
-                       ]);
-
-                       throw $e;
-               }
+               $data = $this->checkResponseBody($response, $type);
 
                return $this->api = $this->api->with(compact("type", "data", "links"));
        }
@@ -74,4 +65,18 @@ final class Result
 
                return new API\ContentType($type);
        }
+
+       private function checkResponseBody(Response $response, API\ContentType $type) {
+               try {
+                       $data = $type->parseBody($response->getBody());
+               } catch (\Exception $e) {
+                       $this->api->getLogger()->error("response -> error: ".$e->getMessage(), [
+                               "url" => (string) $this->api->getUrl(),
+                       ]);
+
+                       throw $e;
+               }
+
+               return $data;
+       }
 }
index 6e677fe0dde7e3e1dde39f1ac6a80f1aab47b608..d593588aee7883c47302b9c4b03c953793e7a929 100644 (file)
@@ -2,22 +2,21 @@
 
 namespace seekat\API;
 
+use AsyncInterop\Promise;
 use Generator;
 use http\Client;
-use React\Promise\{
-       Deferred,
-       ExtendedPromiseInterface,
-       PromiseInterface,
-       function all
+use seekat\API;
+use seekat\Exception\{
+       InvalidArgumentException, UnexpectedValueException, function exception
 };
 
-final class Consumer extends Deferred
+final class Consumer
 {
        /**
-        * The HTTP client
-        * @var Client
+        * Loop
+        * @var callable
         */
-       private $client;
+       private $loop;
 
        /**
         * The return value of the generator
@@ -32,24 +31,49 @@ final class Consumer extends Deferred
        private $cancelled = false;
 
        /**
-        * Create a new generator invoker
-        * @param Client $client
+        * @var Promise
         */
-       function __construct(Client $client) {
-               $this->client = $client;
+       private $promise;
 
-               parent::__construct(function($resolve, $reject) {
-                       return $this->cancel($resolve, $reject);
+       /**
+        * @var \Closure
+        */
+       private $resolve;
+
+       /**
+        * @var \Closure
+        */
+       private $reject;
+
+       /**
+        * @var \Closure
+        */
+       private $reduce;
+
+       /**
+        * Create a new generator consumer
+        * @param Future $future
+        * @param callable $loop
+        */
+       function __construct(Future $future, callable $loop) {
+               $this->loop = $loop;
+
+               $context = $future->createContext(function() {
+                       $this->cancelled = true;
                });
+               $this->promise = $future->getPromise($context);
+               $this->resolve = API\Future\resolver($future, $context);
+               $this->reject = API\Future\rejecter($future, $context);
+               $this->reduce = API\Future\reducer($future, $context);
        }
 
        /**
         * Iterate over $gen, a \Generator yielding promises
         *
         * @param Generator $gen
-        * @return ExtendedPromiseInterface
+        * @return Promise
         */
-       function __invoke(Generator $gen) : ExtendedPromiseInterface {
+       function __invoke(Generator $gen) : Promise {
                $this->cancelled = false;
 
                foreach ($gen as $promise) {
@@ -59,50 +83,47 @@ final class Consumer extends Deferred
                        $this->give($promise, $gen);
                }
 
+               #($this->loop)();
+
                if (!$this->cancelled) {
-                       $this->resolve($this->result = $gen->getReturn());
+                       $this->result = $gen->getReturn();
+               }
+               if (isset($this->result)) {
+                       ($this->resolve)($this->result);
+               } else {
+                       ($this->reject)("Cancelled");
                }
 
-               return $this->promise();
+               return $this->promise;
        }
 
        /**
         * Promise handler
         *
-        * @param array|PromiseInterface $promise
+        * @param array|Promise $promise
         * @param Generator $gen
         */
        private function give($promise, Generator $gen) {
-               if ($promise instanceof PromiseInterface) {
-                       $promise->then(function($result) use($gen) {
-                               if (($promise = $gen->send($result))) {
-                                       $this->give($promise, $gen);
+               if ($promise instanceof \Traversable) {
+                       $promise = iterator_to_array($promise);
+               }
+               if (is_array($promise)) {
+                       $promise = ($this->reduce)($promise);
+               }
+               if ($promise instanceof Promise) {
+                       $promise->when(function($error, $result) use($gen) {
+                               if ($error) {
+                                       $gen->throw(exception($error));
                                }
-                       });
-               } else {
-                       all($promise)->then(function($results) use($gen) {
-                               if (($promise = $gen->send($results))) {
+                               if (($promise = $gen->send($result))) {
                                        $this->give($promise, $gen);
                                }
                        });
-               }
-               $this->client->send();
-       }
-
-       /**
-        * Cancellation callback
-        *
-        * @param callable $resolve
-        * @param callable $reject
-        */
-       private function cancel(callable $resolve, callable $reject) {
-               $this->cancelled = true;
-
-               /* did we finish in the meantime? */
-               if ($this->result) {
-                       $resolve($this->result);
                } else {
-                       $reject("Cancelled");
+                       $gen->throw(new UnexpectedValueException(
+                               "Expected Promise or array of Promises; got ".\seekat\typeof($promise)));
                }
+               /* FIXME: external loop */
+               ($this->loop)();
        }
 }
index 3ce123d1024e391dc30eea192c6956e463631eb6..d13b11a6d925a4581fefd96d203b9779c5ed4f1a 100644 (file)
@@ -123,7 +123,7 @@ final class ContentType
         * @throws UnexpectedValueException
         */
        private static function fromBase64(Body $base64) : string {
-               if (false === ($decoded = base64_decode($base64))) {
+               if (false === ($decoded = base64_decode($base64, true))) {
                        throw new UnexpectedValueException("Could not decode BASE64");
                }
                return $decoded;
index 8c3571c6e0dc6a8bceff2ce40811943ec64cf33f..2b53ea1cf847c827be0838eb5320f54557f1694f 100644 (file)
@@ -18,6 +18,12 @@ interface Future
         */
        function getPromise($context) : Promise;
 
+       /**
+        * @param Promise $promise
+        * @return bool
+        */
+       function cancelPromise(Promise $promise) : bool;
+
        /**
         * @param object $context Promisor returned by createContext
         * @param mixed $value
@@ -34,8 +40,8 @@ interface Future
 
        /**
         * @param object $context Promisor returned by createContext
-        * @param mixed $update
-        * @return void
+        * @param array $promises
+        * @return Promise
         */
-       function onUpdate($context, $update);
+       function onMultiple($context, array $promises) : Promise;
 }
index 34d38c5adbe78d55c59b9c3a223f8cea11017aa0..b6e890886aa511a8b8152fd9f308dbc7c1d03087 100644 (file)
@@ -4,7 +4,6 @@ namespace seekat\API\Future;
 
 use Amp\Deferred as AmpDeferred;
 use AsyncInterop\Promise;
-use Icicle\Awaitable\Deferred as IcicleDeferred;
 use React\Promise\Deferred as ReactDeferred;
 use seekat\API\Future;
 
@@ -57,9 +56,9 @@ function rejecter(Future $future, $context) {
  * @param mixed $context Promisor
  * @return \Closure
  */
-function updater(Future $future, $context) {
-       return function($update) use($future, $context) {
-               return $future->onUpdate($context, $update);
+function reducer(Future $future, $context) {
+       return function(array $promises) use($future, $context) : Promise {
+               return $future->onMultiple($context, $promises);
        };
 }
 
@@ -81,6 +80,12 @@ function react() {
                        return $context->promise();
                }
 
+               function cancelPromise(Promise $promise) : bool {
+                       /* @var $promise \React\Promise\Promise */
+                       $promise->cancel();
+                       return true;
+               }
+
                function onSuccess($context, $value) {
                        /* @var $context ReactDeferred */
                        $context->resolve($value);
@@ -91,9 +96,8 @@ function react() {
                        $context->reject($reason);
                }
 
-               function onUpdate($context, $update) {
-                       /* @var $context ReactDeferred */
-                       $context->notify($update);
+               function onMultiple($context, array $promises) : Promise {
+                       return \React\Promise\all($promises);
                }
        };
 }
@@ -115,54 +119,22 @@ function amp() {
                        return $context->promise();
                }
 
-               function onSuccess($context, $value) {
-                       /* @var $context AmpDeferred */
-                       $context->resolve($value);
-               }
-
-               function onFailure($context, $reason) {
-                       /* @var $context AmpDeferred */
-                       $context->fail($reason);
-               }
-
-               function onUpdate($context, $update) {
-                       /* @var $context AmpDeferred */
-                       /* noop */
-               }
-       };
-}
-
-/**
- * @return Future
- */
-function icicle() {
-       return new class implements Future {
-               /**
-                * @param callable|null $onCancel
-                * @return IcicleDeferred
-                */
-               function createContext(callable $onCancel = null) {
-                       return new IcicleDeferred($onCancel);
-               }
-
-               function getPromise($context): Promise {
-                       /* @var $context IcicleDeferred */
-                       return $context->getPromise();
+               function cancelPromise(Promise $promise) : bool {
+                       return false;
                }
 
                function onSuccess($context, $value) {
-                       /* @var $context IcicleDeferred */
+                       /* @var $context AmpDeferred */
                        $context->resolve($value);
                }
 
                function onFailure($context, $reason) {
-                       /* @var $context IcicleDeferred */
-                       $context->reject($reason);
+                       /* @var $context AmpDeferred */
+                       $context->fail(\seekat\Exception\exception($reason));
                }
 
-               function onUpdate($context, $update) {
-                       /* @var $context IcicleDeferred */
-                       /* noop */
+               function onMultiple($context, array $promises) : Promise {
+                       return \Amp\all($promises);
                }
        };
 }
index eb9b495adedb11354d95b4b44e2cb1f1fb3f61da..f740851e9de6bc00131750539a63751142a89763 100644 (file)
@@ -17,7 +17,7 @@ function first(API $api, Cache\Service $cache = null) : Promise {
        if ($links && ($first = $links->getFirst())) {
                return $api->withUrl($first)->get(null, null, $cache);
        }
-       return Future\reject($api->getFuture(), $links);
+       return Future\resolve($api->getFuture(), null);
 }
 
 /**
@@ -30,7 +30,7 @@ function prev(API $api, Cache\Service $cache = null) : Promise {
        if ($links && ($prev = $links->getPrev())) {
                return $api->withUrl($prev)->get(null, null, $cache);
        }
-       return Future\reject($api->getFuture(), $links);
+       return Future\resolve($api->getFuture(), null);
 }
 
 /**
@@ -43,7 +43,7 @@ function next(API $api, Cache\Service $cache = null) : Promise {
        if ($links && ($next = $links->getNext())) {
                return $api->withUrl($next)->get(null, null, $cache);
        }
-       return Future\reject($api->getFuture(), $links);
+       return Future\resolve($api->getFuture(), null);
 }
 
 /**
@@ -56,6 +56,6 @@ function last(API $api, Cache\Service $cache = null) : Promise {
        if ($links && ($last = $links->getLast())) {
                return $api->withUrl($last)->get(null, null, $cache);
        }
-       return Future\reject($api->getFuture(), $links);
+       return Future\resolve($api->getFuture(), null);
 }
 
index 575097fc811a2d391db475b3db0a633cda598395..3ef263c00e8d4d72ef700eee5ca5858e15e3dc42 100644 (file)
@@ -60,6 +60,13 @@ class RequestException extends \Exception implements Exception
                return $this->errors;
        }
 
+       /**
+        * @return Response
+        */
+       function getResponse() : Response {
+               return $this->response;
+       }
+
        /**
         * Combine any errors into a single string
         * @staticvar array $reasons
index 08c8ddbca656d3cfc88d8f9065b9f6b6223a9fd6..9045cb3df3473f1f41df7a9422fb5f4a8c37b432 100644 (file)
@@ -2,6 +2,20 @@
 
 namespace seekat\Exception;
 
+/**
+ * @param $message
+ * @return \Throwable
+ */
+function exception(&$message) : \Throwable {
+       if ($message instanceof \Throwable){
+               $exception = $message;
+               $message = $exception->getMessage();
+       } else {
+               $exception = new \Exception($message);
+       }
+       return $exception;
+}
+
 /**
  * Canonical error message from a string or Exception
  * @param string|Exception $error
@@ -12,7 +26,7 @@ function message(&$error) : string {
                $message = $error->getMessage();
        } else {
                $message = $error;
-               $error = new \Exception($error);
+               $error = new \Exception($message);
        }
        return $message;
 }
index 77d617fb9ff64e7418ef5b27e8c873a580b8012f..066dca7beac40c44e7529df401aab45f75c6cd39 100644 (file)
@@ -1,72 +1,25 @@
 <?php
 
-use Evenement\EventEmitterInterface as EventEmitter;
 use Monolog\Handler\AbstractProcessingHandler;
 use Monolog\Logger;
+use Peridot\Cli\Environment;
+use Peridot\Cli\Application;
 use Peridot\Configuration;
-use Peridot\Console\Application;
-use Peridot\Console\Environment;
 use Peridot\Core\Suite;
 use Peridot\Core\Test;
-use Peridot\Reporter\AbstractBaseReporter;
-use Peridot\Reporter\AnonymousReporter;
-use Peridot\Reporter\CodeCoverage\AbstractCodeCoverageReporter;
-use Peridot\Reporter\CodeCoverageReporters;
-use Peridot\Reporter\ReporterFactory;
+use Peridot\Plugin\Scenarios;
 use seekat\API;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Output\OutputInterface;
 
-return function(EventEmitter $emitter) {
-       (new CodeCoverageReporters($emitter))->register();
-
-       $emitter->on('peridot.reporters', function(InputInterface $input, ReporterFactory $reporterFactory) {
-        $reporterFactory->register(
-            'seekat',
-            'Spec + Text Code coverage reporter',
-            function(AnonymousReporter $ar) use ($reporterFactory) {
-
-                               return new class($reporterFactory, $ar->getConfiguration(), $ar->getOutput(), $ar->getEventEmitter()) extends AbstractBaseReporter {
-                                       private $reporters = [];
-                                       private $factory;
-
-                                       function __construct(ReporterFactory $factory, Configuration $configuration, OutputInterface $output, EventEmitter $eventEmitter) {
-                                               $this->factory = $factory;
-                                               parent::__construct($configuration, $output, $eventEmitter);
-                                       }
-
-                                       function init() {
-                                               fprintf(STDERR, "Creating reporters\n");
-                                               $this->reporters[] = $this->factory->create("spec");
-                                               if (extension_loaded("xdebug")) {
-                                                       $this->reporters[] = $this->factory->create("text-code-coverage");
-                                               }
-                                       }
-
-                                       function X__call($method, array $args) {
-                                               fprintf(STDERR, "Calling %s\n", $method);
-                                               foreach ($this->reporters as $reporter) {
-                                                       $output = $reporter->$method(...$args);
-                                               }
-                                               return $output;
-                                       }
-                               };
-                       }
-        );
-       });
-
-       $emitter->on('code-coverage.start', function (AbstractCodeCoverageReporter $reporter) {
-        $reporter->addDirectoryToWhitelist(__DIR__."/lib")
-                       ->addDirectoryToWhitelist(__DIR__."/tests");
-       });
+return function(\Peridot\EventEmitterInterface $emitter) {
+       Scenarios\Plugin::register($emitter);
 
        $emitter->on("peridot.start", function(Environment $env, Application $app) {
                $app->setCatchExceptions(false);
                $definition = $env->getDefinition();
                $definition->getArgument("path")
                        ->setDefault(implode(" ", glob("tests/*")));
-               $definition->getOption("reporter")
-                       ->setDefault("seekat");
        });
 
        $log = new class extends AbstractProcessingHandler {
@@ -85,7 +38,7 @@ return function(EventEmitter $emitter) {
                        }
                }
        };
-       $emitter->on("suite.start", function(Suite $suite) use($log) {
+       $emitter->on("suite.start", function(Suite $suite) use(&$headers, $log) {
                $headers = [];
                if (($token = getenv("GITHUB_TOKEN"))) {
                        $headers["Authorization"] = "token $token";
@@ -98,7 +51,10 @@ return function(EventEmitter $emitter) {
                } else {
                        throw new Exception("GITHUB_TOKEN is not set in the environment");
                }
-               $suite->getScope()->api = new API($headers, null, null, new Logger("seekat", [$log]));
+               $suite->getScope()->amp = new API(API\Future\amp(),
+                       $headers, null, null, new Logger("amp", [$log]));
+               $suite->getScope()->react = new API(API\Future\react(),
+                       $headers, null, null, new Logger("react", [$log]));
        });
 
        $emitter->on("test.failed", function(Test $test, \Throwable $e) {
diff --git a/tests/APITest.php b/tests/APITest.php
new file mode 100644 (file)
index 0000000..9f17af6
--- /dev/null
@@ -0,0 +1,73 @@
+<?php
+
+use AsyncInterop\Promise;
+use seekat\API;
+
+class APITest extends BaseTest
+{
+       function provideHttpMethodAndAPI() {
+               $data = [];
+               $methods = [
+                       "HEAD",
+                       "GET",
+                       "POST",
+                       "PUT",
+                       "DELETE",
+                       "PATCH"
+               ];
+               foreach ($this->provideAPI() as $name => list($api)) {
+                       foreach ($methods as $method) {
+                               $data["$method $name"] = [$api, $method];
+                       }
+               }
+               return $data;
+       }
+
+       /**
+        * @group testdox
+        * @dataProvider provideAPI
+        */
+       function testReturnsApiOnPropertyAccess($api) {
+               $this->assertInstanceOf(API::class, $api->foo);
+       }
+
+       /**
+        * @group testdox
+        * @dataProvider provideAPI
+        */
+       function testReturnsCloneOnPropertyAccess($api) {
+               $this->assertNotEquals($api, $api->bar);
+       }
+
+       /**
+        * @group testdox
+        * @dataProvider provideAPI
+        */
+       function testReturnsPromiseOnMethodCall($api) {
+               $this->assertInstanceOf(Promise::class, $api->baz());
+       }
+
+       /**
+        * @dataProvider provideHttpMethodAndAPI
+        */
+       function testProvidesMethodForStandardHttpMethod($api, $method) {
+               $this->assertTrue(method_exists($api, $method));
+       }
+
+       /**
+        * @dataProvider provideHttpMethodAndAPI
+        */
+       function testReturnsPromiseOnMethodCallForStandardHttpMethod($api, $method) {
+               $this->assertInstanceOf(Promise::class, $api->$method());
+       }
+
+       /**
+        * @group testdox
+        * @dataProvider provideAPI
+        * @depends testProvidesMethodForStandardHttpMethod
+        * @depends testReturnsPromiseOnMethodCallForStandardHttpMethod
+        */
+       function testProvidesMethodsForStandardHttpMethods($api) {
+               $this->assertTrue(true);
+       }
+}
diff --git a/tests/CacheTest.php b/tests/CacheTest.php
new file mode 100644 (file)
index 0000000..6ceffbd
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+
+class CacheTest extends BaseTest
+{
+       use ConsumePromise;
+       use AssertSuccess;
+
+       /**
+        * @var seekat\API\Cache\Service
+        */
+       private $cache;
+
+       function setUp() {
+               $this->cache = new seekat\API\Call\Cache\Service\Hollow;
+       }
+
+       /**
+        * @group testdox
+        * @dataProvider provideAPI
+        */
+       function testCachesSuccessiveCalls($api) {
+               $m6w6 = $this->assertSuccess($api->users->m6w6, null, null, $this->cache);
+               $data = $this->cache->getStorage();
+               $m6w6_ = $this->assertSuccess($api->users->m6w6, null, null, $this->cache);
+
+               $this->assertEquals("m6w6", $m6w6->login);
+               $this->assertEquals("m6w6", $m6w6_->login);
+
+               $this->assertInternalType("array", $data);
+               $this->assertCount(1, $data);
+               $this->assertEquals($data, $this->cache->getStorage());
+       }
+
+       /**
+        * @group testdox
+        * @dataProvider provideAPI
+        */
+       function testRefreshesStaleCacheEntries($api) {
+               $this->markTestIncomplete("TODO");
+       }
+}
diff --git a/tests/CallTest.php b/tests/CallTest.php
new file mode 100644 (file)
index 0000000..4227e1e
--- /dev/null
@@ -0,0 +1,119 @@
+<?php
+
+use seekat\API;
+
+class CallTest extends BaseTest
+{
+       use ConsumePromise;
+       use AssertSuccess;
+
+       /**
+        * @group testdox
+        * @dataProvider provideAPI
+        */
+       function testResolvesPromiseWithResultAfterCallCompletes($api) {
+               $m6w6 = $this->assertSuccess($api->users->m6w6);
+               $this->assertInstanceOf(API::class, $m6w6);
+       }
+
+       /**
+        * @group testdox
+        * @dataProvider provideAPI
+        */
+       function testExportsArrayWithKeysDataAndUrlAndTypeAndLinksAndHeaders($api) {
+               $m6w6 = $this->assertSuccess($api->users->m6w6);
+               $export = $m6w6->export();
+               $this->assertArrayHasKey("data", $export);
+               $this->assertArrayHasKey("url", $export);
+               $this->assertArrayHasKey("type", $export);
+               $this->assertArrayHasKey("links", $export);
+               $this->assertArrayHasKey("headers", $export);
+       }
+
+       /**
+        * @group testdox
+        * @dataProvider provideAPI
+        */
+       function testFetchedDataIsAccessibleOnPropertyAccess($api) {
+               $m6w6 = $this->assertSuccess($api->users->m6w6);
+               $this->assertEquals("m6w6", $m6w6->login);
+       }
+
+       /**
+        * @group testdox
+        * @dataProvider provideAPI
+        */
+       function testFetchedDataIsAccessibleOnPropertyAccessDespiteUrlSuffixAvailable($api) {
+               $m6w6 = $this->assertSuccess($api->users->m6w6);
+               $this->assertGreaterThan(0, (int) (string) $m6w6->followers);
+       }
+
+       /**
+        * @group testdox
+        * @dataProvider provideAPI
+        */
+       function testFetchUrlSuffix($api) {
+               $m6w6 = $this->assertSuccess($api->users->m6w6);
+               $followers = $this->assertSuccess($api->users->m6w6->followers);
+               $data = $followers->export()["data"];
+               $this->assertInternalType("array", $data);
+               $this->assertInternalType("object", $data[0]);
+               $this->assertInternalType("object", $followers->{0});
+               $this->assertGreaterThan(30, (string) $m6w6->followers);
+               $this->assertGreaterThan(0, count($followers));
+       }
+
+       /**
+        * @group testdox
+        * @dataProvider provideAPI
+        */
+       function testFetchExplicitUrlSuffix($api) {
+               $m6w6 = $this->assertSuccess($api->users->m6w6);
+               $followers = $this->assertSuccess($m6w6->followers_url);
+               $data = $followers->export()["data"];
+               $this->assertInternalType("array", $data);
+               $this->assertInternalType("object", $data[0]);
+               $this->assertInternalType("object", $followers->{0});
+               $this->assertGreaterThan(30, (string) $m6w6->followers);
+               $this->assertGreaterThan(0, count($followers));
+       }
+
+       /**
+        * @group testdox
+        * @dataProvider provideAPI
+        */
+       function testFetchImplicitUrlSuffix($api) {
+               $m6w6 = $this->assertSuccess($api->users->m6w6);
+               $promise = $m6w6->followers();
+               $this->consumePromise($promise, $errors, $results);
+               $api->send();
+               $this->assertEmpty($errors);
+               $this->assertNotEmpty($results);
+               $followers = $results[0];
+               $this->assertInstanceOf(API::class, $followers);
+               $data = $followers->export()["data"];
+               $this->assertInternalType("array", $data);
+               $this->assertGreaterThan(0, count($data));
+       }
+
+       /**
+        * @group testdox
+        * @dataProvider provideAPI
+        */
+       function testFetchParallelFromIterator($api) {
+               $m6w6 = $this->assertSuccess($api->users->m6w6);
+               foreach ($m6w6 as $key => $val) {
+                       switch ($key) {
+                               case "html_url":
+                               case "avatar_url":
+                                       break;
+                               default:
+                                       if (substr($key, -4) === "_url") {
+                                               $batch[] = $val;
+                                       }
+                       }
+               }
+               $results = $this->assertAllSuccess($batch);
+               $this->assertGreaterThan(2, $results);
+       }
+}
diff --git a/tests/ContentTypeTest.php b/tests/ContentTypeTest.php
new file mode 100644 (file)
index 0000000..2bdeb48
--- /dev/null
@@ -0,0 +1,162 @@
+<?php
+
+use seekat\API\ContentType;
+use http\Header;
+use http\Message\Body;
+
+class ContentTypeTest extends BaseTest
+{
+       /**
+        * @group testdox
+        * @dataProvider provideAPI
+        */
+       function testIsAbleToApplyVersionedContentTypeToApi($api) {
+               $api = ContentType::apply($api, "json");
+               $this->assertEquals(
+                       $this->getVersionedContentType("json")->value,
+                       $api->export()["headers"]["Accept"]);
+
+               $api = ContentType::apply($api, "+raw");
+               $this->assertEquals(
+                       $this->getVersionedContentType("+raw")->value,
+                       $api->export()["headers"]["Accept"]);
+
+               $api = ContentType::apply($api, ".html");
+               $this->assertEquals(
+                       $this->getVersionedContentType(".html")->value,
+                       $api->export()["headers"]["Accept"]);
+       }
+
+       /**
+        * @group testdox
+        * @dataProvider provideAPI
+        */
+       function testIsAbleToApplyBasicContentTypeToApi($api) {
+               $api = ContentType::apply($api, "text/plain");
+               $this->assertEquals("text/plain", $api->export()["headers"]["Accept"]);
+       }
+
+       /**
+        * @group testdox
+        */
+       function testUserCanOverrideApiVersion() {
+               $this->assertEquals(3, ContentType::version(2));
+               $this->assertEquals(2, ContentType::version(3));
+       }
+
+       /**
+        * @group testdox
+        */
+       function testAllowsToRegisterAndUnregisterContentTypeHandlers() {
+               $this->assertFalse(ContentType::registered("foobar"));
+               ContentType::register("foobar", function() {});
+               $this->assertTrue(ContentType::registered("foobar"));
+               ContentType::unregister("foobar");
+               $this->assertFalse(ContentType::registered("foobar"));
+       }
+
+       /**
+        * @group testdox
+        */
+       function testAcceptsContentTypeHeader() {
+               new ContentType(new Header("Content-Type"));
+               new ContentType(new Header("content-type"));
+       }
+
+       /**
+        * @group testdox
+        * @expectedException \seekat\Exception\InvalidArgumentException
+        */
+       function testDoesNotAcceptNonContentTypeHeader() {
+               new ContentType(new Header("ContentType"));
+       }
+
+       /**
+        * @group testdox
+        * @expectedException \seekat\Exception\UnexpectedValueException
+        */
+       function testThrowsOnUnknownContentType() {
+               $ct = new ContentType($this->getVersionedContentType("foo"));
+               $ct->parseBody((new Body)->append("foo"));
+       }
+
+       /**
+        * @group testdox
+        */
+       function testHandlesJson() {
+               $this->assertTrue(ContentType::registered("json"));
+               $ct = new ContentType(new Header("Content-Type", "application/json"));
+               $result = $ct->parseBody((new Body())->append("[1,2,3]"));
+               $this->assertEquals([1, 2, 3], $result);
+               return $ct;
+       }
+
+       /**
+        * @group testdox
+        * @depends testHandlesJson
+        * @expectedException \seekat\Exception\UnexpectedValueException
+        */
+       function testThrowsOnInvalidJson(ContentType $ct) {
+               $ct->parseBody((new Body)->append("yaml:\n - data"));
+       }
+
+       /**
+        * @group testdox
+        */
+       function testHandlesBase64() {
+               $this->assertTrue(ContentType::registered("base64"));
+               $ct = new ContentType($this->getVersionedContentType("base64"));
+               $result = $ct->parseBody((new Body())->append(base64_encode("This is a test")));
+               $this->assertEquals("This is a test", $result);
+               return $ct;
+       }
+
+       /**
+        * @group testdox
+        * @depends testHandlesBase64
+        * @expectedException \seekat\Exception\UnexpectedValueException
+        */
+       function testThrowsOnInvalidBase64(ContentType $ct) {
+               $ct->parseBody((new Body)->append("[1,2,3]"));
+       }
+
+       /**
+        * @group testdox
+        */
+       function testHandlesOctetStream() {
+               $this->assertTrue(ContentType::registered("application/octet-stream"));
+               $ct = new ContentType(new Header("Content-Type", "application/octet-stream"));
+               $result = $ct->parseBody((new Body)->append("This is a test"));
+               $this->assertInternalType("resource", $result);
+               rewind($result);
+               $this->assertEquals("This is a test", stream_get_contents($result));
+       }
+
+       /**
+        * @group testdox
+        */
+       function testHandlesData() {
+               $this->assertTrue(ContentType::registered("text/plain"));
+               $ct = new ContentType(new Header("Content-Type", "text/plain"));
+               $result = $ct->parseBody((new Body)->append("This is a test"));
+               $this->assertInternalType("string", $result);
+               $this->assertEquals("This is a test", $result);
+       }
+
+       /**
+        * @param string $type
+        * @return Header Content-Type header
+        */
+       private function getVersionedContentType($type) {
+               switch ($type{0}) {
+                       case ".":
+                       case "+":
+                       case "":
+                               break;
+                       default:
+                               $type = ".$type";
+               }
+               return new Header("Content-Type",
+                       sprintf("application/vnd.github.v%d%s", ContentType::version(), $type));
+       }
+}
diff --git a/tests/ErrorsTest.php b/tests/ErrorsTest.php
new file mode 100644 (file)
index 0000000..b99fe92
--- /dev/null
@@ -0,0 +1,29 @@
+<?php
+
+class ErrorsTest extends BaseTest
+{
+       use ConsumePromise;
+       use AssertCancelled;
+       use AssertFailure;
+
+       /**
+        * @dataProvider provideAPI
+        */
+       function testCancellation($api) {
+               $promise = $api->users->m6w6();
+
+               if (!$api->getFuture()->cancelPromise($promise)) {
+                       return;
+               }
+
+               $this->assertCancelled($promise);
+       }
+
+       /**
+        * @dataProvider provideAPI
+        */
+       function test404($api) {
+               $error = $this->assertFailure($api->generate->a404);
+               $this->assertEquals($error->getMessage(), "Not Found");
+       }
+}
diff --git a/tests/GeneratorTest.php b/tests/GeneratorTest.php
new file mode 100644 (file)
index 0000000..a4cb79a
--- /dev/null
@@ -0,0 +1,51 @@
+<?php
+
+use seekat\API\Links;
+
+class GeneratorTest extends BaseTest
+{
+       use ConsumePromise;
+
+       /**
+        * @group testdox
+        * @dataProvider provideApi
+        */
+       function testIteratesOverAGeneratorOfPromises($api) {
+               $api(function($api) use(&$gists_count, &$files_count) {
+                       $gists = yield $api->users->m6w6->gists();
+                       $gists_count = count($gists);
+                       foreach ($gists as $gist) {
+                               $files_count += count($gist->files);
+                       }
+               });
+               $this->assertGreaterThan(0, $gists_count);
+               $this->assertGreaterThanOrEqual($gists_count, $files_count);
+       }
+
+       /**
+        * @group testdox
+        * @dataProvider provideAPI
+        */
+       function testIteratesOverAGeneratorOfPromisesUsingLinks($api) {
+               $promise = $api(function($api) use(&$repos, &$first, &$next, &$last, &$prev) {
+                       $repos = yield $api->users->m6w6->repos(["per_page" => 1]);
+                       $last = yield Links\last($repos);
+                       $prev = yield Links\prev($last);
+                       $next = yield Links\next($prev);
+                       $first = yield Links\first($prev);
+                       return -123;
+               });
+
+               $this->consumePromise($promise, $errors, $results);
+               $this->assertEmpty($errors);
+               $this->assertEquals([-123], $results);
+
+               $first_data = $first->export()["data"];
+               $next_data = $next->export()["data"];
+               $last_data = $last->export()["data"];
+               $repos_data = $repos->export()["data"];
+
+               $this->assertEquals($repos_data, $first_data);
+               $this->assertEquals($last_data, $next_data);
+       }
+}
diff --git a/tests/api.php b/tests/api.php
deleted file mode 100644 (file)
index 2ba9d04..0000000
+++ /dev/null
@@ -1,214 +0,0 @@
-<?php
-
-use seekat\API;
-use React\Promise\PromiseInterface;
-use seekat\API\Links\ {
-       function first, function last, function next, function prev
-};
-
-describe("API", function() {
-
-       describe("Interface", function() {
-               it("should return API on property access", function() {
-                       expect($this->api->users)->to->be->instanceof(API::class);
-               });
-
-               it("should return a clone on property access", function() {
-                       expect($this->api->users)->to->not->equal($this->api);
-               });
-
-               it("should return PromiseInterface on function call", function() {
-                       expect($this->api->users->m6w6())->to->be->instanceof(PromiseInterface::class);
-               });
-       });
-
-       describe("Requests", function() {
-
-               it("should successfully request /users/m6w6", function() {
-                       $this->api->users->m6w6()->then(function($json) use(&$m6w6) {
-                               $m6w6 = $json;
-                       }, function($error) use(&$errors) {
-                               $errors[] = (string) $error;
-                       });
-
-                       $this->api->send();
-
-                       expect($errors)->to->be->empty;
-                       expect($m6w6->login)->to->loosely->equal("m6w6");
-               });
-
-               it("should export an array of data, url, type, links and headers", function() {
-                       $this->api->users->m6w6()->then(function($json) use(&$m6w6) {
-                               $m6w6 = $json;
-                       }, function($error) use(&$errors) {
-                               $errors[] = (string) $error;
-                       });
-
-                       $this->api->send();
-
-                       expect($errors)->to->be->empty();
-                       expect($m6w6->export())->to->be->an("array")->and->contain->keys([
-                               "data", "url", "type", "links", "headers"
-                       ]);
-               });
-
-               it("should return the count of followers when accessing /users/m6w6->followers", function() {
-                       $this->api->users->m6w6()->then(function($m6w6) use(&$followers) {
-                               $followers = $m6w6->followers;
-                       }, function($error) use(&$errors) {
-                               $errors[] = (string) $error;
-                       });
-
-                       $this->api->send();
-
-                       expect($errors)->to->be->empty();
-                       expect($followers->export()["data"])->to->be->an("integer");
-               });
-
-               it("should fetch followers_url when accessing /users/m6w6->followers_url", function() {
-                       $this->api->users->m6w6()->then(function($m6w6) use(&$followers, &$errors) {
-                               $m6w6->followers_url()->then(function($json) use(&$followers) {
-                                       $followers = $json;
-                               }, function($error) use(&$errors) {
-                                       $errors[] = (string) $error;
-                               });
-                       }, function($error) use(&$errors) {
-                               $errors[] = (string) $error;
-                       });
-
-                       $this->api->send();
-
-                       expect($errors)->to->be->empty;
-                       expect($followers->export()["data"])->to->be->an("array");
-                       expect(count($followers))->to->be->above(0);
-               });
-
-               it("should provide access to array indices", function() {
-                       $this->api->users->m6w6()->then(function($m6w6) use(&$followers, &$errors) {
-                               $m6w6->followers_url()->then(function($json) use(&$followers) {
-                                       $followers = $json;
-                               }, function($error) use(&$errors) {
-                                       $errors[] = (string) $error;
-                               });
-                       }, function($error) use(&$errors) {
-                               $errors[] = (string) $error;
-                       });
-
-                       $this->api->send();
-
-                       expect($errors)->to->be->empty;
-                       expect($followers->{0})->to->be->an("object");
-                       expect($followers->export()["data"][0])->to->be->an("object");
-               });
-
-               it("should handle a few requests in parallel", function() {
-                       $this->api->users->m6w6()->then(function($m6w6) use(&$count, &$errors) {
-                               foreach ($m6w6 as $key => $val) {
-                                       switch ($key) {
-                                               case "html_url":
-                                               case "avatar_url":
-                                                       break;
-                                               default:
-                                                       if (substr($key, -4) === "_url") {
-                                                               $val->get()->then(function() use(&$count) {
-                                                                       ++$count;
-                                                               }, function($error) use(&$errors) {
-                                                                       $errors[] = (string) $error;
-                                                               });
-                                                       }
-                                       }
-                               }
-                       }, function($error) use(&$errors) {
-                               $errors[] = (string) $error;
-                       });
-
-                       $this->api->send();
-
-                       expect($errors)->to->be->empty;
-                       expect($count)->to->be->above(2);
-               });
-
-       });
-
-       describe("Cache", function() {
-               it("should cache successive calls", function() {
-                       $cache = new API\Call\Cache\Service\Hollow();
-                       $this->api->users->m6w6(null, null, $cache)->then(function($json) use(&$m6w6) {
-                               $m6w6 = $json;
-                       }, function($error) use(&$errors) {
-                               $errors[] = (string) $error;
-                       });
-
-                       $this->api->send();
-
-                       $data = $cache->getStorage();
-                       $this->api->users->m6w6(null, null, $cache)->then(function($json) use(&$m6w6_) {
-                               $m6w6_ = $json;
-                       }, function($error) use(&$errors) {
-                               $errors[] = (string) $error;
-                       });
-
-                       $this->api->send();
-
-                       expect($errors)->to->be->empty;
-                       expect($m6w6->login)->to->loosely->equal("m6w6");
-                       expect($m6w6_->login)->to->loosely->equal("m6w6");
-                       expect($data)->to->equal($cache->getStorage());
-                       expect(count($cache->getStorage()))->to->equal(1);
-               });
-               xit("should refresh stale cache entries");
-       });
-
-       describe("Generators", function() {
-               it("should iterate over a generator of promises", function() {
-                       ($this->api)(function($api) use(&$gists_count, &$files_count) {
-                               $gists = yield $api->users->m6w6->gists();
-                               $gists_count = count($gists);
-                               foreach ($gists as $gist) {
-                                       $files_count += count($gist->files);
-                               }
-                       });
-                       expect($gists_count)->to->be->above(0);
-                       expect($files_count)->to->be->at->least($gists_count);
-               });
-               it("should iterate over a generator of promises with links", function() {
-                       ($this->api)(function($api) use(&$repos, &$first, &$next, &$last, &$prev) {
-                               $repos = yield $api->users->m6w6->repos(["per_page" => 1]);
-                               $last = yield last($repos);
-                               $prev = yield prev($last);
-                               $next = yield next($prev);
-                               $first = yield first($prev);
-                               return -123;
-                       })->done(function($value) use(&$result) {
-                               $result = $value;
-                       });
-
-                       expect($result)->to->equal(-123);
-                       expect($repos->export()["data"])->to->loosely->equal($first->export()["data"]);
-                       expect($last->export()["data"])->to->loosely->equal($next->export()["data"]);
-               });
-       });
-
-       describe("Errors", function() {
-               it("should handle cancellation gracefully", function() {
-                       $this->api->users->m6w6()->then(function($value) use(&$result) {
-                               $result = $value;
-                       }, function($error) use(&$message) {
-                               $message = \seekat\Exception\message($error);
-                       })->cancel();
-                       expect($result)->to->be->empty();
-                       expect($message)->to->equal("Cancelled");
-               });
-
-               it("should handle request errors gracefully", function() {
-                       $this->api->generate->a404()->then(function($value) use(&$result) {
-                               $result = $value;
-                       }, function($error) use(&$message) {
-                               $message = \seekat\Exception\message($error);
-                       });
-                       $this->api->send();
-                       expect($result)->to->be->empty();
-                       expect($message)->to->equal("Not Found");
-               });
-       });
-});
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644 (file)
index 0000000..3d502be
--- /dev/null
@@ -0,0 +1,196 @@
+<?php
+
+require_once __DIR__."/../vendor/autoload.php";
+
+function headers() : array {
+       static $headers;
+
+       if (!isset($headers)) {
+               if (($token = getenv("GITHUB_TOKEN"))) {
+                       $headers["Authorization"] = "token $token";
+               } elseif (function_exists("posix_isatty") && defined("STDIN") && posix_isatty(STDIN)) {
+                       fprintf(STDOUT, "GITHUB_TOKEN is not set in the environment, enter Y to continue without: ");
+                       fflush(STDOUT);
+                       if (strncasecmp(fgets(STDIN), "Y", 1)) {
+                               exit;
+                       }
+                       $headers = [];
+               } else {
+                       throw new Exception("GITHUB_TOKEN is not set in the environment");
+               }
+       }
+
+       return $headers;
+}
+
+function logger() : \Monolog\Logger {
+       static $logger;
+
+       if (!isset($logger)) {
+               $logger = new \Monolog\Logger(
+                       "test",
+                       [
+                               new \Monolog\Handler\FingersCrossedHandler(
+                                       new \Monolog\Handler\StreamHandler(STDERR),
+                                       \Monolog\Logger::EMERGENCY
+                               )
+                       ]
+               );
+       }
+
+       return $logger;
+}
+
+class BaseTest extends \PHPUnit\Framework\TestCase
+{
+       /**
+        * @return Generator
+        */
+       function provideAPI() {
+               $auth = \seekat\API\auth("token", getenv("GITHUB_TOKEN"));
+               $headers = headers();
+               $url = null;
+               $client = null;
+               $logger = logger();
+
+               return [
+                       "with ReactPHP" => [new \seekat\API(\seekat\API\Future\react(), $headers, $url, $client, $logger)],
+                       "with AmPHP"   => [new \seekat\API(\seekat\API\Future\amp(), $headers, $url, $client, $logger)],
+               ];
+       }
+}
+
+trait ConsumePromise
+{
+       function consumePromise(\AsyncInterop\Promise $p, &$errors, &$results) {
+               $p->when(function($error, $result) use(&$errors, &$results) {
+                       if ($error) $errors[] = $error;
+                       if ($result) $results[] = $result;
+               });
+       }
+}
+
+trait AssertSuccess
+{
+       function assertAllSuccess(array $apis, ...$args) {
+               foreach ($apis as $api) {
+                       $this->consumePromise($api->get(...$args), $errors, $results);
+               }
+               $api->send();
+               $this->assertEmpty($errors, "errors");
+               $this->assertNotEmpty($results, "results");
+               return $results;
+       }
+
+       function assertSuccess(seekat\API $api, ...$args) {
+               $this->consumePromise($api->get(...$args), $errors, $results);
+               $api->send();
+               $this->assertEmpty($errors, "errors");
+               $this->assertNotEmpty($results, "results");
+               return $results[0];
+       }
+}
+
+trait AssertCancelled
+{
+       function assertCancelled(\AsyncInterop\Promise $promise) {
+               $this->consumePromise($promise, $errors, $results);
+
+               $this->assertEmpty($results);
+               $this->assertStringMatchesFormat("%SCancelled%S", $errors[0]->getMessage());
+       }
+}
+
+trait AssertFailure
+{
+       function assertFailure(seekat\API $api, ...$args) {
+               $this->consumePromise($api->get(...$args), $errors, $results);
+               $api->send();
+               $this->assertNotEmpty($errors, "errors");
+               $this->assertEmpty($results, "results");
+               return $errors[0];
+       }
+}
+
+class CombinedTestdoxPrinter extends PHPUnit_TextUI_ResultPrinter
+{
+       function isTestClass(PHPUnit_Framework_TestSuite $suite) {
+               $suiteName = $suite->getName();
+               return false === strpos($suiteName, "::")
+                       && substr($suiteName, -4) === "Test";
+       }
+
+       function startTestSuite(PHPUnit_Framework_TestSuite $suite) {
+               if ($this->isTestClass($suite)) {
+                       $this->column = 0;
+               }
+
+               return parent::startTestSuite($suite);
+       }
+
+       function endTestSuite(PHPUnit_Framework_TestSuite $suite) {
+               /* print % progress */
+               if ($this->isTestClass($suite)) {
+                       if ($this->numTestsRun != $this->numTests) {
+                               $colWidth = $this->maxColumn - $this->column;
+                               $this->column = $this->maxColumn - 1;
+
+                               --$this->numTestsRun;
+                               $this->writeProgress(str_repeat(" ", $colWidth));
+                       } else {
+                               $this->writeNewLine();
+                       }
+               }
+
+               parent::endTestSuite($suite);
+       }
+}
+
+class TestdoxListener extends PHPUnit_Util_TestDox_ResultPrinter_Text
+{
+       private $groups;
+
+       function __construct() {
+               parent::__construct("php://stdout", ["testdox"]);
+               $this->groups = new ReflectionProperty("PHPUnit_Util_TestDox_ResultPrinter", "groups");
+               $this->groups->setAccessible(true);
+       }
+
+       function startTest(PHPUnit_Framework_Test $test) {
+               /* always show test class, even if no testdox test */
+               if ($test instanceof \PHPUnit\Framework\TestCase) {
+                       if ($test->getGroups() == ["default"]) {
+                               $this->groups->setValue($this, ["default"]);
+                       }
+               }
+
+               parent::startTest($test);
+               $this->groups->setValue($this, ["testdox"]);
+
+       }
+}
+
+class DebugLogListener extends PHPUnit\Framework\BaseTestListener
+{
+       private $printLog = false;
+
+       function endTest(PHPUnit_Framework_Test $test, $time) {
+               /* @var $handler \Monolog\Handler\FingersCrossedHandler */
+               $handler = logger()->getHandlers()[0];
+               if ($this->printLog) {
+                       $this->printLog = false;
+                       $handler->activate();
+               } else {
+                       $handler->clear();
+               }
+       }
+
+       function addError(PHPUnit_Framework_Test $test, Exception $e, $time) {
+               $this->printLog = true;
+       }
+
+       function addFailure(PHPUnit_Framework_Test $test, PHPUnit_Framework_AssertionFailedError $e, $time) {
+               $this->printLog = true;
+       }
+
+}