16 use InvalidArgumentException
;
17 use IteratorAggregate
;
28 Exception\RequestException
31 ExtendedPromiseInterface
,
36 use UnexpectedValueException
;
38 class API
implements IteratorAggregate
, Countable
{
40 * The current API endpoint URL
47 * @var LoggerInterface
58 * Default headers to send out to the API endpoint
64 * Current endpoint data's Content-Type
70 * Current endpoint's data
76 * Current endpoints links
82 * Create a new API endpoint root
84 * @param array $headers Standard request headers, defaults to ["Accept" => "application/vnd.github.v3+json"]
85 * @param Url $url The API's endpoint, defaults to https://api.github.com
86 * @param Client $client The HTTP client to use for executing requests
87 * @param LoggerInterface $log A logger
89 function __construct(array $headers = null, Url
$url = null, Client
$client = null, LoggerInterface
$log = null) {
90 $this->__log
= $log ??
new NullLogger
;
91 $this->__url
= $url ??
new Url("https://api.github.com");
92 $this->__client
= $client ??
new Client
;
93 $this->__headers
= (array) $headers +
[
94 "Accept" => "application/vnd.github.v3+json"
99 * Ascend one level deep into the API endpoint
101 * @param string|int $seg The "path" element to ascend into
102 * @return API Endpoint clone referring to {$parent}/{$seg}
104 function __get($seg) : API
{
105 if (substr($seg, -4) === "_url") {
106 $url = new Url(uri_template($this->__data
->$seg));
107 $that = $this->withUrl($url);
108 $seg = basename($that->__url
->path
);
111 $that->__url
->path
.= "/".urlencode($seg);
112 $this->exists($seg, $that->__data
);
115 $this->__log
->debug(__FUNCTION__
."($seg)", [
117 (string) $this->__url
,
118 (string) $that->__url
126 * Call handler that actually queues a data fetch and returns a promise
128 * @param string $method The API's "path" element to ascend into
129 * @param array $args Array of arguments forwarded to \seekat\API::get()
130 * @return ExtendedPromiseInterface
132 function __call(string $method, array $args) : ExtendedPromiseInterface
{
133 /* We cannot implement an explicit then() method,
134 * because the Promise implementation might think
135 * we're actually implementing Thenable,
136 * which might cause an infinite loop.
138 if ($method === "then") {
139 return $this->get()->then(...$args);
143 * very short-hand version:
144 * ->users->m6w6->gists->get()->then(...)
146 * ->users->m6w6->gists(...)
148 if (is_callable(current($args))) {
149 return $this->$method->get()->then(current($args));
152 /* standard access */
153 if ($this->exists($method)) {
154 return $this->$method->get(...$args);
157 /* fetch resource, unless already localized, and try for {$method}_url */
158 return $this->$method->get(...$args)->otherwise(function(Throwable
$error) use($method, $args) {
159 if ($this->exists($method."_url", $url)) {
161 $this->__log
->info(__FUNCTION__
."($method): ". $error->getMessage(), [
162 "url" => (string) $this->__url
165 $url = new Url(uri_template($url, (array) current($args)));
166 return $this->withUrl($url)->get(...$args);
169 $this->__log
->error(__FUNCTION__
."($method): ". $error->getMessage(), [
170 "url" => (string) $this->__url
178 * Clone handler ensuring the underlying url will be cloned, too
181 $this->__url
= clone $this->__url
;
185 * The string handler for the endpoint's data
189 function __toString() : string {
190 if (is_scalar($this->__data
)) {
191 return (string) $this->__data
;
195 return json_encode($this->__data
);
199 * Import handler for the endpoint's underlying data
201 * \seekat\Call will call this when the request will have finished.
203 * @param Response $response
205 * @throws UnexpectedValueException
206 * @throws RequestException
209 function import(Response
$response) : API
{
210 $this->__log
->info(__FUNCTION__
.": ". $response->getInfo(), [
211 "url" => (string) $this->__url
214 if ($response->getResponseCode() >= 400) {
215 $e = new RequestException($response);
217 $this->__log
->critical(__FUNCTION__
.": ".$e->getMessage(), [
218 "url" => (string) $this->__url
,
224 if (!($type = $response->getHeader("Content-Type", Header
::class))) {
225 $e = new RequestException($response);
227 __FUNCTION__
.": Empty Content-Type -> ".$e->getMessage(), [
228 "url" => (string) $this->__url
,
234 $this->__type
= new ContentType($type);
235 $this->__data
= $this->__type
->parseBody($response->getBody());
237 if (($link = $response->getHeader("Link", Header
::class))) {
238 $this->__links
= new Links($link);
240 } catch (\Exception
$e) {
241 $this->__log
->error(__FUNCTION__
.": ".$e->getMessage(), [
242 "url" => (string) $this->__url
252 * Export the endpoint's underlying data
257 function export(&$type = null) {
258 $type = clone $this->__type
;
259 return $this->__data
;
263 * Create a copy of the endpoint with specific data
268 function withData($data) : API
{
270 $that->__data
= $data;
275 * Create a copy of the endpoint with a specific Url, but with data reset
280 function withUrl(Url
$url) : API
{
281 $that = $this->withData(null);
287 * Create a copy of the endpoint with a specific header added/replaced
289 * @param string $name
290 * @param mixed $value
293 function withHeader(string $name, $value) : API
{
296 $that->__headers
[$name] = $value;
298 unset($that->__headers
[$name]);
304 * Create a copy of the endpoint with a customized accept header
306 * Changes the returned endpoint's accept header to "application/vnd.github.v3.{$type}"
308 * @param string $type The expected return data type, e.g. "raw", "html", etc.
309 * @param bool $keepdata Whether to keep already fetched data
312 function as(string $type, bool $keepdata = true) : API
{
313 switch(substr($type, 0, 1)) {
322 $vapi = ContentType
::version();
323 $that = $this->withHeader("Accept", "application/vnd.github.v$vapi$type");
325 $that->__data
= null;
331 * Create an iterator over the endpoint's underlying data
335 function getIterator() : Iterator
{
336 return new Iterator($this);
340 * Count the underlying data's entries
344 function count() : int {
345 return count($this->__data
);
349 * Perform a GET request against the endpoint's underlying URL
351 * @param mixed $args The HTTP query string parameters
352 * @param array $headers The request's additional HTTP headers
353 * @return ExtendedPromiseInterface
355 function get($args = null, array $headers = null) : ExtendedPromiseInterface
{
356 return $this->__xfer("GET", $args, null, $headers);
360 * Perform a DELETE request against the endpoint's underlying URL
362 * @param mixed $args The HTTP query string parameters
363 * @param array $headers The request's additional HTTP headers
364 * @return ExtendedPromiseInterface
366 function delete($args = null, array $headers = null) : ExtendedPromiseInterface
{
367 return $this->__xfer("DELETE", $args, null, $headers);
371 * Perform a POST request against the endpoint's underlying URL
373 * @param mixed $body The HTTP message's body
374 * @param mixed $args The HTTP query string parameters
375 * @param array $headers The request's additional HTTP headers
376 * @return ExtendedPromiseInterface
378 function post($body = null, $args = null, array $headers = null) : ExtendedPromiseInterface
{
379 return $this->__xfer("POST", $args, $body, $headers);
383 * Perform a PUT request against the endpoint's underlying URL
385 * @param mixed $body The HTTP message's body
386 * @param mixed $args The HTTP query string parameters
387 * @param array $headers The request's additional HTTP headers
388 * @return ExtendedPromiseInterface
390 function put($body = null, $args = null, array $headers = null) : ExtendedPromiseInterface
{
391 return $this->__xfer("PUT", $args, $body, $headers);
395 * Perform a PATCH request against the endpoint's underlying URL
397 * @param mixed $body The HTTP message's body
398 * @param mixed $args The HTTP query string parameters
399 * @param array $headers The request's additional HTTP headers
400 * @return ExtendedPromiseInterface
402 function patch($body = null, $args = null, array $headers = null) : ExtendedPromiseInterface
{
403 return $this->__xfer("PATCH", $args, $body, $headers);
407 * Accessor to any hypermedia links
412 return $this->__links
;
416 * Perform a GET request against the link's "first" relation
418 * @return ExtendedPromiseInterface
420 function first() : ExtendedPromiseInterface
{
421 if ($this->links() && ($first = $this->links()->getFirst())) {
422 return $this->withUrl($first)->get();
424 return reject($this->links());
428 * Perform a GET request against the link's "prev" relation
430 * @return ExtendedPromiseInterface
432 function prev() : ExtendedPromiseInterface
{
433 if ($this->links() && ($prev = $this->links()->getPrev())) {
434 return $this->withUrl($prev)->get();
436 return reject($this->links());
440 * Perform a GET request against the link's "next" relation
442 * @return ExtendedPromiseInterface
444 function next() : ExtendedPromiseInterface
{
445 if ($this->links() && ($next = $this->links()->getNext())) {
446 return $this->withUrl($next)->get();
448 return reject($this->links());
452 * Perform a GET request against the link's "last" relation
454 * @return ExtendedPromiseInterface
456 function last() : ExtendedPromiseInterface
{
457 if ($this->links() && ($last = $this->links()->getLast())) {
458 return $this->withUrl($last)->get();
460 return reject($this->links());
464 * Perform all queued HTTP transfers
468 function send() : API
{
469 $this->__log
->debug(__FUNCTION__
.": start loop");
470 while (count($this->__client
)) {
471 $this->__client
->send();
473 $this->__log
->debug(__FUNCTION__
.": end loop");
478 * Run the send loop through a generator
480 * @param callable|Generator $cbg A \Generator or a factory of a \Generator yielding promises
481 * @return ExtendedPromiseInterface The promise of the generator's return value
482 * @throws InvalidArgumentException
484 function __invoke($cbg) : ExtendedPromiseInterface
{
485 $this->__log
->debug(__FUNCTION__
);
487 $invoker = new Invoker($this->__client
);
489 if ($cbg instanceof Generator
) {
490 return $invoker->iterate($cbg)->promise();
493 if (is_callable($cbg)) {
494 return $invoker->invoke(function() use($cbg) {
499 throw InvalidArgumentException(
500 "Expected callable or Generator, got ".(
502 ?
"instance of ".get_class($cbg)
503 : gettype($cbg).": ".var_export($cbg, true)
509 * Check for a specific key in the endpoint's underlying data
515 function exists($seg, &$val = null) : bool {
516 if (is_array($this->__data
) && array_key_exists($seg, $this->__data
)) {
517 $val = $this->__data
[$seg];
519 } elseif (is_object($this->__data
) && property_exists($this->__data
, $seg)) {
520 $val = $this->__data
->$seg;
527 $this->__log
->debug(__FUNCTION__
."($seg) in ".(
528 is_object($this->__data
)
529 ?
get_class($this->__data
)
530 : gettype($this->__data
)
536 "url" => (string) $this->__url
,
544 * Queue the actual HTTP transfer through \seekat\API\Deferred and return the promise
546 * @param string $method The HTTP request method
547 * @param mixed $args The HTTP query string parameters
548 * @param mixed $body Thee HTTP message's body
549 * @param array $headers The request's additional HTTP headers
550 * @return ExtendedPromiseInterface
552 private function __xfer(string $method, $args = null, $body = null, array $headers = null) : ExtendedPromiseInterface
{
553 if (isset($this->__data
)) {
554 $this->__log
->debug(__FUNCTION__
."($method) -> resolve", [
555 "url" => (string) $this->__url
,
558 "headers" => $headers,
561 return resolve($this);
564 $url = $this->__url
->mod(["query" => new QueryString($args)]);
565 $request = new Request($method, $url, ((array) $headers) +
$this->__headers
,
566 $body = is_array($body) ?
json_encode($body) : (
567 is_resource($body) ?
new Body($body) : (
568 is_scalar($body) ?
(new Body
)->append($body) :
571 $this->__log
->info(__FUNCTION__
."($method) -> request", [
572 "url" => (string) $this->__url
,
573 "args" => $this->__url
->query
,
575 "headers" => $headers,
578 return (new Call($this, $this->__client
, $request))->promise();