From 2451d97f1cb7b97e445b4dd839835b8673a4d0fc Mon Sep 17 00:00:00 2001 From: Michael Wallner Date: Fri, 13 Jan 2017 17:05:24 +0100 Subject: [PATCH] refactor --- .gitignore | 14 +- composer.json | 9 +- examples/cache.php | 45 ++ examples/cli.php | 24 ++ examples/ev.php | 90 ++++ examples/generator.php | 5 +- examples/gistlog.php | 6 +- examples/hooks.php | 3 +- lib/API.php | 456 ++++++++------------- lib/API/Call.php | 121 ++---- lib/API/Call/Cache.php | 74 ++++ lib/API/Call/Cache/Control.php | 118 ++++++ lib/API/Call/Cache/Service.php | 12 + lib/API/Call/Cache/Service/Hollow.php | 32 ++ lib/API/Call/Cache/Service/ItemPool.php | 43 ++ lib/API/Call/Cache/Service/Simple.php | 32 ++ lib/API/Call/Deferred.php | 157 +++++++ lib/API/Call/Result.php | 66 +++ lib/API/{Invoker.php => Consumer.php} | 25 +- lib/API/ContentType.php | 44 +- lib/API/Iterator.php | 2 +- lib/API/Links.php | 2 +- lib/Exception/InvalidArgumentException.php | 9 + lib/Exception/RequestException.php | 3 + lib/Exception/UnexpectedValueException.php | 9 + lib/functions.php | 75 ++++ peridot.php | 25 +- tests/api.php | 240 ++++++++--- 28 files changed, 1262 insertions(+), 479 deletions(-) create mode 100644 examples/cache.php create mode 100644 examples/cli.php create mode 100644 examples/ev.php create mode 100644 lib/API/Call/Cache.php create mode 100644 lib/API/Call/Cache/Control.php create mode 100644 lib/API/Call/Cache/Service.php create mode 100644 lib/API/Call/Cache/Service/Hollow.php create mode 100644 lib/API/Call/Cache/Service/ItemPool.php create mode 100644 lib/API/Call/Cache/Service/Simple.php create mode 100644 lib/API/Call/Deferred.php create mode 100644 lib/API/Call/Result.php rename lib/API/{Invoker.php => Consumer.php} (78%) create mode 100644 lib/Exception/InvalidArgumentException.php create mode 100644 lib/Exception/UnexpectedValueException.php create mode 100644 lib/functions.php diff --git a/.gitignore b/.gitignore index 8b13789..ad7b598 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,13 @@ - +*~ +/.buildpath +/.externalToolBuilders/ +/.idea/ +/.project +/.settings/ +/clover.xml +/code-coverage-report/ +/composer.lock +/nbproject/ +/perf* +/tmp/ +/vendor/ diff --git a/composer.json b/composer.json index ecc96f9..b9fe1ec 100644 --- a/composer.json +++ b/composer.json @@ -12,14 +12,19 @@ "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 index 0000000..87c0aef --- /dev/null +++ b/examples/cache.php @@ -0,0 +1,45 @@ +#!/usr/bin/env php +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 index 0000000..650ca85 --- /dev/null +++ b/examples/cli.php @@ -0,0 +1,24 @@ + "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 index 0000000..5cc5bae --- /dev/null +++ b/examples/ev.php @@ -0,0 +1,90 @@ +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(); + } +} diff --git a/examples/generator.php b/examples/generator.php index 9d962d4..acee8a5 100755 --- a/examples/generator.php +++ b/examples/generator.php @@ -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; diff --git a/examples/gistlog.php b/examples/gistlog.php index 455b30d..723cc0c 100755 --- a/examples/gistlog.php +++ b/examples/gistlog.php @@ -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; } }); diff --git a/examples/hooks.php b/examples/hooks.php index ad23692..37cb8e0 100755 --- a/examples/hooks.php +++ b/examples/hooks.php @@ -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) { diff --git a/lib/API.php b/lib/API.php index 1fd3da0..15b3830 100644 --- a/lib/API.php +++ b/lib/API.php @@ -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(); } } diff --git a/lib/API/Call.php b/lib/API/Call.php index 26a52ee..d84a24c 100644 --- a/lib/API/Call.php +++ b/lib/API/Call.php @@ -2,121 +2,48 @@ 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 index 0000000..b802172 --- /dev/null +++ b/lib/API/Call/Cache.php @@ -0,0 +1,74 @@ +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 index 0000000..99871d6 --- /dev/null +++ b/lib/API/Call/Cache/Control.php @@ -0,0 +1,118 @@ +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 index 0000000..0b80155 --- /dev/null +++ b/lib/API/Call/Cache/Service.php @@ -0,0 +1,12 @@ +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 index 0000000..63f12de --- /dev/null +++ b/lib/API/Call/Cache/Service/ItemPool.php @@ -0,0 +1,43 @@ +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 index 0000000..901d7aa --- /dev/null +++ b/lib/API/Call/Cache/Service/Simple.php @@ -0,0 +1,32 @@ +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 index 0000000..a01891f --- /dev/null +++ b/lib/API/Call/Deferred.php @@ -0,0 +1,157 @@ +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 index 0000000..acbc0a8 --- /dev/null +++ b/lib/API/Call/Result.php @@ -0,0 +1,66 @@ +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/Invoker.php b/lib/API/Consumer.php similarity index 78% rename from lib/API/Invoker.php rename to lib/API/Consumer.php index c36315e..5e72ef2 100644 --- a/lib/API/Invoker.php +++ b/lib/API/Consumer.php @@ -11,7 +11,7 @@ use React\Promise\{ function all }; -class Invoker extends Deferred +class Consumer extends Deferred { /** * The HTTP client @@ -43,24 +43,13 @@ class Invoker extends Deferred }); } - /** - * 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 + * @return ExtendedPromiseInterface */ - function iterate(Generator $gen) : Invoker { + function __invoke(Generator $gen) : ExtendedPromiseInterface { $this->cancelled = false; foreach ($gen as $promise) { @@ -73,15 +62,7 @@ class Invoker extends Deferred if (!$this->cancelled) { $this->resolve($this->result = $gen->getReturn()); } - return $this; - } - /** - * Get the generator's result - * - * @return ExtendedPromiseInterface - */ - function result() : ExtendedPromiseInterface { return $this->promise(); } diff --git a/lib/API/ContentType.php b/lib/API/ContentType.php index 929dc0c..4fccb9f 100644 --- a/lib/API/ContentType.php +++ b/lib/API/ContentType.php @@ -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/Iterator.php b/lib/API/Iterator.php index 6c4a9cc..b218747 100644 --- a/lib/API/Iterator.php +++ b/lib/API/Iterator.php @@ -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"]; } /** diff --git a/lib/API/Links.php b/lib/API/Links.php index 325f4b7..7022cda 100644 --- a/lib/API/Links.php +++ b/lib/API/Links.php @@ -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 index 0000000..104bdb8 --- /dev/null +++ b/lib/Exception/InvalidArgumentException.php @@ -0,0 +1,9 @@ +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); +} + diff --git a/peridot.php b/peridot.php index e4ce997..77d617f 100644 --- a/peridot.php +++ b/peridot.php @@ -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); diff --git a/tests/api.php b/tests/api.php index fc0e26a..2ba9d04 100644 --- a/tests/api.php +++ b/tests/api.php @@ -2,89 +2,213 @@ 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"); + }); }); }); -- 2.30.2