refactor
authorMichael Wallner <mike@php.net>
Fri, 13 Jan 2017 16:05:24 +0000 (17:05 +0100)
committerMichael Wallner <mike@php.net>
Fri, 13 Jan 2017 16:05:24 +0000 (17:05 +0100)
29 files changed:
.gitignore
composer.json
examples/cache.php [new file with mode: 0644]
examples/cli.php [new file with mode: 0644]
examples/ev.php [new file with mode: 0644]
examples/generator.php
examples/gistlog.php
examples/hooks.php
lib/API.php
lib/API/Call.php
lib/API/Call/Cache.php [new file with mode: 0644]
lib/API/Call/Cache/Control.php [new file with mode: 0644]
lib/API/Call/Cache/Service.php [new file with mode: 0644]
lib/API/Call/Cache/Service/Hollow.php [new file with mode: 0644]
lib/API/Call/Cache/Service/ItemPool.php [new file with mode: 0644]
lib/API/Call/Cache/Service/Simple.php [new file with mode: 0644]
lib/API/Call/Deferred.php [new file with mode: 0644]
lib/API/Call/Result.php [new file with mode: 0644]
lib/API/Consumer.php [new file with mode: 0644]
lib/API/ContentType.php
lib/API/Invoker.php [deleted file]
lib/API/Iterator.php
lib/API/Links.php
lib/Exception/InvalidArgumentException.php [new file with mode: 0644]
lib/Exception/RequestException.php
lib/Exception/UnexpectedValueException.php [new file with mode: 0644]
lib/functions.php [new file with mode: 0644]
peridot.php
tests/api.php

index 8b137891791fe96927ad78e64b0aad7bded08bdc..ad7b598f5d59052243286b4fa18d2a1805ae930c 100644 (file)
@@ -1 +1,13 @@
-
+*~
+/.buildpath
+/.externalToolBuilders/
+/.idea/
+/.project
+/.settings/
+/clover.xml
+/code-coverage-report/
+/composer.lock
+/nbproject/
+/perf*
+/tmp/
+/vendor/
index ecc96f92ce10a4123fe8b1adadfa0dfd9fc84c1c..b9fe1ec8ed1e4cd21b6431d5336275cc754daa42 100644 (file)
     "autoload": {
         "psr-4": {
             "seekat\\": "lib/"
-        }
+        },
+        "files": [
+            "lib/functions.php"
+        ]
     },
     "require": {
         "php": "^7.0",
         "ext-http": "^3.0",
         "react/promise": "^2.4",
         "seebz/uri-template": "dev-master",
-               "psr/log": "^1.0"
+               "psr/log": "^1.0",
+        "psr/cache": "^1.0",
+        "psr/simple-cache": "^1.0"
     },
     "require-dev": {
         "peridot-php/peridot": "^1.15",
diff --git a/examples/cache.php b/examples/cache.php
new file mode 100644 (file)
index 0000000..87c0aef
--- /dev/null
@@ -0,0 +1,45 @@
+#!/usr/bin/env php
+<?php
+
+require __DIR__."/../vendor/autoload.php";
+
+use Monolog\{
+       Handler\StreamHandler, Logger
+};
+
+$log = new Logger("seekat");
+$log->pushHandler(new StreamHandler(STDERR, Logger::INFO));
+
+$redis = new Redis;
+$redis->connect("localhost");
+$redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_PHP);
+$cache = new class($redis) implements \seekat\API\Call\Cache\Service {
+       private $redis;
+       function __construct(Redis $redis) {
+               $this->redis = $redis;
+       }
+       function clear() {
+               return $this->redis->flushDB();
+       }
+       function fetch(string $key, \http\Client\Response &$response = null): bool {
+               list($exists, $response) = $this->redis
+                       ->multi()
+                       ->exists($key)
+                       ->get($key)
+                       ->exec();
+               return $exists;
+       }
+       function store(string $key, \http\Client\Response $response): bool {
+               return $this->redis->set($key, $response);
+       }
+};
+
+$api = new seekat\API([
+       "Authorization" => "token ".getenv("GITHUB_TOKEN")
+], null, null, $log, $cache);
+
+$api(function($api) use($cache) {
+       yield $api->users->m6w6();
+       yield $api->users->m6w6();
+       $cache->clear();
+});
diff --git a/examples/cli.php b/examples/cli.php
new file mode 100644 (file)
index 0000000..650ca85
--- /dev/null
@@ -0,0 +1,24 @@
+<?php
+
+require_once __DIR__."/../vendor/autoload.php";
+
+$api = new seekat\API([
+       "Authorization" => "token ".getenv("GITHUB_TOKEN")
+]);
+array_shift($argv);
+
+($self = function($api) use(&$self) {
+       global $argv;
+
+       while (null !== ($arg = array_shift($argv))) {
+               if ("." === $arg) {
+                       $api->then($self);
+                       return;
+               }
+               $api = $api->$arg;
+       }
+
+       echo $api, "\n";
+})($api);
+
+$api->send();
diff --git a/examples/ev.php b/examples/ev.php
new file mode 100644 (file)
index 0000000..5cc5bae
--- /dev/null
@@ -0,0 +1,90 @@
+<?php
+
+
+class EvHandler implements http\Client\Curl\User
+{
+       private $client;
+       private $run;
+       private $ios = [];
+       private $timeout;
+
+       function __construct(http\Client $client) {
+               $this->client = $client;
+       }
+
+       function init($run) {
+               $this->run = $run;
+       }
+
+       function timer(int $timeout_ms) {
+               if (isset($this->timeout)) {
+                       $this->timeout->set($timeout_ms/1000, 0);
+                       $this->timeout->start();
+               } else {
+                       $this->timeout = new EvTimer($timeout_ms/1000, 0, function() {
+                               if (!call_user_func($this->run, $this->client)) {
+                                       if ($this->timeout) {
+                                               $this->timeout->stop();
+                                               $this->timeout = null;
+                                       }
+                               }
+                       });
+               }
+       }
+
+       function socket($socket, int $action) {
+               switch ($action) {
+                       case self::POLL_NONE:
+                               break;
+                       case self::POLL_REMOVE:
+                               echo "U";
+                               if (isset($this->ios[(int) $socket])) {
+                                       $this->ios[(int) $socket]->stop();
+                                       unset($this->ios[(int) $socket]);
+                               }
+                               break;
+                       default:
+                               $ev = 0;
+                               if ($action & self::POLL_IN) {
+                                       $ev |= Ev::READ;
+                               }
+                               if ($action & self::POLL_OUT) {
+                                       $ev |= Ev::WRITE;
+                               }
+                               if (isset($this->ios[(int) $socket])) {
+                                       $this->ios[(int) $socket]->set($socket, $ev);
+                               } else {
+                                       $this->ios[(int) $socket] = new EvIo($socket, $ev, function($watcher, $events) use($socket) {
+                                               $action = 0;
+                                               if ($events & Ev::READ) {
+                                                       $action |= self::POLL_IN;
+                                               }
+                                               if ($events & Ev::WRITE) {
+                                                       $action |= self::POLL_OUT;
+                                               }
+                                               if (!call_user_func($this->run, $this->client, $socket, $action)) {
+                                                       if ($this->timeout) {
+                                                               $this->timeout->stop();
+                                                               $this->timeout = null;
+                                                       }
+                                               }
+                                       });
+                               }
+                               break;
+               }
+       }
+
+       function once() {
+               echo "O";
+               Ev::run(EV::RUN_NOWAIT);
+       }
+       function wait(int $timeout_ms = null) {
+               echo "W";
+               Ev::run(EV::RUN_ONCE);
+       }
+       function send() {
+               echo "!";
+               Ev::verify();
+               Ev::run();
+       }
+}
index 9d962d4103076eaf99035926926b708639a1a6e7..acee8a5f48870e1d6a393464d1af60d782dcc73f 100755 (executable)
@@ -4,6 +4,7 @@
 require_once __DIR__."/../vendor/autoload.php";
 
 use seekat\API;
+use seekat\API\Links;
 
 $log = new Monolog\Logger("seekat");
 $log->pushHandler((new Monolog\Handler\StreamHandler(STDERR))->setLevel(Monolog\Logger::INFO));
@@ -19,10 +20,10 @@ $api(function($api) {
        $events = yield $api->repos->m6w6->{"ext-http"}->issues->events();
        while ($events) {
                /* pro-actively queue the next request */
-               $next = $events->next();
+               $next = Links\next($events);
 
                foreach ($events as $event) {
-                       if ($event->event == "labeled") {
+                       if ($event->event == "labeled" || $event->event == "unlabeled") {
                                continue;
                        }
                        ++$count;
index 455b30d180d6e3a1ca67d5f345a1a1f1b8e82e1f..723cc0c6f72d7fc1eb693c3e8545c42dc657c0ed 100755 (executable)
@@ -4,12 +4,14 @@
 require __DIR__."/../vendor/autoload.php";
 
 $api = new seekat\API([
-       "Authentication" => "token ".getenv("GUTHUB_TOKEN")
+       "Authorization" => "token ".getenv("GITHUB_TOKEN")
 ]);
 
 $api(function($api) {
        $gists = yield $api->users->m6w6->gists();
        while ($gists) {
+               $next = \seekat\API\Links\next($gists);
+
                foreach ($gists as $gist) {
                        foreach ($gist->files as $name => $file) {
                                if ($name == "blog.md") {
@@ -25,6 +27,6 @@ $api(function($api) {
                        }
                }
 
-               $gists = yield $gists->next();
+               $gists = yield $next;
        }
 });
index ad236925ffbcde60278c96dce3eed549e7b9e06f..37cb8e0ed2f895f9e58d3d4dbcf6b6f11ea4d0de 100755 (executable)
@@ -9,6 +9,7 @@ $cli = new http\Client("curl", "seekat");
 $cli->configure([
        "max_host_connections" => 10,
        "max_total_connections" => 50,
+       "use_eventloop" => false,
 ]);
 
 $log = new Monolog\Logger("seekat");
@@ -24,7 +25,7 @@ $api(function() use($api) {
                "affiliation" => "owner"
        ]);
        while ($repos) {
-               $next = $repos->next();
+               $next = next($repos);
 
                $batch = [];
                foreach ($repos as $repo) {
index 1fd3da011150fdfa0c3d20216b69da24a95553df..15b38302c23015c78170667099873d89bce4b44f 100644 (file)
@@ -3,81 +3,69 @@
 namespace seekat;
 
 use Countable;
-use Exception;
 use Generator;
 use http\{
-       Client,
-       Client\Request,
-       Client\Response,
-       Header,
-       Message\Body,
-       QueryString,
-       Url
+       Client, Client\Request, Message\Body, QueryString, Url
 };
-use InvalidArgumentException;
 use IteratorAggregate;
 use Psr\Log\{
-       LoggerInterface,
-       NullLogger
-};
-use seekat\{
-       API\Call,
-       API\ContentType,
-       API\Invoker,
-       API\Iterator,
-       API\Links,
-       Exception\RequestException
+       LoggerInterface, NullLogger
 };
 use React\Promise\{
-       ExtendedPromiseInterface,
-       function reject,
-       function resolve
+       ExtendedPromiseInterface, function resolve
+};
+use seekat\{
+       API\Call, API\Consumer, API\ContentType, API\Iterator, API\Links, Exception\InvalidArgumentException
 };
-use Throwable;
-use UnexpectedValueException;
 
 class API implements IteratorAggregate, Countable {
        /**
         * The current API endpoint URL
         * @var Url
         */
-       private $__url;
+       private $url;
 
        /**
         * Logger
         * @var LoggerInterface
         */
-       private $__log;
+       private $logger;
+
+       /**
+        * Cache
+        * @var Call\Cache\Service
+        */
+       private $cache;
 
        /**
         * The HTTP client
         * @var Client
         */
-       private $__client;
+       private $client;
 
        /**
         * Default headers to send out to the API endpoint
         * @var array
         */
-       private $__headers;
+       private $headers;
 
        /**
         * Current endpoint data's Content-Type
-        * @var Header
+        * @var API\ContentType
         */
-       private $__type;
+       private $type;
 
        /**
         * Current endpoint's data
         * @var array|object
         */
-       private $__data;
+       private $data;
 
        /**
         * Current endpoints links
         * @var Links
         */
-       private $__links;
+       private $links;
 
        /**
         * Create a new API endpoint root
@@ -87,11 +75,12 @@ class API implements IteratorAggregate, Countable {
         * @param Client $client The HTTP client to use for executing requests
         * @param LoggerInterface $log A logger
         */
-       function __construct(array $headers = null, Url $url = null, Client $client = null, LoggerInterface $log = null) {
-               $this->__log = $log ?? new NullLogger;
-               $this->__url = $url ?? new Url("https://api.github.com");
-               $this->__client = $client ?? new Client;
-               $this->__headers = (array) $headers + [
+       function __construct(array $headers = null, Url $url = null, Client $client = null, LoggerInterface $log = null, Call\Cache\Service $cache = null) {
+               $this->cache = $cache;
+               $this->logger = $log ?? new NullLogger;
+               $this->url = $url ?? new Url("https://api.github.com");
+               $this->client = $client ?? new Client;
+               $this->headers = (array) $headers + [
                        "Accept" => "application/vnd.github.v3+json"
                ];
        }
@@ -104,19 +93,19 @@ class API implements IteratorAggregate, Countable {
         */
        function __get($seg) : API {
                if (substr($seg, -4) === "_url") {
-                       $url = new Url(uri_template($this->__data->$seg));
+                       $url = new Url(uri_template($this->data->$seg));
                        $that = $this->withUrl($url);
-                       $seg = basename($that->__url->path);
+                       $seg = basename($that->url->path);
                } else {
                        $that = clone $this;
-                       $that->__url->path .= "/".urlencode($seg);
-                       $this->exists($seg, $that->__data);
+                       $that->url->path .= "/".urlencode($seg);
+                       $this->exists($seg, $that->data);
                }
 
-               $this->__log->debug(__FUNCTION__."($seg)", [
+               $this->logger->debug(__FUNCTION__."($seg)", [
                        "url" => [
-                               (string) $this->__url,
-                               (string) $that->__url
+                               (string) $this->url,
+                               (string) $that->url
                        ],
                ]);
 
@@ -147,45 +136,48 @@ class API implements IteratorAggregate, Countable {
                 * ->users->m6w6->gists(...)
                 */
                if (is_callable(current($args))) {
-                       return $this->$method->get()->then(current($args));
+                       return $this->api->get()->then(current($args));
                }
 
-               /* standard access */
-               if ($this->exists($method)) {
-                       return $this->$method->get(...$args);
+               return (new Call($this, $method))($args);
+       }
+
+       /**
+        * Run the send loop through a generator
+        *
+        * @param callable|Generator $cbg A \Generator or a factory of a \Generator yielding promises
+        * @return ExtendedPromiseInterface The promise of the generator's return value
+        * @throws InvalidArgumentException
+        */
+       function __invoke($cbg) : ExtendedPromiseInterface {
+               $this->logger->debug(__FUNCTION__);
+
+               $consumer = new Consumer($this->client);
+
+               invoke:
+               if ($cbg instanceof Generator) {
+                       return $consumer($cbg);
                }
 
-               /* fetch resource, unless already localized, and try for {$method}_url */
-               return $this->$method->get(...$args)->otherwise(function($error) use($method, $args) {
-                       if ($error instanceof Throwable) {
-                               $message = $error->getMessage();
-                       } else {
-                               $message = $error;
-                               $error = new Exception($error);
-                       }
-                       if ($this->exists($method."_url", $url)) {
-
-                               $this->__log->info(__FUNCTION__."($method): ". $message, [
-                                       "url" => (string) $this->__url
-                               ]);
-
-                               $url = new Url(uri_template($url, (array) current($args)));
-                               return $this->withUrl($url)->get(...$args);
-                       }
-
-                       $this->__log->error(__FUNCTION__."($method): ". $message, [
-                               "url" => (string) $this->__url
-                       ]);
+               if (is_callable($cbg)) {
+                       $cbg = $cbg($this);
+                       goto invoke;
+               }
 
-                       throw $error;
-               });
+               throw InvalidArgumentException(
+                       "Expected callable or Generator, got ".(
+                       is_object($cbg)
+                               ? "instance of ".get_class($cbg)
+                               : gettype($cbg).": ".var_export($cbg, true)
+                       )
+               );
        }
 
        /**
         * Clone handler ensuring the underlying url will be cloned, too
         */
        function __clone() {
-               $this->__url = clone $this->__url;
+               $this->url = clone $this->url;
        }
 
        /**
@@ -194,76 +186,97 @@ class API implements IteratorAggregate, Countable {
         * @return string
         */
        function __toString() : string {
-               if (is_scalar($this->__data)) {
-                       return (string) $this->__data;
+               if (is_scalar($this->data)) {
+                       return (string) $this->data;
                }
 
                /* FIXME */
-               return json_encode($this->__data);
+               return json_encode($this->data);
        }
 
        /**
-        * Import handler for the endpoint's underlying data
-        *
-        * \seekat\Call will call this when the request will have finished.
+        * Create an iterator over the endpoint's underlying data
         *
-        * @param Response $response
-        * @return API self
-        * @throws UnexpectedValueException
-        * @throws RequestException
-        * @throws \Exception
+        * @return Iterator
         */
-       function import(Response $response) : API {
-               $this->__log->info(__FUNCTION__.": ". $response->getInfo(), [
-                       "url" => (string) $this->__url
-               ]);
-
-               if ($response->getResponseCode() >= 400) {
-                       $e = new RequestException($response);
-
-                       $this->__log->critical(__FUNCTION__.": ".$e->getMessage(), [
-                               "url" => (string) $this->__url,
-                       ]);
+       function getIterator() : Iterator {
+               return new Iterator($this);
+       }
 
-                       throw $e;
-               }
+       /**
+        * Count the underlying data's entries
+        *
+        * @return int
+        */
+       function count() : int {
+               return count($this->data);
+       }
 
-               if (!($type = $response->getHeader("Content-Type", Header::class))) {
-                       $e = new RequestException($response);
-                       $this->__log->error(
-                               __FUNCTION__.": Empty Content-Type -> ".$e->getMessage(), [
-                               "url" => (string) $this->__url,
-                       ]);
-                       throw $e;
-               }
+       /**
+        * @return Url
+        */
+       function getUrl() : Url {
+               return $this->url;
+       }
 
-               try {
-                       $this->__type = new ContentType($type);
-                       $this->__data = $this->__type->parseBody($response->getBody());
+       /**
+        * @return LoggerInterface
+        */
+       function getLogger() : LoggerInterface {
+               return $this->logger;
+       }
 
-                       if (($link = $response->getHeader("Link", Header::class))) {
-                               $this->__links = new Links($link);
-                       }
-               } catch (\Exception $e) {
-                       $this->__log->error(__FUNCTION__.": ".$e->getMessage(), [
-                               "url" => (string) $this->__url
-                       ]);
+       /**
+        * @return Client
+        */
+       public function getClient(): Client {
+               return $this->client;
+       }
 
-                       throw $e;
-               }
+       /**
+        * @return array|object
+        */
+       function getData() {
+               return $this->data;
+       }
 
-               return $this;
+       /**
+        * Accessor to any hypermedia links
+        *
+        * @return null|Links
+        */
+       function getLinks() {
+               return $this->links;
        }
 
        /**
         * Export the endpoint's underlying data
         *
-        * @param
-        * @return mixed
+        * @return array ["url", "data", "type", "links", "headers"]
+        */
+       function export() : array {
+               $data = $this->data;
+               $url = clone $this->url;
+               $type = clone $this->type;
+               $links = $this->links ? clone $this->links : null;
+               $headers = $this->headers;
+               return compact("url", "data", "type", "links", "headers");
+       }
+
+       /**
+        * @param $export
+        * @return API
         */
-       function export(&$type = null) {
-               $type = clone $this->__type;
-               return $this->__data;
+       function with($export) : API {
+               $that = clone $this;
+               if (is_array($export) || ($export instanceof \ArrayAccess)) {
+                       isset($export["url"]) && $that->url = $export["url"];
+                       isset($export["data"]) && $that->data = $export["data"];
+                       isset($export["type"]) && $that->type = $export["type"];
+                       isset($export["links"]) && $that->links = $export["links"];
+                       isset($export["headers"]) && $that->headers = $export["headers"];
+               }
+               return $that;
        }
 
        /**
@@ -274,7 +287,7 @@ class API implements IteratorAggregate, Countable {
         */
        function withData($data) : API {
                $that = clone $this;
-               $that->__data = $data;
+               $that->data = $data;
                return $that;
        }
 
@@ -285,8 +298,10 @@ class API implements IteratorAggregate, Countable {
         * @return API clone
         */
        function withUrl(Url $url) : API {
-               $that = $this->withData(null);
-               $that->__url = $url;
+               $that = clone $this;
+               $that->url = $url;
+               $that->data = null;
+               #$that->links = null;
                return $that;
        }
 
@@ -300,9 +315,9 @@ class API implements IteratorAggregate, Countable {
        function withHeader(string $name, $value) : API {
                $that = clone $this;
                if (isset($value)) {
-                       $that->__headers[$name] = $value;
+                       $that->headers[$name] = $value;
                } else {
-                       unset($that->__headers[$name]);
+                       unset($that->headers[$name]);
                }
                return $that;
        }
@@ -312,46 +327,18 @@ class API implements IteratorAggregate, Countable {
         *
         * Changes the returned endpoint's accept header to "application/vnd.github.v3.{$type}"
         *
-        * @param string $type The expected return data type, e.g. "raw", "html", etc.
+        * @param string $type The expected return data type, e.g. "raw", "html", ..., or a complete content type
         * @param bool $keepdata Whether to keep already fetched data
         * @return API clone
         */
        function as(string $type, bool $keepdata = true) : API {
-               switch(substr($type, 0, 1)) {
-               case "+":
-               case ".":
-               case "":
-                       break;
-               default:
-                       $type = ".$type";
-                       break;
-               }
-               $vapi = ContentType::version();
-               $that = $this->withHeader("Accept", "application/vnd.github.v$vapi$type");
+               $that = ContentType::apply($this, $type);
                if (!$keepdata) {
-                       $that->__data = null;
+                       $that->data = null;
                }
                return $that;
        }
 
-       /**
-        * Create an iterator over the endpoint's underlying data
-        *
-        * @return Iterator
-        */
-       function getIterator() : Iterator {
-               return new Iterator($this);
-       }
-
-       /**
-        * Count the underlying data's entries
-        *
-        * @return int
-        */
-       function count() : int {
-               return count($this->__data);
-       }
-
        /**
         * Perform a GET request against the endpoint's underlying URL
         *
@@ -359,8 +346,8 @@ class API implements IteratorAggregate, Countable {
         * @param array $headers The request's additional HTTP headers
         * @return ExtendedPromiseInterface
         */
-       function get($args = null, array $headers = null) : ExtendedPromiseInterface {
-               return $this->__xfer("GET", $args, null, $headers);
+       function get($args = null, array $headers = null, $cache = null) : ExtendedPromiseInterface {
+               return $this->request("GET", $args, null, $headers, $cache);
        }
 
        /**
@@ -371,7 +358,7 @@ class API implements IteratorAggregate, Countable {
         * @return ExtendedPromiseInterface
         */
        function delete($args = null, array $headers = null) : ExtendedPromiseInterface {
-               return $this->__xfer("DELETE", $args, null, $headers);
+               return $this->request("DELETE", $args, null, $headers);
        }
 
        /**
@@ -383,7 +370,7 @@ class API implements IteratorAggregate, Countable {
         * @return ExtendedPromiseInterface
         */
        function post($body = null, $args = null, array $headers = null) : ExtendedPromiseInterface {
-               return $this->__xfer("POST", $args, $body, $headers);
+               return $this->request("POST", $args, $body, $headers);
        }
 
        /**
@@ -395,7 +382,7 @@ class API implements IteratorAggregate, Countable {
         * @return ExtendedPromiseInterface
         */
        function put($body = null, $args = null, array $headers = null) : ExtendedPromiseInterface {
-               return $this->__xfer("PUT", $args, $body, $headers);
+               return $this->request("PUT", $args, $body, $headers);
        }
 
        /**
@@ -407,64 +394,7 @@ class API implements IteratorAggregate, Countable {
         * @return ExtendedPromiseInterface
         */
        function patch($body = null, $args = null, array $headers = null) : ExtendedPromiseInterface {
-               return $this->__xfer("PATCH", $args, $body, $headers);
-       }
-
-       /**
-        * Accessor to any hypermedia links
-        *
-        * @return null|Links
-        */
-       function links() {
-               return $this->__links;
-       }
-
-       /**
-        * Perform a GET request against the link's "first" relation
-        *
-        * @return ExtendedPromiseInterface
-        */
-       function first() : ExtendedPromiseInterface {
-               if ($this->links() && ($first = $this->links()->getFirst())) {
-                       return $this->withUrl($first)->get();
-               }
-               return reject($this->links());
-       }
-
-       /**
-        * Perform a GET request against the link's "prev" relation
-        *
-        * @return ExtendedPromiseInterface
-        */
-       function prev() : ExtendedPromiseInterface {
-               if ($this->links() && ($prev = $this->links()->getPrev())) {
-                       return $this->withUrl($prev)->get();
-               }
-               return reject($this->links());
-       }
-
-       /**
-        * Perform a GET request against the link's "next" relation
-        *
-        * @return ExtendedPromiseInterface
-        */
-       function next() : ExtendedPromiseInterface {
-               if ($this->links() && ($next = $this->links()->getNext())) {
-                       return $this->withUrl($next)->get();
-               }
-               return reject($this->links());
-       }
-
-       /**
-        * Perform a GET request against the link's "last" relation
-        *
-        * @return ExtendedPromiseInterface
-        */
-       function last() : ExtendedPromiseInterface {
-               if ($this->links() && ($last = $this->links()->getLast())) {
-                       return $this->withUrl($last)->get();
-               }
-               return reject($this->links());
+               return $this->request("PATCH", $args, $body, $headers);
        }
 
        /**
@@ -473,45 +403,14 @@ class API implements IteratorAggregate, Countable {
         * @return API self
         */
        function send() : API {
-               $this->__log->debug(__FUNCTION__.": start loop");
-               while (count($this->__client)) {
-                       $this->__client->send();
+               $this->logger->debug(__FUNCTION__.": start loop");
+               while (count($this->client)) {
+                       $this->client->send();
                }
-               $this->__log->debug(__FUNCTION__.": end loop");
+               $this->logger->debug(__FUNCTION__.": end loop");
                return $this;
        }
 
-       /**
-        * Run the send loop through a generator
-        *
-        * @param callable|Generator $cbg A \Generator or a factory of a \Generator yielding promises
-        * @return ExtendedPromiseInterface The promise of the generator's return value
-        * @throws InvalidArgumentException
-        */
-       function __invoke($cbg) : ExtendedPromiseInterface {
-               $this->__log->debug(__FUNCTION__);
-
-               $invoker = new Invoker($this->__client);
-
-               if ($cbg instanceof Generator) {
-                       return $invoker->iterate($cbg)->promise();
-               }
-
-               if (is_callable($cbg)) {
-                       return $invoker->invoke(function() use($cbg) {
-                               return $cbg($this);
-                       })->promise();
-               }
-
-               throw InvalidArgumentException(
-                       "Expected callable or Generator, got ".(
-                               is_object($cbg)
-                                       ? "instance of ".get_class($cbg)
-                                       : gettype($cbg).": ".var_export($cbg, true)
-                       )
-               );
-       }
-
        /**
         * Check for a specific key in the endpoint's underlying data
         *
@@ -520,27 +419,27 @@ class API implements IteratorAggregate, Countable {
         * @return bool
         */
        function exists($seg, &$val = null) : bool {
-               if (is_array($this->__data) && array_key_exists($seg, $this->__data)) {
-                       $val = $this->__data[$seg];
+               if (is_array($this->data) && array_key_exists($seg, $this->data)) {
+                       $val = $this->data[$seg];
                        $exists = true;
-               } elseif (is_object($this->__data) && property_exists($this->__data, $seg)) {
-                       $val = $this->__data->$seg;
+               } elseif (is_object($this->data) && property_exists($this->data, $seg)) {
+                       $val = $this->data->$seg;
                        $exists = true;
                } else {
                        $val = null;
                        $exists = false;
                }
 
-               $this->__log->debug(__FUNCTION__."($seg) in ".(
-                       is_object($this->__data)
-                               ? get_class($this->__data)
-                               : gettype($this->__data)
+               $this->logger->debug(__FUNCTION__."($seg) in ".(
+                       is_object($this->data)
+                               ? get_class($this->data)
+                               : gettype($this->data)
                )." -> ".(
                        $exists
                                ? "true"
                                : "false"
                ), [
-                       "url" => (string) $this->__url,
+                       "url" => (string) $this->url,
                        "val" => $val,
                ]);
 
@@ -554,34 +453,37 @@ class API implements IteratorAggregate, Countable {
         * @param mixed $args The HTTP query string parameters
         * @param mixed $body Thee HTTP message's body
         * @param array $headers The request's additional HTTP headers
+        * @param Call\Cache\Service $cache
         * @return ExtendedPromiseInterface
         */
-       private function __xfer(string $method, $args = null, $body = null, array $headers = null) : ExtendedPromiseInterface {
-               if (isset($this->__data)) {
-                       $this->__log->debug(__FUNCTION__."($method) -> resolve", [
-                               "url" => (string) $this->__url,
-                               "args" => $args,
-                               "body" => $body,
+       private function request(string $method, $args = null, $body = null, array $headers = null, Call\Cache\Service $cache = null) : ExtendedPromiseInterface {
+               if (isset($this->data)) {
+                       $this->logger->debug("request -> resolve", [
+                               "method"  => $method,
+                               "url"     => (string)$this->url,
+                               "args"    => $args,
+                               "body"    => $body,
                                "headers" => $headers,
                        ]);
 
                        return resolve($this);
                }
 
-               $url = $this->__url->mod(["query" => new QueryString($args)]);
-               $request = new Request($method, $url, ((array) $headers) + $this->__headers,
+               $url = $this->url->mod(["query" => new QueryString($args)]);
+               $request = new Request($method, $url, ((array) $headers) + $this->headers,
                         $body = is_array($body) ? json_encode($body) : (
                                is_resource($body) ? new Body($body) : (
                                        is_scalar($body) ? (new Body)->append($body) :
                                                $body)));
 
-               $this->__log->info(__FUNCTION__."($method) -> request", [
-                       "url" => (string) $this->__url,
-                       "args" => $this->__url->query,
+               $this->logger->info("request -> deferred", [
+                       "method" => $method,
+                       "url" => (string) $this->url,
+                       "args" => $this->url->query,
                        "body" => $body,
                        "headers" => $headers,
                ]);
 
-               return (new Call($this, $this->__client, $request))->promise();
+               return (new Call\Deferred($this, $request, $cache ?: $this->cache))->promise();
        }
 }
index 26a52ee0995fbff2807d3e4ab850ca97f157eea9..d84a24c673f71e438d2a5bb60cb14fceb28124aa 100644 (file)
 
 namespace seekat\API;
 
-use Exception;
-use http\ {
-       Client,
-       Client\Request,
-       Client\Response
-};
-use React\Promise\Deferred;
+use http\Url;
+use React\Promise\ExtendedPromiseInterface;
 use seekat\API;
-use SplObserver;
-use SplSubject;
+use seekat\Exception;
 
-class Call extends Deferred implements SplObserver
+class Call
 {
        /**
-        * The endpoint
         * @var API
         */
        private $api;
 
        /**
-        * The HTTP client
-        * @var Client
+        * @var string
         */
-       private $client;
+       private $call;
 
-       /**
-        * The executed request
-        * @var Request
-        */
-       private $request;
-
-       /**
-        * The promised response
-        * @var Response
-        */
-       private $response;
-
-       /**
-        * Create a deferred promise for the response of $request
-        *
-        * @param API $api The endpoint of the request
-        * @param Client $client The HTTP client to send the request
-        * @param Request $request The request to execute
-        */
-       function __construct(API $api, Client $client, Request $request) {
+       function __construct(API $api, string $call) {
                $this->api = $api;
-               $this->client = $client;
-               $this->request = $request;
-
-               parent::__construct(function($resolve, $reject) {
-                       return $this->cancel($resolve, $reject);
-               });
-
-               $client->attach($this);
-               $client->enqueue($request, function(Response $response) {
-                       $this->response = $response;
-                       $this->complete(
-                               [$this, "resolve"],
-                               [$this, "reject"]
-                       );
-                       return true;
-               });
-               /* start off */
-               $client->once();
+               $this->call = $call;
        }
 
-       /**
-        * 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;
-               }
+       function __invoke(array $args) : ExtendedPromiseInterface {
+               $promise = $this->api->{$this->call}->get(...$args);
 
-               $this->notify((object) compact("client", "request", "progress"));
-       }
+               /* fetch resource, unless already localized, and try for {$method}_url */
+               if (!$this->api->exists($this->call)) {
+                       $promise = $promise->otherwise(function ($error) use($args) {
+                               if ($this->api->exists($this->call."_url", $url)) {
+                                       $url = new Url(uri_template($url, (array)current($args)));
+                                       return $this->api->withUrl($url)->get(...$args);
+                               }
 
-       /**
-        * Completion callback
-        * @param callable $resolve
-        * @param callable $reject
-        */
-       private function complete(callable $resolve, callable $reject) {
-               $this->client->detach($this);
+                               $message = Exception\message($error);
+                               $this->api->getLogger()->error("call($this->call): " . $message, [
+                                       "url" => (string) $this->api->getUrl()
+                               ]);
 
-               if ($this->response) {
-                       try {
-                               $resolve($this->api->import($this->response));
-                       } catch (Exception $e) {
-                               $reject($e);
-                       }
-               } else {
-                       $reject($this->client->getTransferInfo($this->request)->error);
+                               throw $error;
+                       });
                }
-       }
 
-       /**
-        * Cancellation callback
-        * @param callable $resolve
-        * @param callable $reject
-        */
-       private function cancel(callable $resolve, callable $reject) {
-               /* did we finish in the meantime? */
-               if ($this->response) {
-                       $this->complete($resolve, $reject);
-               } else {
-                       $this->client->detach($this);
-                       $this->client->dequeue($this->request);
-                       $reject("Cancelled");
-               }
+               return $promise;
        }
 }
diff --git a/lib/API/Call/Cache.php b/lib/API/Call/Cache.php
new file mode 100644 (file)
index 0000000..b802172
--- /dev/null
@@ -0,0 +1,74 @@
+<?php
+
+namespace seekat\API\Call;
+
+use http\Client\Request;
+use http\Client\Response;
+use seekat\API\Call\Cache\Control;
+use seekat\API\Call\Cache\Service;
+use seekat\API\Call\Cache\Service\Hollow;
+
+
+class Cache
+{
+       /**
+        * @var Service
+        */
+       private $cache;
+
+       /**
+        * @param Service $cache
+        */
+       public function __construct(Service $cache = null) {
+               $this->cache = $cache ?? new Hollow;
+       }
+
+       /**
+        * Save call data
+        * @param Request $request
+        * @param Response $response
+        * @return bool
+        */
+       public function save(Request $request, Response $response) : bool {
+               $ctl = new Control($request);
+               if (!$ctl->isValid()) {
+                       return false;
+               }
+
+               $time = time();
+               if ($time - 1 <= $response->getHeader("X-Cache-Time")) {
+                       return true;
+               }
+               $response->setHeader("X-Cache-Time", $time);
+
+               return $this->cache->store($ctl->getKey(), $response);
+       }
+
+       /**
+        * Attempt to load call data
+        * @param Request $request
+        * @param Response $response out param
+        * @return bool
+        */
+       public function load(Request $request, Response &$response = null) : bool {
+               $ctl = new Control($request);
+               if (!$ctl->isValid()) {
+                       return false;
+               }
+
+               if (!$this->cache->fetch($ctl->getKey(), $response)) {
+                       return false;
+               }
+               if ($ctl->isStale($response)) {
+                       if (($lmod = $response->getHeader("Last-Modified"))) {
+                               $request->setOptions(["lastmodified" => strtotime($lmod)]);
+                       }
+                       if (($etag = $response->getHeader("ETag"))) {
+                               $request->setOptions(["etag" => $etag]);
+                       }
+                       return false;
+               }
+               return true;
+       }
+
+}
diff --git a/lib/API/Call/Cache/Control.php b/lib/API/Call/Cache/Control.php
new file mode 100644 (file)
index 0000000..99871d6
--- /dev/null
@@ -0,0 +1,118 @@
+<?php
+
+namespace seekat\API\Call\Cache;
+
+use http\Client\Request;
+use http\Client\Response;
+use http\Header;
+use http\Params;
+use http\QueryString;
+use http\Url;
+
+final class Control
+{
+       /**
+        * @var string
+        */
+       private $key;
+
+       /**
+        * @param Request $request
+        */
+       function __construct(Request $request) {
+               $method = $request->getRequestMethod();
+               switch ($method) {
+                       case "HEAD":
+                       case "GET":
+                               $uid = $this->extractAuth($request);
+                               $url = $request->getRequestUrl();
+                               $this->key = "seekat call $uid $method $url";
+                               break;
+                       default:
+                               $this->key = "";
+                               break;
+               }
+       }
+
+       /**
+        * @return bool
+        */
+       public function isValid() : bool {
+               return strlen($this->key) > 0;
+       }
+
+       /**
+        * @return string
+        */
+       public function getKey() : string {
+               return $this->key;
+       }
+
+       /**
+        * @param Response $response
+        * @return bool
+        */
+       public function isStale(Response $response) : bool {
+               if (false === $response->getHeader("Cache-Control") &&
+                       false === $response->getHeader("Expires")) {
+                       return false;
+               }
+
+               $max_age = $this->extractMaxAge($response);
+               $cur_age = time() - $response->getHeader("X-Cache-Time");
+
+               return $max_age >= 0 && $max_age < $cur_age;
+       }
+
+       /**
+        * @param Response $response
+        * @return int
+        */
+       private function extractMaxAge(Response $response) : int {
+               /* @var Header $date
+                * @var Header $control
+                * @var Header $expires
+                */
+               $control = $response->getHeader("Cache-Control", Header::class);
+               if ($control) {
+                       /* @var Params $params */
+                       $params = $control->getParams();
+                       if (isset($params["max-age"])) {
+                               return (int) $params->params["max-age"]["value"];
+                       }
+               }
+
+               $expires = $response->getHeader("Expires", Header::class);
+               if ($expires) {
+                       if ($expires->match(0, Header::MATCH_FULL)) {
+                               return 0;
+                       }
+
+                       $date = $response->getHeader("Date", Header::class);
+                       if ($date) {
+                               return strtotime($expires->value) - strtotime($date->value);
+                       }
+               }
+
+               return -1;
+       }
+
+       /**
+        * @param Request $request
+        * @return string
+        */
+       private function extractAuth(Request $request) : string {
+               $auth = $request->getHeader("Authorization");
+               if ($auth) {
+                       return substr($auth, strpos($auth, " ") + 1);
+               }
+
+               $opts = $request->getOptions();
+               if (isset($opts["httpauth"])) {
+                       return base64_encode($opts["httpauth"]);
+               }
+
+               $query = new QueryString((new Url($request->getRequestUrl()))->query);
+               return $query->getString("access_token", $query->getString("client_id", ""));
+       }
+}
diff --git a/lib/API/Call/Cache/Service.php b/lib/API/Call/Cache/Service.php
new file mode 100644 (file)
index 0000000..0b80155
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace seekat\API\Call\Cache;
+
+use http\Client\Response;
+
+interface Service
+{
+       function fetch(string $key, Response &$response = null) : bool;
+       function store(string $key, Response $response) : bool;
+       function clear();
+}
diff --git a/lib/API/Call/Cache/Service/Hollow.php b/lib/API/Call/Cache/Service/Hollow.php
new file mode 100644 (file)
index 0000000..47175ff
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+
+namespace seekat\API\Call\Cache\Service;
+
+use http\Client\Response;
+use seekat\API\Call\Cache\Service;
+
+final class Hollow implements Service
+{
+       private $storage = [];
+
+       public function fetch(string $key, Response &$response = null) : bool {
+               if (isset($this->storage[$key])) {
+                       $response = $this->storage[$key];
+                       return true;
+               }
+               return false;
+       }
+
+       public function store(string $key, Response $response) : bool {
+               $this->storage[$key] = $response;
+               return true;
+       }
+
+       public function clear() {
+               $this->storage = [];
+       }
+
+       public function getStorage() : array {
+               return $this->storage;
+       }
+}
diff --git a/lib/API/Call/Cache/Service/ItemPool.php b/lib/API/Call/Cache/Service/ItemPool.php
new file mode 100644 (file)
index 0000000..63f12de
--- /dev/null
@@ -0,0 +1,43 @@
+<?php
+
+namespace seekat\API\Call\Cache\Service;
+
+use http\Client\Response;
+use Psr\Cache\CacheItemInterface;
+use Psr\Cache\CacheItemPoolInterface;
+use seekat\API\Call\Cache\Service;
+
+final class ItemPool implements Service
+{
+       /**
+        * @var CacheItemPoolInterface
+        */
+       private $cache;
+
+       /**
+        * @var CacheItemInterface
+        */
+       private $item;
+
+       public function __construct(CacheItemPoolInterface $cache) {
+               $this->cache = $cache;
+       }
+
+       public function fetch(string $key, Response &$response = null) : bool {
+               $this->item = $this->cache->getItem($key);
+               if ($this->item->isHit()) {
+                       $response = $this->item->get();
+                       return true;
+               }
+               return false;
+       }
+
+       public function store(string $key, Response $response) : bool {
+               $this->item->set($response);
+               return $this->cache->save($this->item);
+       }
+
+       public function clear() {
+               $this->cache->clear();
+       }
+}
diff --git a/lib/API/Call/Cache/Service/Simple.php b/lib/API/Call/Cache/Service/Simple.php
new file mode 100644 (file)
index 0000000..901d7aa
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+
+namespace seekat\API\Call\Cache\Service;
+
+use http\Client\Response;
+use Psr\SimpleCache\CacheInterface;
+use seekat\API\Call\Cache\Service;
+
+final class Simple implements Service
+{
+       /**
+        * @var CacheInterface
+        */
+       private $cache;
+
+       public function __construct(CacheInterface $cache) {
+               $this->cache = $cache;
+       }
+
+       public function fetch(string $key, Response &$response = null) : bool {
+               $response = $this->cache->get($key);
+               return !!$response;
+       }
+
+       public function store(string $key, Response $response) : bool {
+               return $this->cache->set($key, $response);
+       }
+
+       public function clear() {
+               $this->cache->clear();
+       }
+}
diff --git a/lib/API/Call/Deferred.php b/lib/API/Call/Deferred.php
new file mode 100644 (file)
index 0000000..a01891f
--- /dev/null
@@ -0,0 +1,157 @@
+<?php
+
+namespace seekat\API\Call;
+
+use Exception;
+use http\{
+       Client, Client\Request, Client\Response
+};
+use React\Promise\ExtendedPromiseInterface;
+use seekat\API;
+use SplObserver;
+use SplSubject;
+
+class Deferred extends \React\Promise\Deferred implements SplObserver
+{
+       /**
+        * The response importer
+        *
+        * @var Result
+        */
+       private $result;
+
+       /**
+        * The HTTP client
+        *
+        * @var Client
+        */
+       private $client;
+
+       /**
+        * Request cache
+        *
+        * @var callable
+        */
+       private $cache;
+
+       /**
+        * The executed request
+        *
+        * @var Request
+        */
+       private $request;
+
+       /**
+        * The promised response
+        *
+        * @var Response
+        */
+       private $response;
+
+       /**
+        * Create a deferred promise for the response of $request
+        *
+        * @param API $api The endpoint of the request
+        * @param Request $request The request to execute
+        * @param Cache\Service $cache
+        */
+       function __construct(API $api, Request $request, Cache\Service $cache = null) {
+               parent::__construct(function ($resolve, $reject) {
+                       return $this->cancel($resolve, $reject);
+               });
+
+               $this->request = $request;
+               $this->client = $api->getClient();
+               $this->result = new Result($api);
+               $this->cache = new Cache($cache);
+
+               if ($this->cache->load($this->request, $cached)) {
+                       $api->getLogger()->info("deferred -> cached", [
+                               "method" => $request->getRequestMethod(),
+                               "url" => $request->getRequestUrl(),
+                       ]);
+
+                       $this->response = $cached;
+                       $this->complete(
+                               [$this, "resolve"],
+                               [$this, "reject"]
+                       );
+               } else {
+                       $this->client->attach($this);
+                       $this->client->enqueue($this->request, function(Response $response) use($cached) {
+                               if ($response->getResponseCode() == 304) {
+                                       $this->response = $cached;
+                               } else {
+                                       $this->response = $response;
+                               }
+                               $this->complete(
+                                       [$this, "resolve"],
+                                       [$this, "reject"]
+                               );
+                               return true;
+                       });
+                       $api->getLogger()->info("deferred -> enqueued", [
+                               "method" => $request->getRequestMethod(),
+                               "url" => $request->getRequestUrl(),
+                       ]);
+                       /* start off */
+                       $this->client->once();
+               }
+       }
+
+       /**
+        * 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->notify((object) compact("client", "request", "progress"));
+       }
+
+       /**
+        * Completion callback
+        * @param callable $resolve
+        * @param callable $reject
+        */
+       private function complete(callable $resolve, callable $reject) {
+               $this->client->detach($this);
+
+               if ($this->response) {
+                       try {
+                               $api = ($this->result)($this->response);
+
+                               $this->cache->save($this->request, $this->response);
+
+                               $resolve($api);
+                       } catch (Exception $e) {
+                               $reject($e);
+                       }
+               } else {
+                       $reject($this->client->getTransferInfo($this->request)->error);
+               }
+       }
+
+       /**
+        * Cancellation callback
+        * @param callable $resolve
+        * @param callable $reject
+        */
+       private function cancel(callable $resolve, callable $reject) {
+               /* did we finish in the meantime? */
+               if ($this->response) {
+                       $this->complete($resolve, $reject);
+               } else {
+                       $this->client->detach($this);
+                       $this->client->dequeue($this->request);
+                       $reject("Cancelled");
+               }
+       }
+}
diff --git a/lib/API/Call/Result.php b/lib/API/Call/Result.php
new file mode 100644 (file)
index 0000000..acbc0a8
--- /dev/null
@@ -0,0 +1,66 @@
+<?php
+
+namespace seekat\API\Call;
+
+use http\Client\Response;
+use http\Header;
+use seekat\API;
+use seekat\Exception\RequestException;
+
+class Result
+{
+       private $api;
+
+       function __construct(API $api) {
+               $this->api = $api;
+       }
+
+       function __invoke(Response $response) : API {
+               $url = $this->api->getUrl();
+               $log = $this->api->getLogger();
+               $log->info(($response->getHeader("X-Cache-Time") ? "cached" : "enqueued")." -> response", [
+                       "url" => (string) $url,
+                       "info" => $response->getInfo(),
+               ]);
+
+               if ($response->getResponseCode() >= 400) {
+                       $e = new RequestException($response);
+
+                       $log->critical(__FUNCTION__.": ".$e->getMessage(), [
+                               "url" => (string) $url,
+                       ]);
+
+                       throw $e;
+               }
+
+               if (!($type = $response->getHeader("Content-Type", Header::class))) {
+                       $e = new RequestException($response);
+                       $log->error(
+                               __FUNCTION__.": Empty Content-Type -> ".$e->getMessage(), [
+                               "url" => (string) $url,
+                       ]);
+                       throw $e;
+               }
+
+               try {
+                       $type = new API\ContentType($type);
+                       $data = $type->parseBody($response->getBody());
+
+                       if (($link = $response->getHeader("Link", Header::class))) {
+                               $links = new API\Links($link);
+                       } else {
+                               $links = null;
+                       }
+
+                       $this->api = $this->api->with(compact("type", "data", "links"));
+               } catch (\Exception $e) {
+                       $log->error(__FUNCTION__.": ".$e->getMessage(), [
+                               "url" => (string) $url
+                       ]);
+
+                       throw $e;
+               }
+
+               return $this->api;
+       }
+}
diff --git a/lib/API/Consumer.php b/lib/API/Consumer.php
new file mode 100644 (file)
index 0000000..5e72ef2
--- /dev/null
@@ -0,0 +1,108 @@
+<?php
+
+namespace seekat\API;
+
+use Generator;
+use http\Client;
+use React\Promise\{
+       Deferred,
+       ExtendedPromiseInterface,
+       PromiseInterface,
+       function all
+};
+
+class Consumer extends Deferred
+{
+       /**
+        * The HTTP client
+        * @var Client
+        */
+       private $client;
+
+       /**
+        * The return value of the generator
+        * @var mixed
+        */
+       private $result;
+
+       /**
+        * Cancellation flag
+        * @var bool
+        */
+       private $cancelled = false;
+
+       /**
+        * Create a new generator invoker
+        * @param Client $client
+        */
+       function __construct(Client $client) {
+               $this->client = $client;
+
+               parent::__construct(function($resolve, $reject) {
+                       return $this->cancel($resolve, $reject);
+               });
+       }
+
+       /**
+        * Iterate over $gen, a \Generator yielding promises
+        *
+        * @param Generator $gen
+        * @return ExtendedPromiseInterface
+        */
+       function __invoke(Generator $gen) : ExtendedPromiseInterface {
+               $this->cancelled = false;
+
+               foreach ($gen as $promise) {
+                       if ($this->cancelled) {
+                               break;
+                       }
+                       $this->give($promise, $gen);
+               }
+
+               if (!$this->cancelled) {
+                       $this->resolve($this->result = $gen->getReturn());
+               }
+
+               return $this->promise();
+       }
+
+       /**
+        * Promise handler
+        *
+        * @param array|PromiseInterface $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);
+                               }
+                       });
+               } else {
+                       all($promise)->then(function($results) use($gen) {
+                               if (($promise = $gen->send($results))) {
+                                       $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");
+               }
+       }
+}
index 929dc0c2c80907eea6ab59aba6dc489b98e1647b..4fccb9f4367362f3cd625a4942a78cfe5c40c077 100644 (file)
@@ -2,14 +2,13 @@
 
 namespace seekat\API;
 
+use seekat\{
+       API, Exception\InvalidArgumentException, Exception\UnexpectedValueException
+};
 use http\{
-       Header,
-       Message\Body
+       Header, Message\Body
 };
 
-use InvalidArgumentException;
-use UnexpectedValueException;
-
 class ContentType
 {
        /**
@@ -31,6 +30,7 @@ class ContentType
                "diff"          => "self::fromData",
                "patch"         => "self::fromData",
                "text/plain"=> "self::fromData",
+               "application/octet-stream" => "self::fromStream",
        ];
 
        /**
@@ -78,6 +78,31 @@ class ContentType
                return $api;
        }
 
+       /**
+        * @param API $api
+        * @param string $type
+        * @return API
+        */
+       static function apply(API $api, string $type) : API {
+               $part = "[^()<>@,;:\\\"\/\[\]?.=[:space:][:cntrl:]]+";
+               if (preg_match("/^$part\/$part\$/", $type)) {
+                       $that = $api->withHeader("Accept", $type);
+               } else {
+                       switch (substr($type, 0, 1)) {
+                               case "+":
+                               case ".":
+                               case "":
+                                       break;
+                               default:
+                                       $type = ".$type";
+                                       break;
+                       }
+                       $vapi = static::version();
+                       $that = $api->withHeader("Accept", "application/vnd.github.v$vapi$type");
+               }
+               return $that;
+       }
+
        /**
         * @param Body $json
         * @return mixed
@@ -104,6 +129,15 @@ class ContentType
                return $decoded;
        }
 
+
+       /**
+        * @param Body $stream
+        * @return resource stream
+        */
+       private static function fromStream(Body $stream) {
+               return $stream->getResource();
+       }
+
        /**
         * @param Body $data
         * @return string
diff --git a/lib/API/Invoker.php b/lib/API/Invoker.php
deleted file mode 100644 (file)
index c36315e..0000000
+++ /dev/null
@@ -1,127 +0,0 @@
-<?php
-
-namespace seekat\API;
-
-use Generator;
-use http\Client;
-use React\Promise\{
-       Deferred,
-       ExtendedPromiseInterface,
-       PromiseInterface,
-       function all
-};
-
-class Invoker extends Deferred
-{
-       /**
-        * The HTTP client
-        * @var Client
-        */
-       private $client;
-
-       /**
-        * The return value of the generator
-        * @var mixed
-        */
-       private $result;
-
-       /**
-        * Cancellation flag
-        * @var bool
-        */
-       private $cancelled = false;
-
-       /**
-        * Create a new generator invoker
-        * @param Client $client
-        */
-       function __construct(Client $client) {
-               $this->client = $client;
-
-               parent::__construct(function($resolve, $reject) {
-                       return $this->cancel($resolve, $reject);
-               });
-       }
-
-       /**
-        * Invoke $generator to create a \Generator which yields promises
-        *
-        * @param callable $generator as function():\Generator, creating a generator yielding promises
-        * @return Invoker
-        */
-       function invoke(callable $generator) : Invoker {
-               $this->iterate($generator());
-               return $this;
-       }
-
-       /**
-        * Iterate over $gen, a \Generator yielding promises
-        *
-        * @param Generator $gen
-        * @return Invoker
-        */
-       function iterate(Generator $gen) : Invoker {
-               $this->cancelled = false;
-
-               foreach ($gen as $promise) {
-                       if ($this->cancelled) {
-                               break;
-                       }
-                       $this->give($promise, $gen);
-               }
-
-               if (!$this->cancelled) {
-                       $this->resolve($this->result = $gen->getReturn());
-               }
-               return $this;
-       }
-
-       /**
-        * Get the generator's result
-        *
-        * @return ExtendedPromiseInterface
-        */
-       function result() : ExtendedPromiseInterface {
-               return $this->promise();
-       }
-
-       /**
-        * Promise handler
-        *
-        * @param array|PromiseInterface $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);
-                               }
-                       });
-               } else {
-                       all($promise)->then(function($results) use($gen) {
-                               if (($promise = $gen->send($results))) {
-                                       $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");
-               }
-       }
-}
index 6c4a9cc59be6a0c9bd6cfbd2c11bd249929cd0d0..b2187478e3e1eb8835d53fea62d58e525ff9d879 100644 (file)
@@ -40,7 +40,7 @@ class Iterator implements BaseIterator
         */
        function __construct(API $api) {
                $this->api = $api;
-               $this->data = (array) $api->export();
+               $this->data = (array) $api->export()["data"];
        }
 
        /**
index 325f4b7289875e1a415f911489d4b4dafd254da9..7022cda2c9ea8c85d27ad6a58cfd58b99bbea2dc 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace seekat\API;
 
+use seekat\Exception\UnexpectedValueException;
 use http\ {
        Header,
        Params,
@@ -9,7 +10,6 @@ use http\ {
        Url
 };
 use Serializable;
-use UnexpectedValueException;
 
 class Links implements Serializable
 {
diff --git a/lib/Exception/InvalidArgumentException.php b/lib/Exception/InvalidArgumentException.php
new file mode 100644 (file)
index 0000000..104bdb8
--- /dev/null
@@ -0,0 +1,9 @@
+<?php
+
+namespace seekat\Exception;
+
+use seekat\Exception;
+
+class InvalidArgumentException extends \InvalidArgumentException implements Exception
+{
+}
index a9da6d6c731bc8ed60d8ab011e04d66feb472a19..7f91535b7d59f3c8cf69af8d26fac31ac4954077 100644 (file)
@@ -9,6 +9,9 @@ use http\ {
 };
 use seekat\Exception;
 
+/**
+ * @code-coverage-ignore
+ */
 class RequestException extends BaseException implements Exception
 {
        /**
diff --git a/lib/Exception/UnexpectedValueException.php b/lib/Exception/UnexpectedValueException.php
new file mode 100644 (file)
index 0000000..314c9c9
--- /dev/null
@@ -0,0 +1,9 @@
+<?php
+
+namespace seekat\Exception;
+
+use seekat\Exception;
+
+class UnexpectedValueException extends \UnexpectedValueException implements Exception
+{
+}
diff --git a/lib/functions.php b/lib/functions.php
new file mode 100644 (file)
index 0000000..9e82d72
--- /dev/null
@@ -0,0 +1,75 @@
+<?php
+
+namespace seekat\Exception;
+
+function message(&$error) : string {
+       if ($error instanceof \Throwable) {
+               $message = $error->getMessage();
+       } else {
+               $message = $error;
+               $error = new \Exception($error);
+       }
+       return $message;
+}
+
+namespace seekat\API\Links;
+
+use React\Promise\{
+       ExtendedPromiseInterface,
+       function reject
+};
+use seekat\API;
+use seekat\API\Call\Cache;
+
+/**
+ * Perform a GET request against the link's "first" relation
+ *
+ * @return ExtendedPromiseInterface
+ */
+function first(API $api, Cache\Service $cache = null) : ExtendedPromiseInterface {
+       $links = $api->getLinks();
+       if ($links && ($first = $links->getFirst())) {
+               return $api->withUrl($first)->get(null, null, $cache);
+       }
+       return reject($links);
+}
+
+/**
+ * Perform a GET request against the link's "prev" relation
+ *
+ * @return ExtendedPromiseInterface
+ */
+function prev(API $api, Cache\Service $cache = null) : ExtendedPromiseInterface {
+       $links = $api->getLinks();
+       if ($links && ($prev = $links->getPrev())) {
+               return $api->withUrl($prev)->get(null, null, $cache);
+       }
+       return reject($links);
+}
+
+/**
+ * Perform a GET request against the link's "next" relation
+ *
+ * @return ExtendedPromiseInterface
+ */
+function next(API $api, Cache\Service $cache = null) : ExtendedPromiseInterface {
+       $links = $api->getLinks();
+       if ($links && ($next = $links->getNext())) {
+               return $api->withUrl($next)->get(null, null, $cache);
+       }
+       return reject($links);
+}
+
+/**
+ * Perform a GET request against the link's "last" relation
+ *
+ * @return ExtendedPromiseInterface
+ */
+function last(API $api, Cache\Service $cache = null) : ExtendedPromiseInterface {
+       $links = $api->getLinks();
+       if ($links && ($last = $links->getLast())) {
+               return $api->withUrl($last)->get(null, null, $cache);
+       }
+       return reject($links);
+}
+
index e4ce997c19d1fe9209e4c3772e1ef94780595a89..77d617fb9ff64e7418ef5b27e8c873a580b8012f 100644 (file)
@@ -8,14 +8,14 @@ use Peridot\Console\Application;
 use Peridot\Console\Environment;
 use Peridot\Core\Suite;
 use Peridot\Core\Test;
-use seekat\API;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Output\OutputInterface;
+use Peridot\Reporter\AbstractBaseReporter;
+use Peridot\Reporter\AnonymousReporter;
 use Peridot\Reporter\CodeCoverage\AbstractCodeCoverageReporter;
 use Peridot\Reporter\CodeCoverageReporters;
 use Peridot\Reporter\ReporterFactory;
-use Peridot\Reporter\AnonymousReporter;
-use Peridot\Reporter\AbstractBaseReporter;
+use seekat\API;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
 
 return function(EventEmitter $emitter) {
        (new CodeCoverageReporters($emitter))->register();
@@ -28,17 +28,22 @@ return function(EventEmitter $emitter) {
 
                                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) {
-                                               fprintf(STDERR, "Creating reporters\n");
-                                               $this->reporters[] = $factory->create("spec");
-                                               $this->reporters[] = $factory->create("text-code-coverage");
+                                               $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 __2call($method, array $args) {
+
+                                       function X__call($method, array $args) {
                                                fprintf(STDERR, "Calling %s\n", $method);
                                                foreach ($this->reporters as $reporter) {
                                                        $output = $reporter->$method(...$args);
@@ -83,7 +88,7 @@ return function(EventEmitter $emitter) {
        $emitter->on("suite.start", function(Suite $suite) use($log) {
                $headers = [];
                if (($token = getenv("GITHUB_TOKEN"))) {
-                       $headers["Authentication"] = "token $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);
index fc0e26aab99271e12056f72d2a1057972ffde391..2ba9d045f788acf98ce465885d0376727d2baeeb 100644 (file)
 
 use seekat\API;
 use React\Promise\PromiseInterface;
+use seekat\API\Links\ {
+       function first, function last, function next, function prev
+};
 
 describe("API", function() {
 
-       it("should return API on property access", function() {
-               expect($this->api->users)->to->be->instanceof(API::class);
-       });
+       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 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);
+               it("should return PromiseInterface on function call", function() {
+                       expect($this->api->users->m6w6())->to->be->instanceof(PromiseInterface::class);
+               });
        });
 
-       it("should successfully request /users/m6w6", function() {
-               $this->api->users->m6w6()->then(function($json) use(&$m6w6) {
-                       $m6w6 = $json;
-               })->otherwise(function($error) use(&$errors) {
-                       $errors[] = (string) $error;
+       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");
                });
 
-               $this->api->send();
+               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;
+                       });
 
-               expect($errors)->to->be->empty;
-               expect($m6w6->login)->to->loosely->equal("m6w6");
-       });
+                       $this->api->send();
 
-       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;
-               })->otherwise(function($error) use(&$errors) {
-                       $errors[] = (string) $error;
+                       expect($errors)->to->be->empty();
+                       expect($m6w6->export())->to->be->an("array")->and->contain->keys([
+                               "data", "url", "type", "links", "headers"
+                       ]);
                });
 
-               $this->api->send();
+               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;
+                       });
 
-               expect($errors)->to->be->empty();
-               expect($followers->export())->to->be->an("integer");
-       });
+                       $this->api->send();
 
-       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;
-                       })->otherwise(function($error) use(&$errors) {
+                       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;
                        });
-               })->otherwise(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);
                });
 
-               $this->api->send();
+               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);
+               });
 
-               expect($errors)->to->be->empty;
-               expect($followers->export())->to->be->an("array");
-               expect(count($followers))->to->be->above(0);
        });
 
-       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;
-                                                       })->otherwise(function($error) use(&$errors) {
-                                                               $errors[] = (string) $error;
-                                                       });
-                                               }
+       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);
                                }
-                       }
-               })->otherwise(function($error) use(&$errors) {
-                       $errors[] = (string) $error;
+                       });
+                       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"]);
                });
+       });
 
-               $this->api->send();
+       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");
+               });
 
-               expect($errors)->to->be->empty;
-               expect($count)->to->be->above(2);
+               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");
+               });
        });
 });