17 use InvalidArgumentException
;
18 use IteratorAggregate
;
29 Exception\RequestException
32 ExtendedPromiseInterface
,
37 use UnexpectedValueException
;
39 class API
implements IteratorAggregate
, Countable
{
41 * The current API endpoint URL
48 * @var LoggerInterface
59 * Default headers to send out to the API endpoint
65 * Current endpoint data's Content-Type
71 * Current endpoint's data
77 * Current endpoints links
83 * Create a new API endpoint root
85 * @param array $headers Standard request headers, defaults to ["Accept" => "application/vnd.github.v3+json"]
86 * @param Url $url The API's endpoint, defaults to https://api.github.com
87 * @param Client $client The HTTP client to use for executing requests
88 * @param LoggerInterface $log A logger
90 function __construct(array $headers = null, Url
$url = null, Client
$client = null, LoggerInterface
$log = null) {
91 $this->__log
= $log ??
new NullLogger
;
92 $this->__url
= $url ??
new Url("https://api.github.com");
93 $this->__client
= $client ??
new Client
;
94 $this->__headers
= (array) $headers +
[
95 "Accept" => "application/vnd.github.v3+json"
100 * Ascend one level deep into the API endpoint
102 * @param string|int $seg The "path" element to ascend into
103 * @return API Endpoint clone referring to {$parent}/{$seg}
105 function __get($seg) : API
{
106 if (substr($seg, -4) === "_url") {
107 $url = new Url(uri_template($this->__data
->$seg));
108 $that = $this->withUrl($url);
109 $seg = basename($that->__url
->path
);
112 $that->__url
->path
.= "/".urlencode($seg);
113 $this->exists($seg, $that->__data
);
116 $this->__log
->debug(__FUNCTION__
."($seg)", [
118 (string) $this->__url
,
119 (string) $that->__url
127 * Call handler that actually queues a data fetch and returns a promise
129 * @param string $method The API's "path" element to ascend into
130 * @param array $args Array of arguments forwarded to \seekat\API::get()
131 * @return ExtendedPromiseInterface
133 function __call(string $method, array $args) : ExtendedPromiseInterface
{
134 /* We cannot implement an explicit then() method,
135 * because the Promise implementation might think
136 * we're actually implementing Thenable,
137 * which might cause an infinite loop.
139 if ($method === "then") {
140 return $this->get()->then(...$args);
144 * very short-hand version:
145 * ->users->m6w6->gists->get()->then(...)
147 * ->users->m6w6->gists(...)
149 if (is_callable(current($args))) {
150 return $this->$method->get()->then(current($args));
153 /* standard access */
154 if ($this->exists($method)) {
155 return $this->$method->get(...$args);
158 /* fetch resource, unless already localized, and try for {$method}_url */
159 return $this->$method->get(...$args)->otherwise(function($error) use($method, $args) {
160 if ($error instanceof Throwable
) {
161 $message = $error->getMessage();
164 $error = new Exception($error);
166 if ($this->exists($method."_url", $url)) {
168 $this->__log
->info(__FUNCTION__
."($method): ". $message, [
169 "url" => (string) $this->__url
172 $url = new Url(uri_template($url, (array) current($args)));
173 return $this->withUrl($url)->get(...$args);
176 $this->__log
->error(__FUNCTION__
."($method): ". $message, [
177 "url" => (string) $this->__url
185 * Clone handler ensuring the underlying url will be cloned, too
188 $this->__url
= clone $this->__url
;
192 * The string handler for the endpoint's data
196 function __toString() : string {
197 if (is_scalar($this->__data
)) {
198 return (string) $this->__data
;
202 return json_encode($this->__data
);
206 * Import handler for the endpoint's underlying data
208 * \seekat\Call will call this when the request will have finished.
210 * @param Response $response
212 * @throws UnexpectedValueException
213 * @throws RequestException
216 function import(Response
$response) : API
{
217 $this->__log
->info(__FUNCTION__
.": ". $response->getInfo(), [
218 "url" => (string) $this->__url
221 if ($response->getResponseCode() >= 400) {
222 $e = new RequestException($response);
224 $this->__log
->critical(__FUNCTION__
.": ".$e->getMessage(), [
225 "url" => (string) $this->__url
,
231 if (!($type = $response->getHeader("Content-Type", Header
::class))) {
232 $e = new RequestException($response);
234 __FUNCTION__
.": Empty Content-Type -> ".$e->getMessage(), [
235 "url" => (string) $this->__url
,
241 $this->__type
= new ContentType($type);
242 $this->__data
= $this->__type
->parseBody($response->getBody());
244 if (($link = $response->getHeader("Link", Header
::class))) {
245 $this->__links
= new Links($link);
247 } catch (\Exception
$e) {
248 $this->__log
->error(__FUNCTION__
.": ".$e->getMessage(), [
249 "url" => (string) $this->__url
259 * Export the endpoint's underlying data
264 function export(&$type = null) {
265 $type = clone $this->__type
;
266 return $this->__data
;
270 * Create a copy of the endpoint with specific data
275 function withData($data) : API
{
277 $that->__data
= $data;
282 * Create a copy of the endpoint with a specific Url, but with data reset
287 function withUrl(Url
$url) : API
{
288 $that = $this->withData(null);
294 * Create a copy of the endpoint with a specific header added/replaced
296 * @param string $name
297 * @param mixed $value
300 function withHeader(string $name, $value) : API
{
303 $that->__headers
[$name] = $value;
305 unset($that->__headers
[$name]);
311 * Create a copy of the endpoint with a customized accept header
313 * Changes the returned endpoint's accept header to "application/vnd.github.v3.{$type}"
315 * @param string $type The expected return data type, e.g. "raw", "html", etc.
316 * @param bool $keepdata Whether to keep already fetched data
319 function as(string $type, bool $keepdata = true) : API
{
320 switch(substr($type, 0, 1)) {
329 $vapi = ContentType
::version();
330 $that = $this->withHeader("Accept", "application/vnd.github.v$vapi$type");
332 $that->__data
= null;
338 * Create an iterator over the endpoint's underlying data
342 function getIterator() : Iterator
{
343 return new Iterator($this);
347 * Count the underlying data's entries
351 function count() : int {
352 return count($this->__data
);
356 * Perform a GET request against the endpoint's underlying URL
358 * @param mixed $args The HTTP query string parameters
359 * @param array $headers The request's additional HTTP headers
360 * @return ExtendedPromiseInterface
362 function get($args = null, array $headers = null) : ExtendedPromiseInterface
{
363 return $this->__xfer("GET", $args, null, $headers);
367 * Perform a DELETE request against the endpoint's underlying URL
369 * @param mixed $args The HTTP query string parameters
370 * @param array $headers The request's additional HTTP headers
371 * @return ExtendedPromiseInterface
373 function delete($args = null, array $headers = null) : ExtendedPromiseInterface
{
374 return $this->__xfer("DELETE", $args, null, $headers);
378 * Perform a POST request against the endpoint's underlying URL
380 * @param mixed $body The HTTP message's body
381 * @param mixed $args The HTTP query string parameters
382 * @param array $headers The request's additional HTTP headers
383 * @return ExtendedPromiseInterface
385 function post($body = null, $args = null, array $headers = null) : ExtendedPromiseInterface
{
386 return $this->__xfer("POST", $args, $body, $headers);
390 * Perform a PUT request against the endpoint's underlying URL
392 * @param mixed $body The HTTP message's body
393 * @param mixed $args The HTTP query string parameters
394 * @param array $headers The request's additional HTTP headers
395 * @return ExtendedPromiseInterface
397 function put($body = null, $args = null, array $headers = null) : ExtendedPromiseInterface
{
398 return $this->__xfer("PUT", $args, $body, $headers);
402 * Perform a PATCH request against the endpoint's underlying URL
404 * @param mixed $body The HTTP message's body
405 * @param mixed $args The HTTP query string parameters
406 * @param array $headers The request's additional HTTP headers
407 * @return ExtendedPromiseInterface
409 function patch($body = null, $args = null, array $headers = null) : ExtendedPromiseInterface
{
410 return $this->__xfer("PATCH", $args, $body, $headers);
414 * Accessor to any hypermedia links
419 return $this->__links
;
423 * Perform a GET request against the link's "first" relation
425 * @return ExtendedPromiseInterface
427 function first() : ExtendedPromiseInterface
{
428 if ($this->links() && ($first = $this->links()->getFirst())) {
429 return $this->withUrl($first)->get();
431 return reject($this->links());
435 * Perform a GET request against the link's "prev" relation
437 * @return ExtendedPromiseInterface
439 function prev() : ExtendedPromiseInterface
{
440 if ($this->links() && ($prev = $this->links()->getPrev())) {
441 return $this->withUrl($prev)->get();
443 return reject($this->links());
447 * Perform a GET request against the link's "next" relation
449 * @return ExtendedPromiseInterface
451 function next() : ExtendedPromiseInterface
{
452 if ($this->links() && ($next = $this->links()->getNext())) {
453 return $this->withUrl($next)->get();
455 return reject($this->links());
459 * Perform a GET request against the link's "last" relation
461 * @return ExtendedPromiseInterface
463 function last() : ExtendedPromiseInterface
{
464 if ($this->links() && ($last = $this->links()->getLast())) {
465 return $this->withUrl($last)->get();
467 return reject($this->links());
471 * Perform all queued HTTP transfers
475 function send() : API
{
476 $this->__log
->debug(__FUNCTION__
.": start loop");
477 while (count($this->__client
)) {
478 $this->__client
->send();
480 $this->__log
->debug(__FUNCTION__
.": end loop");
485 * Run the send loop through a generator
487 * @param callable|Generator $cbg A \Generator or a factory of a \Generator yielding promises
488 * @return ExtendedPromiseInterface The promise of the generator's return value
489 * @throws InvalidArgumentException
491 function __invoke($cbg) : ExtendedPromiseInterface
{
492 $this->__log
->debug(__FUNCTION__
);
494 $invoker = new Invoker($this->__client
);
496 if ($cbg instanceof Generator
) {
497 return $invoker->iterate($cbg)->promise();
500 if (is_callable($cbg)) {
501 return $invoker->invoke(function() use($cbg) {
506 throw InvalidArgumentException(
507 "Expected callable or Generator, got ".(
509 ?
"instance of ".get_class($cbg)
510 : gettype($cbg).": ".var_export($cbg, true)
516 * Check for a specific key in the endpoint's underlying data
522 function exists($seg, &$val = null) : bool {
523 if (is_array($this->__data
) && array_key_exists($seg, $this->__data
)) {
524 $val = $this->__data
[$seg];
526 } elseif (is_object($this->__data
) && property_exists($this->__data
, $seg)) {
527 $val = $this->__data
->$seg;
534 $this->__log
->debug(__FUNCTION__
."($seg) in ".(
535 is_object($this->__data
)
536 ?
get_class($this->__data
)
537 : gettype($this->__data
)
543 "url" => (string) $this->__url
,
551 * Queue the actual HTTP transfer through \seekat\API\Deferred and return the promise
553 * @param string $method The HTTP request method
554 * @param mixed $args The HTTP query string parameters
555 * @param mixed $body Thee HTTP message's body
556 * @param array $headers The request's additional HTTP headers
557 * @return ExtendedPromiseInterface
559 private function __xfer(string $method, $args = null, $body = null, array $headers = null) : ExtendedPromiseInterface
{
560 if (isset($this->__data
)) {
561 $this->__log
->debug(__FUNCTION__
."($method) -> resolve", [
562 "url" => (string) $this->__url
,
565 "headers" => $headers,
568 return resolve($this);
571 $url = $this->__url
->mod(["query" => new QueryString($args)]);
572 $request = new Request($method, $url, ((array) $headers) +
$this->__headers
,
573 $body = is_array($body) ?
json_encode($body) : (
574 is_resource($body) ?
new Body($body) : (
575 is_scalar($body) ?
(new Body
)->append($body) :
578 $this->__log
->info(__FUNCTION__
."($method) -> request", [
579 "url" => (string) $this->__url
,
580 "args" => $this->__url
->query
,
582 "headers" => $headers,
585 return (new Call($this, $this->__client
, $request))->promise();