5 use seekat\API\ContentType
;
6 use seekat\Exception\RequestException
;
11 use http\Client\Request
;
12 use http\Client\Response
;
13 use http\Message\Body
;
16 use Psr\Log\LoggerInterface
;
17 use Psr\Log\NullLogger
;
19 use React\Promise\ExtendedPromiseInterface
;
20 use function React\Promise\resolve
;
21 use function React\Promise\reject
;
22 use function React\Promise\map
;
24 class API
implements \IteratorAggregate
, \Countable
{
26 * The current API endpoint URL
33 * @var \Psr\Log\LoggerInterface
44 * Default headers to send out to the API endpoint
50 * Current endpoint data's Content-Type
56 * Current endpoint's data
62 * Current endpoints links
63 * @var seekat\API\Links
68 * Create a new API endpoint root
70 * @var array $headers Standard request headers, defaults to ["Accept" => "application/vnd.github.v3+json"]
71 * @var \http\Url The API's endpoint, defaults to https://api.github.com
72 * @var \http\Client $client The HTTP client to use for executing requests
73 * @var \Psr\Log\LoggerInterface $log A logger
75 function __construct(array $headers = null, Url
$url = null, Client
$client = null, LoggerInterface
$log = null) {
76 $this->__log
= $log ??
new NullLogger
;
77 $this->__url
= $url ??
new Url("https://api.github.com");
78 $this->__client
= $client ??
new Client
;
79 $this->__headers
= (array) $headers +
[
80 "Accept" => "application/vnd.github.v3+json"
85 * Ascend one level deep into the API endpoint
87 * @var string|int $seg The "path" element to ascend into
88 * @return \seekat\API Endpoint clone referring to {$parent}/{$seg}
90 function __get($seg) : API
{
91 if (substr($seg, -4) === "_url") {
92 $url = new Url(uri_template($this->__data
->$seg));
93 $that = $this->withUrl($url);
94 $seg = basename($that->__url
->path
);
97 $that->__url
->path
.= "/".urlencode($seg);
98 $this->exists($seg, $that->__data
);
101 $this->__log
->debug(__FUNCTION__
."($seg)", [
103 (string) $this->__url
,
104 (string) $that->__url
112 * Call handler that actually queues a data fetch and returns a promise
114 * @var string $method The API's "path" element to ascend into
115 * @var array $args Array of arguments forwarded to \seekat\API::get()
116 * @return \React\Promise\ExtendedPromiseInterface
118 function __call(string $method, array $args) : ExtendedPromiseInterface
{
119 /* We cannot implement an explicit then() method,
120 * because the Promise implementation might think
121 * we're actually implementing Thenable,
122 * which might cause an infite loop.
124 if ($method === "then") {
125 return $this->get()->then(...$args);
129 * very short-hand version:
130 * ->users->m6w6->gists->get()->then(...)
132 * ->users->m6w6->gists(...)
134 if (is_callable(current($args))) {
135 return $this->$method->get()->then(current($args));
138 /* standard access */
139 if ($this->exists($method)) {
140 return $this->$method->get(...$args);
143 /* fetch resource, unless already localized, and try for {$method}_url */
144 return $this->$method->get(...$args)->otherwise(function($error) use($method, $args) {
145 if ($this->exists($method."_url", $url)) {
147 $this->__log
->info(__FUNCTION__
."($method): ". $error->getMessage(), [
148 "url" => (string) $this->__url
151 $url = new Url(uri_template($url, (array) current($args)));
152 return $this->withUrl($url)->get(...$args);
155 $this->__log
->error(__FUNCTION__
."($method): ". $error->getMessage(), [
156 "url" => (string) $this->__url
164 * Clone handler ensuring the underlying url will be cloned, too
167 $this->__url
= clone $this->__url
;
171 * The string handler for the endpoint's data
175 function __toString() : string {
176 if (is_scalar($this->__data
)) {
177 return (string) $this->__data
;
181 return json_encode($this->__data
);
185 * Import handler for the endpoint's underlying data
187 * \seekat\Deferred will call this when the request will have finished.
189 * @var \http\Client\Response $response
190 * @return \seekat\API self
192 function import(Response
$response) : API
{
193 //addcslashes($response, "\0..\40\42\47\134\140\177..\377")
195 $this->__log
->info(__FUNCTION__
.": ". $response->getInfo(), [
196 "url" => (string) $this->__url
199 if ($response->getResponseCode() >= 400) {
200 $e = new RequestException($response);
202 $this->__log
->critical(__FUNCTION__
.": ".$e->getMessage(), [
203 "url" => (string) $this->__url
,
209 if (!($type = $response->getHeader("Content-Type", Header
::class))) {
210 $e = new RequestException($response);
212 __FUNCTION__
.": Empty Content-Type -> ".$e->getMessage(), [
213 "url" => (string) $this->__url
,
219 $this->__type
= new ContentType($type);
220 $this->__data
= $this->__type
->parseBody($response->getBody());
222 if (($link = $response->getHeader("Link", Header
::class))) {
223 $this->__links
= new API\
Links($link);
225 } catch (\Exception
$e) {
226 $this->__log
->error(__FUNCTION__
.": ".$e->getMessage(), [
227 "url" => (string) $this->__url
237 * Export the endpoint's underlying data
241 function export(&$type = null) {
242 $type = clone $this->__type
;
243 return $this->__data
;
247 * Create a copy of the endpoint with specific data
250 * @return \seekat\API clone
252 function withData($data) : API
{
254 $that->__data
= $data;
259 * Create a copy of the endpoint with a specific Url, but with data reset
261 * @var \http\Url $url
262 * @return \seekat\API clone
264 function withUrl(Url
$url) : API
{
265 $that = $this->withData(null);
271 * Create a copy of the endpoint with a specific header added/replaced
275 * @return \seekat\API clone
277 function withHeader(string $name, $value) : API
{
280 $that->__headers
[$name] = $value;
282 unset($that->__headers
[$name]);
288 * Create a copy of the endpoint with a customized accept header
290 * Changes the returned endpoint's accept header to
291 * "application/vnd.github.v3.{$type}"
293 * @var string $type The expected return data type, e.g. "raw", "html", etc.
294 * @var bool $keepdata Whether to keep already fetched data
295 * @return \seekat\API clone
297 function as(string $type, bool $keepdata = true) : API
{
298 switch(substr($type, 0, 1)) {
307 $vapi = ContentType
::version();
308 $that = $this->withHeader("Accept", "application/vnd.github.v$vapi$type");
310 $that->__data
= null;
316 * Create an iterator over the endpoint's underlying data
318 * @return \seekat\API\Iterator
320 function getIterator() : API\Iterator
{
321 return new API\
Iterator($this);
325 * Count the underlying data's entries
329 function count() : int {
330 return count($this->__data
);
334 * Perform a GET request against the endpoint's underlying URL
336 * @var mixed $args The HTTP query string parameters
337 * @var array $headers The request's additional HTTP headers
338 * @return \React\Promise\ExtendedPromiseInterface
340 function get($args = null, array $headers = null) : ExtendedPromiseInterface
{
341 return $this->__xfer("GET", $args, null, $headers);
345 * Perform a DELETE request against the endpoint's underlying URL
347 * @var mixed $args The HTTP query string parameters
348 * @var array $headers The request's additional HTTP headers
349 * @return \React\Promise\ExtendedPromiseInterface
351 function delete($args = null, array $headers = null) : ExtendedPromiseInterface
{
352 return $this->__xfer("DELETE", $args, null, $headers);
356 * Perform a POST request against the endpoint's underlying URL
358 * @var mixed $body The HTTP message's body
359 * @var mixed $args The HTTP query string parameters
360 * @var array $headers The request's additional HTTP headers
361 * @return \React\Promise\ExtendedPromiseInterface
363 function post($body = null, $args = null, array $headers = null) : ExtendedPromiseInterface
{
364 return $this->__xfer("POST", $args, $body, $headers);
368 * Perform a PUT request against the endpoint's underlying URL
370 * @var mixed $body The HTTP message's body
371 * @var mixed $args The HTTP query string parameters
372 * @var array $headers The request's additional HTTP headers
373 * @return \React\Promise\ExtendedPromiseInterface
375 function put($body = null, $args = null, array $headers = null) : ExtendedPromiseInterface
{
376 return $this->__xfer("PUT", $args, $body, $headers);
380 * Perform a PATCH request against the endpoint's underlying URL
382 * @var mixed $body The HTTP message's body
383 * @var mixed $args The HTTP query string parameters
384 * @var array $headers The request's additional HTTP headers
385 * @return \React\Promise\ExtendedPromiseInterface
387 function patch($body = null, $args = null, array $headers = null) : ExtendedPromiseInterface
{
388 return $this->__xfer("PATCH", $args, $body, $headers);
392 * Accessor to any hypermedia links
394 * @return null|\seekat\API\Links
397 return $this->__links
;
401 * Perform a GET request against the link's "first" relation
403 * @return \React\Promise\ExtendedPromiseInterface
405 function first() : ExtendedPromiseInterface
{
406 if ($this->links() && ($first = $this->links()->getFirst())) {
407 return $this->withUrl($first)->get();
409 return reject($this->links());
413 * Perform a GET request against the link's "prev" relation
415 * @return \React\Promise\ExtendedPromiseInterface
417 function prev() : ExtendedPromiseInterface
{
418 if ($this->links() && ($prev = $this->links()->getPrev())) {
419 return $this->withUrl($prev)->get();
421 return reject($this->links());
425 * Perform a GET request against the link's "next" relation
427 * @return \React\Promise\ExtendedPromiseInterface
429 function next() : ExtendedPromiseInterface
{
430 if ($this->links() && ($next = $this->links()->getNext())) {
431 return $this->withUrl($next)->get();
433 return reject($this->links());
437 * Perform a GET request against the link's "last" relation
439 * @return \React\Promise\ExtendedPromiseInterface
441 function last() : ExtendedPromiseInterface
{
442 if ($this->links() && ($last = $this->links()->getLast())) {
443 return $this->withUrl($last)->get();
445 return reject($this->links());
449 * Perform all queued HTTP transfers
451 * @return \seekat\API self
453 function send() : API
{
454 $this->__log
->debug(__FUNCTION__
.": start loop");
455 while (count($this->__client
)) {
456 $this->__client
->send();
458 $this->__log
->debug(__FUNCTION__
.": end loop");
463 * Run the send loop through a generator
465 * @param callable|\Generator $cbg A \Generator or a factory of a \Generator yielding promises
466 * @return \React\Promise\ExtendedPromiseInterface The promise of the generator's return value
468 function __invoke($cbg) : ExtendedPromiseInterface
{
469 $this->__log
->debug(__FUNCTION__
);
471 $invoker = new API\
Invoker($this->__client
);
473 if ($cbg instanceof \Generator
) {
474 return $invoker->iterate($cbg)->promise();
477 if (is_callable($cbg)) {
478 return $invoker->invoke(function() use($cbg) {
483 throw \
InvalidArgumentException(
484 "Expected callable or Generator, got ".(
486 ?
"instance of ".get_class($cbg)
487 : gettype($cbg).": ".var_export($cbg, true)
493 * Check for a specific key in the endpoint's underlying data
499 function exists($seg, &$val = null) : bool {
500 if (is_array($this->__data
) && array_key_exists($seg, $this->__data
)) {
501 $val = $this->__data
[$seg];
503 } elseif (is_object($this->__data
) && property_exists($this->__data
, $seg)) {
504 $val = $this->__data
->$seg;
511 $this->__log
->debug(__FUNCTION__
."($seg) in ".(
512 is_object($this->__data
)
513 ?
get_class($this->__data
)
514 : gettype($this->__data
)
520 "url" => (string) $this->__url
,
528 * Queue the actual HTTP transfer through \seekat\API\Deferred and return the promise
530 * @var string $method The HTTP request method
531 * @var mixed $args The HTTP query string parameters
532 * @var mixed $body Thee HTTP message's body
533 * @var array $headers The request's additional HTTP headers
534 * @return \React\Promise\ExtendedPromiseInterface
536 private function __xfer(string $method, $args = null, $body = null, array $headers = null) : ExtendedPromiseInterface
{
537 if (isset($this->__data
)) {
538 $this->__log
->debug(__FUNCTION__
."($method) -> resolve", [
539 "url" => (string) $this->__url
,
542 "headers" => $headers,
545 return resolve($this);
548 $url = $this->__url
->mod(["query" => new QueryString($args)]);
549 $request = new Request($method, $url, ((array) $headers) +
$this->__headers
,
550 $body = is_array($body) ?
json_encode($body) : (
551 is_resource($body) ?
new Body($body) : (
552 is_scalar($body) ?
(new Body
)->append($body) :
555 $this->__log
->info(__FUNCTION__
."($method) -> request", [
556 "url" => (string) $this->__url
,
557 "args" => $this->__url
->query
,
559 "headers" => $headers,
562 return (new API\
Call($this, $this->__client
, $request))->promise();