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 seekat\API\{
+ Call, Consumer, ContentType, Future, Iterator, Links
+};
+use seekat\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
- */
- private $__url;
-
- /**
- * Logger
- * @var \Psr\Log\LoggerInterface
+ * @var Url
*/
- private $__log;
-
- /**
- * The HTTP client
- * @var \http\Client
- */
- private $__client;
-
+ private $url;
+
/**
* Default headers to send out to the API endpoint
* @var array
*/
- private $__headers;
-
+ private $headers;
+
+ /**
+ * Current endpoints links
+ * @var Links
+ */
+ private $links;
+
/**
* 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
+ * Logger
+ * @var LoggerInterface
*/
- private $__links;
-
+ private $logger;
+
+ /**
+ * Cache
+ * @var Call\Cache\Service
+ */
+ private $cache;
+
+ /**
+ * Promisor
+ * @var Future
+ */
+ private $future;
+
+ /**
+ * The HTTP client
+ * @var Client
+ */
+ private $client;
+
/**
* 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 Future $future pretending to fulfill promises
+ * @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
+ * @param Call\Cache\Service $cache A cache
+ */
+ function __construct(Future $future, array $headers = null, Url $url = null, Client $client = null, LoggerInterface $log = null, Call\Cache\Service $cache = null) {
+ $this->future = $future;
+ $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("get($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 mixed promise
*/
- function __call(string $method, array $args) : ExtendedPromiseInterface {
+ function __call(string $method, array $args) {
/* 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);
- }
-
+ if ($method === "then"
/*
* very short-hand version:
* ->users->m6w6->gists->get()->then(...)
* vs:
* ->users->m6w6->gists(...)
*/
- if (is_callable(current($args))) {
- return $this->$method->get()->then(current($args));
- }
-
- /* standard access */
- if ($this->exists($method)) {
- return $this->$method->get(...$args);
+ || is_callable(current($args))) {
+ return $this->future->handlePromise($this->get(), ...$args);
}
-
- /* 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;
+
+ 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 mixed The promise of the generator's return value
+ * @throws InvalidArgumentException
+ */
+ function __invoke($cbg) {
+ $this->logger->debug(__FUNCTION__);
+
+ $consumer = new Consumer($this->getFuture(), function() {
+ $this->client->send();
});
+
+ invoke:
+ if ($cbg instanceof Generator) {
+ return $consumer($cbg);
+ }
+
+ if (is_callable($cbg)) {
+ $cbg = $cbg($this);
+ goto invoke;
+ }
+
+ throw new InvalidArgumentException(
+ "Expected callable or Generator, got ".typeof($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 Future
+ */
+ function getFuture() {
+ return $this->future;
+ }
+
+ /**
+ * @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 = $this->type ? clone $this->type : null;
+ $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
+ * Perform a HEAD request against the endpoint's underlying URL
*
- * @return int
+ * @param mixed $args The HTTP query string parameters
+ * @param array $headers The request's additional HTTP headers
+ * @return mixed promise
*/
- function count() : int {
- return count($this->__data);
+ function head($args = null, array $headers = null, $cache = null) {
+ return $this->request("HEAD", $args, null, $headers, $cache);
}
-
+
/**
* 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 mixed promise
*/
- function get($args = null, array $headers = null) : ExtendedPromiseInterface {
- return $this->__xfer("GET", $args, null, $headers);
+ function get($args = null, array $headers = null, $cache = null) {
+ 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 mixed promise
*/
- function delete($args = null, array $headers = null) : ExtendedPromiseInterface {
- return $this->__xfer("DELETE", $args, null, $headers);
+ function delete($args = null, array $headers = null) {
+ 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 mixed promise
*/
- function post($body = null, $args = null, array $headers = null) : ExtendedPromiseInterface {
- return $this->__xfer("POST", $args, $body, $headers);
+ function post($body = null, $args = null, array $headers = null) {
+ 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 mixed promise
*/
- function put($body = null, $args = null, array $headers = null) : ExtendedPromiseInterface {
- return $this->__xfer("PUT", $args, $body, $headers);
+ function put($body = null, $args = null, array $headers = null) {
+ 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 mixed promise
*/
- function patch($body = null, $args = null, array $headers = null) : ExtendedPromiseInterface {
- return $this->__xfer("PATCH", $args, $body, $headers);
+ function patch($body = null, $args = null, array $headers = null) {
+ return $this->request("PATCH", $args, $body, $headers);
}
-
- /**
- * Accessor to any hypermedia links
- *
- * @return null|\seekat\API\Links
- */
- function links() {
- return $this->__links;
- }
-
- /**
- * 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("send: start loop");
+ while (count($this->client)) {
+ $this->client->send();
}
- $this->__log->debug(__FUNCTION__.": end loop");
+ $this->logger->debug("send: 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)
- )." -> ".(
- $exists
- ? "true"
- : "false"
+
+ $this->logger->debug(sprintf("exists(%s) in %s -> %s",
+ $seg, typeof($this->data, false), $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 mixed promise
+ */
+ private function request(string $method, $args = null, $body = null, array $headers = null, Call\Cache\Service $cache = null) {
+ if (isset($this->data)) {
+ $this->logger->debug("request -> resolve", [
+ "method" => $method,
+ "url" => (string) $this->url,
+ "args" => $args,
+ "body" => $body,
"headers" => $headers,
]);
-
- return resolve($this);
+
+ return Future\resolve($this->future, $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))();
}
}