X-Git-Url: https://git.m6w6.name/?p=m6w6%2Fseekat;a=blobdiff_plain;f=lib%2FAPI.php;h=15b38302c23015c78170667099873d89bce4b44f;hp=c3877ec28061185405fcdc3a383f7fd291d79a0f;hb=2451d97f1cb7b97e445b4dd839835b8673a4d0fc;hpb=cb67a45f0e94c91583d7563f7a21d89845332c13 diff --git a/lib/API.php b/lib/API.php index c3877ec..15b3830 100644 --- a/lib/API.php +++ b/lib/API.php @@ -2,128 +2,133 @@ namespace seekat; -use seekat\API\ContentType; -use seekat\Exception\RequestException; +use Countable; +use Generator; +use http\{ + Client, Client\Request, Message\Body, QueryString, Url +}; +use IteratorAggregate; +use Psr\Log\{ + LoggerInterface, NullLogger +}; +use React\Promise\{ + ExtendedPromiseInterface, function resolve +}; +use seekat\{ + API\Call, API\Consumer, API\ContentType, API\Iterator, API\Links, Exception\InvalidArgumentException +}; -use http\Url; -use http\Header; -use http\Client; -use http\Client\Request; -use http\Client\Response; -use http\Message\Body; -use http\QueryString; - -use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; - -use React\Promise\ExtendedPromiseInterface; -use function React\Promise\resolve; -use function React\Promise\reject; - -class API implements \IteratorAggregate, \Countable { +class API implements IteratorAggregate, Countable { /** * The current API endpoint URL - * @var \http\Url + * @var Url */ - private $__url; - + private $url; + /** * Logger - * @var \Psr\Log\LoggerInterface + * @var LoggerInterface */ - private $__log; - + private $logger; + + /** + * Cache + * @var Call\Cache\Service + */ + private $cache; + /** * The HTTP client - * @var \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 \http\Header + * @var API\ContentType */ - private $__type; - + private $type; + /** * Current endpoint's data * @var array|object */ - private $__data; - + private $data; + /** * Current endpoints links - * @var seekat\API\Links + * @var Links */ - private $__links; - + private $links; + /** * Create a new API endpoint root * - * @var array $headers Standard request headers, defaults to ["Accept" => "application/vnd.github.v3+json"] - * @var \http\Url The API's endpoint, defaults to https://api.github.com - * @var \http\Client $client The HTTP client to use for executing requests - * @var \Psr\Log\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 + [ - "Accept" => "application/vnd.github.v3+json" + * @param array $headers Standard request headers, defaults to ["Accept" => "application/vnd.github.v3+json"] + * @param Url $url The API's endpoint, defaults to https://api.github.com + * @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, 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" ]; } - + /** * Ascend one level deep into the API endpoint * - * @var string|int $seg The "path" element to ascend into - * @return \seekat\API Endpoint clone referring to {$parent}/{$seg} + * @param string|int $seg The "path" element to ascend into + * @return API Endpoint clone referring to {$parent}/{$seg} */ 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 ], ]); - + return $that; } - + /** * Call handler that actually queues a data fetch and returns a promise * - * @var string $method The API's "path" element to ascend into - * @var array $args Array of arguments forwarded to \seekat\API::get() - * @return \React\Promise\ExtendedPromiseInterface + * @param string $method The API's "path" element to ascend into + * @param array $args Array of arguments forwarded to \seekat\API::get() + * @return ExtendedPromiseInterface */ function __call(string $method, array $args) : ExtendedPromiseInterface { /* We cannot implement an explicit then() method, * because the Promise implementation might think * we're actually implementing Thenable, - * which might cause an infite loop. + * which might cause an infinite loop. */ if ($method === "then") { return $this->get()->then(...$args); } - + /* * very short-hand version: * ->users->m6w6->gists->get()->then(...) @@ -131,427 +136,354 @@ 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 ($this->exists($method."_url", $url)) { - - $this->__log->info(__FUNCTION__."($method): ". $error->getMessage(), [ - "url" => (string) $this->__url - ]); - - $url = new Url(uri_template($url, (array) current($args))); - return $this->withUrl($url)->get(...$args); - } - - $this->__log->error(__FUNCTION__."($method): ". $error->getMessage(), [ - "url" => (string) $this->__url - ]); - - throw $error; - }); + + if (is_callable($cbg)) { + $cbg = $cbg($this); + goto invoke; + } + + 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; } - + /** * The string handler for the endpoint's data * * @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 + * Create an iterator over the endpoint's underlying data * - * \seekat\Deferred will call this when the request will have finished. + * @return Iterator + */ + function getIterator() : Iterator { + return new Iterator($this); + } + + /** + * Count the underlying data's entries * - * @var \http\Client\Response $response - * @return \seekat\API self - */ - function import(Response $response) : API { - //addcslashes($response, "\0..\40\42\47\134\140\177..\377") - - $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, - ]); - - throw $e; - } - - 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; - } - - try { - $this->__type = new ContentType($type); - $this->__data = $this->__type->parseBody($response->getBody()); - - if (($link = $response->getHeader("Link", Header::class))) { - $this->__links = new API\Links($link); - } - } catch (\Exception $e) { - $this->__log->error(__FUNCTION__.": ".$e->getMessage(), [ - "url" => (string) $this->__url - ]); - - throw $e; - } - - return $this; + * @return int + */ + function count() : int { + return count($this->data); + } + + /** + * @return Url + */ + function getUrl() : Url { + return $this->url; + } + + /** + * @return LoggerInterface + */ + function getLogger() : LoggerInterface { + return $this->logger; + } + + /** + * @return Client + */ + public function getClient(): Client { + return $this->client; } - + + /** + * @return array|object + */ + function getData() { + return $this->data; + } + + /** + * Accessor to any hypermedia links + * + * @return null|Links + */ + function getLinks() { + return $this->links; + } + /** * Export the endpoint's underlying data * - * @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; } - + /** * Create a copy of the endpoint with specific data * - * @var mixed $data - * @return \seekat\API clone + * @param mixed $data + * @return API clone */ function withData($data) : API { $that = clone $this; - $that->__data = $data; + $that->data = $data; return $that; } - + /** * Create a copy of the endpoint with a specific Url, but with data reset * - * @var \http\Url $url - * @return \seekat\API clone + * @param Url $url + * @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; } - + /** * Create a copy of the endpoint with a specific header added/replaced * - * @var string $name - * @var mixed $value - * @return \seekat\API clone + * @param string $name + * @param mixed $value + * @return API clone */ 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; } - + /** * Create a copy of the endpoint with a customized accept header * - * Changes the returned endpoint's accept header to - * "application/vnd.github.v3.{$type}" + * Changes the returned endpoint's accept header to "application/vnd.github.v3.{$type}" * - * @var string $type The expected return data type, e.g. "raw", "html", etc. - * @var bool $keepdata Whether to keep already fetched data - * @return \seekat\API clone + * @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 \seekat\API\Iterator - */ - function getIterator() : API\Iterator { - return new API\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 * - * @var mixed $args The HTTP query string parameters - * @var array $headers The request's additional HTTP headers - * @return \React\Promise\ExtendedPromiseInterface + * @param mixed $args The HTTP query string parameters + * @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); } - + /** * Perform a DELETE request against the endpoint's underlying URL * - * @var mixed $args The HTTP query string parameters - * @var array $headers The request's additional HTTP headers - * @return \React\Promise\ExtendedPromiseInterface + * @param mixed $args The HTTP query string parameters + * @param array $headers The request's additional HTTP headers + * @return ExtendedPromiseInterface */ function delete($args = null, array $headers = null) : ExtendedPromiseInterface { - return $this->__xfer("DELETE", $args, null, $headers); + return $this->request("DELETE", $args, null, $headers); } - + /** * Perform a POST request against the endpoint's underlying URL * - * @var mixed $body The HTTP message's body - * @var mixed $args The HTTP query string parameters - * @var array $headers The request's additional HTTP headers - * @return \React\Promise\ExtendedPromiseInterface + * @param mixed $body The HTTP message's body + * @param mixed $args The HTTP query string parameters + * @param array $headers The request's additional HTTP headers + * @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); } - + /** * Perform a PUT request against the endpoint's underlying URL * - * @var mixed $body The HTTP message's body - * @var mixed $args The HTTP query string parameters - * @var array $headers The request's additional HTTP headers - * @return \React\Promise\ExtendedPromiseInterface + * @param mixed $body The HTTP message's body + * @param mixed $args The HTTP query string parameters + * @param array $headers The request's additional HTTP headers + * @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); } - + /** * Perform a PATCH request against the endpoint's underlying URL * - * @var mixed $body The HTTP message's body - * @var mixed $args The HTTP query string parameters - * @var array $headers The request's additional HTTP headers - * @return \React\Promise\ExtendedPromiseInterface + * @param mixed $body The HTTP message's body + * @param mixed $args The HTTP query string parameters + * @param array $headers The request's additional HTTP headers + * @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|\seekat\API\Links - */ - function links() { - return $this->__links; + return $this->request("PATCH", $args, $body, $headers); } - - /** - * Perform a GET request against the link's "first" relation - * - * @return \React\Promise\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 \React\Promise\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 \React\Promise\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 \React\Promise\ExtendedPromiseInterface - */ - function last() : ExtendedPromiseInterface { - if ($this->links() && ($last = $this->links()->getLast())) { - return $this->withUrl($last)->get(); - } - return reject($this->links()); - } - + /** * Perform all queued HTTP transfers * - * @return \seekat\API self + * @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 once - * - * @param callable $timeout as function(\seekat\API $api) : float, returning any applicable select timeout - * @return bool - */ - function __invoke(callable $timeout = null) : bool { - $this->__log->debug(__FUNCTION__); - - if (count($this->__client)) { - if ($this->__client->once()) { - if ($timeout) { - $timeout = $timeout($this); - } - - $this->__log->debug(__FUNCTION__.": wait", compact("timeout")); - - $this->__client->wait($timeout); - return 0 < count($this->__client); - } - } - return false; - } - + /** * Check for a specific key in the endpoint's underlying data * - * @var string $seg - * @var mixed &$val + * @param string $seg + * @param mixed &$val * @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, ]); - + return $exists; } - + /** * Queue the actual HTTP transfer through \seekat\API\Deferred and return the promise * - * @var string $method The HTTP request method - * @var mixed $args The HTTP query string parameters - * @var mixed $body Thee HTTP message's body - * @var array $headers The request's additional HTTP headers - * @return \React\Promise\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, + * @param string $method The HTTP request method + * @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 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 API\Deferred($this, $this->__client, $request))->promise(); + + return (new Call\Deferred($this, $request, $cache ?: $this->cache))->promise(); } }