5 use AsyncInterop\Promise
;
9 Client
, Client\Request
, Message\Body
, QueryString
, Url
11 use IteratorAggregate
;
13 LoggerInterface
, NullLogger
16 Call
, Consumer
, ContentType
, Future
, Iterator
, Links
18 use seekat\Exception\InvalidArgumentException
;
20 class API
implements IteratorAggregate
, Countable
{
22 * The current API endpoint URL
28 * Default headers to send out to the API endpoint
34 * Current endpoints links
40 * Current endpoint data's Content-Type
41 * @var API\ContentType
46 * Current endpoint's data
53 * @var LoggerInterface
59 * @var Call\Cache\Service
76 * Create a new API endpoint root
78 * @param Future $future pretending to fulfill promises
79 * @param array $headers Standard request headers, defaults to ["Accept" => "application/vnd.github.v3+json"]
80 * @param Url $url The API's endpoint, defaults to https://api.github.com
81 * @param Client $client The HTTP client to use for executing requests
82 * @param LoggerInterface $log A logger
83 * @param Call\Cache\Service $cache A cache
85 function __construct(Future
$future, array $headers = null, Url
$url = null, Client
$client = null, LoggerInterface
$log = null, Call\Cache\Service
$cache = null) {
86 $this->future
= $future;
87 $this->cache
= $cache;
88 $this->logger
= $log ??
new NullLogger
;
89 $this->url
= $url ??
new Url("https://api.github.com");
90 $this->client
= $client ??
new Client
;
91 $this->headers
= (array) $headers +
[
92 "Accept" => "application/vnd.github.v3+json"
97 * Ascend one level deep into the API endpoint
99 * @param string|int $seg The "path" element to ascend into
100 * @return API Endpoint clone referring to {$parent}/{$seg}
102 function __get($seg) : API
{
103 if (substr($seg, -4) === "_url") {
104 $url = new Url(uri_template($this->data
->$seg));
105 $that = $this->withUrl($url);
106 $seg = basename($that->url
->path
);
109 $that->url
->path
.= "/".urlencode($seg);
110 $this->exists($seg, $that->data
);
113 $this->logger
->debug("get($seg)", [
124 * Call handler that actually queues a data fetch and returns a promise
126 * @param string $method The API's "path" element to ascend into
127 * @param array $args Array of arguments forwarded to \seekat\API::get()
130 function __call(string $method, array $args) : Promise
{
131 /* We cannot implement an explicit then() method,
132 * because the Promise implementation might think
133 * we're actually implementing Thenable,
134 * which might cause an infinite loop.
137 if ($method === "when") {
138 $promise = $this->get();
139 $promise->when(...$args);
144 * very short-hand version:
145 * ->users->m6w6->gists->get()->when(...)
147 * ->users->m6w6->gists(...)
149 if (is_callable(current($args))) {
150 $promise = $this->get();
151 $promise->when(current($args));
155 return (new Call($this, $method))($args);
159 * Run the send loop through a generator
161 * @param callable|Generator $cbg A \Generator or a factory of a \Generator yielding promises
162 * @return Promise The promise of the generator's return value
163 * @throws InvalidArgumentException
165 function __invoke($cbg) : Promise
{
166 $this->logger
->debug(__FUNCTION__
);
168 $consumer = new Consumer($this->getFuture(), function() {
169 $this->client
->send();
173 if ($cbg instanceof Generator
) {
174 return $consumer($cbg);
177 if (is_callable($cbg)) {
182 throw new InvalidArgumentException(
183 "Expected callable or Generator, got ".typeof($cbg, true)
188 * Clone handler ensuring the underlying url will be cloned, too
191 $this->url
= clone $this->url
;
195 * The string handler for the endpoint's data
199 function __toString() : string {
200 if (is_scalar($this->data
)) {
201 return (string) $this->data
;
205 return json_encode($this->data
);
209 * Create an iterator over the endpoint's underlying data
213 function getIterator() : Iterator
{
214 return new Iterator($this);
218 * Count the underlying data's entries
222 function count() : int {
223 return count($this->data
);
229 function getUrl() : Url
{
234 * @return LoggerInterface
236 function getLogger() : LoggerInterface
{
237 return $this->logger
;
243 function getFuture() {
244 return $this->future
;
250 public function getClient(): Client
{
251 return $this->client
;
255 * @return array|object
262 * Accessor to any hypermedia links
266 function getLinks() {
271 * Export the endpoint's underlying data
273 * @return array ["url", "data", "type", "links", "headers"]
275 function export() : array {
277 $url = clone $this->url
;
278 $type = $this->type ?
clone $this->type
: null;
279 $links = $this->links ?
clone $this->links
: null;
280 $headers = $this->headers
;
281 return compact("url", "data", "type", "links", "headers");
288 function with($export) : API
{
290 if (is_array($export) ||
($export instanceof \ArrayAccess
)) {
291 isset($export["url"]) && $that->url
= $export["url"];
292 isset($export["data"]) && $that->data
= $export["data"];
293 isset($export["type"]) && $that->type
= $export["type"];
294 isset($export["links"]) && $that->links
= $export["links"];
295 isset($export["headers"]) && $that->headers
= $export["headers"];
301 * Create a copy of the endpoint with specific data
306 function withData($data) : API
{
313 * Create a copy of the endpoint with a specific Url, but with data reset
318 function withUrl(Url
$url) : API
{
322 #$that->links = null;
327 * Create a copy of the endpoint with a specific header added/replaced
329 * @param string $name
330 * @param mixed $value
333 function withHeader(string $name, $value) : API
{
336 $that->headers
[$name] = $value;
338 unset($that->headers
[$name]);
344 * Create a copy of the endpoint with a customized accept header
346 * Changes the returned endpoint's accept header to "application/vnd.github.v3.{$type}"
348 * @param string $type The expected return data type, e.g. "raw", "html", ..., or a complete content type
349 * @param bool $keepdata Whether to keep already fetched data
352 function as(string $type, bool $keepdata = true) : API
{
353 $that = ContentType
::apply($this, $type);
361 * Perform a HEAD request against the endpoint's underlying URL
363 * @param mixed $args The HTTP query string parameters
364 * @param array $headers The request's additional HTTP headers
367 function head($args = null, array $headers = null, $cache = null) : Promise
{
368 return $this->request("HEAD", $args, null, $headers, $cache);
372 * Perform a GET request against the endpoint's underlying URL
374 * @param mixed $args The HTTP query string parameters
375 * @param array $headers The request's additional HTTP headers
378 function get($args = null, array $headers = null, $cache = null) : Promise
{
379 return $this->request("GET", $args, null, $headers, $cache);
383 * Perform a DELETE request against the endpoint's underlying URL
385 * @param mixed $args The HTTP query string parameters
386 * @param array $headers The request's additional HTTP headers
389 function delete($args = null, array $headers = null) : Promise
{
390 return $this->request("DELETE", $args, null, $headers);
394 * Perform a POST request against the endpoint's underlying URL
396 * @param mixed $body The HTTP message's body
397 * @param mixed $args The HTTP query string parameters
398 * @param array $headers The request's additional HTTP headers
401 function post($body = null, $args = null, array $headers = null) : Promise
{
402 return $this->request("POST", $args, $body, $headers);
406 * Perform a PUT request against the endpoint's underlying URL
408 * @param mixed $body The HTTP message's body
409 * @param mixed $args The HTTP query string parameters
410 * @param array $headers The request's additional HTTP headers
413 function put($body = null, $args = null, array $headers = null) : Promise
{
414 return $this->request("PUT", $args, $body, $headers);
418 * Perform a PATCH request against the endpoint's underlying URL
420 * @param mixed $body The HTTP message's body
421 * @param mixed $args The HTTP query string parameters
422 * @param array $headers The request's additional HTTP headers
425 function patch($body = null, $args = null, array $headers = null) : Promise
{
426 return $this->request("PATCH", $args, $body, $headers);
430 * Perform all queued HTTP transfers
434 function send() : API
{
435 $this->logger
->debug("send: start loop");
436 while (count($this->client
)) {
437 $this->client
->send();
439 $this->logger
->debug("send: end loop");
444 * Check for a specific key in the endpoint's underlying data
450 function exists($seg, &$val = null) : bool {
451 if (is_array($this->data
) && array_key_exists($seg, $this->data
)) {
452 $val = $this->data
[$seg];
454 } elseif (is_object($this->data
) && property_exists($this->data
, $seg)) {
455 $val = $this->data
->$seg;
462 $this->logger
->debug(sprintf("exists(%s) in %s -> %s",
463 $seg, typeof($this->data
, false), $exists ?
"true" : "false"
465 "url" => (string) $this->url
,
473 * Queue the actual HTTP transfer through \seekat\API\Deferred and return the promise
475 * @param string $method The HTTP request method
476 * @param mixed $args The HTTP query string parameters
477 * @param mixed $body Thee HTTP message's body
478 * @param array $headers The request's additional HTTP headers
479 * @param Call\Cache\Service $cache
482 private function request(string $method, $args = null, $body = null, array $headers = null, Call\Cache\Service
$cache = null) : Promise
{
483 if (isset($this->data
)) {
484 $this->logger
->debug("request -> resolve", [
486 "url" => (string) $this->url
,
489 "headers" => $headers,
492 return Future\resolve
($this->future
, $this);
495 $url = $this->url
->mod(["query" => new QueryString($args)]);
496 $request = new Request($method, $url, ((array) $headers) +
$this->headers
,
497 $body = is_array($body) ?
json_encode($body) : (
498 is_resource($body) ?
new Body($body) : (
499 is_scalar($body) ?
(new Body
)->append($body) :
502 $this->logger
->info("request -> deferred", [
504 "url" => (string) $this->url
,
505 "args" => $this->url
->query
,
507 "headers" => $headers,
510 return (new Call\
Deferred($this, $request, $cache ?
: $this->cache
))();