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
;
23 class API
implements \IteratorAggregate
, \Countable
{
25 * The current API endpoint URL
32 * @var \Psr\Log\LoggerInterface
43 * Default headers to send out to the API endpoint
49 * Current endpoint data's Content-Type
55 * Current endpoint's data
61 * Current endpoints links
62 * @var seekat\API\Links
67 * Create a new API endpoint root
69 * @var array $headers Standard request headers, defaults to ["Accept" => "application/vnd.github.v3+json"]
70 * @var \http\Url The API's endpoint, defaults to https://api.github.com
71 * @var \http\Client $client The HTTP client to use for executing requests
72 * @var \Psr\Log\LoggerInterface $log A logger
74 function __construct(array $headers = null, Url
$url = null, Client
$client = null, LoggerInterface
$log = null) {
75 $this->__log
= $log ??
new NullLogger
;
76 $this->__url
= $url ??
new Url("https://api.github.com");
77 $this->__client
= $client ??
new Client
;
78 $this->__headers
= (array) $headers +
[
79 "Accept" => "application/vnd.github.v3+json"
84 * Ascend one level deep into the API endpoint
86 * @var string|int $seg The "path" element to ascend into
87 * @return \seekat\API Endpoint clone referring to {$parent}/{$seg}
89 function __get($seg) : API
{
90 if (substr($seg, -4) === "_url") {
91 $url = new Url(uri_template($this->__data
->$seg));
92 $that = $this->withUrl($url);
93 $seg = basename($that->__url
->path
);
96 $that->__url
->path
.= "/".urlencode($seg);
97 $this->exists($seg, $that->__data
);
100 $this->__log
->debug(__FUNCTION__
."($seg)", [
102 (string) $this->__url
,
103 (string) $that->__url
111 * Call handler that actually queues a data fetch and returns a promise
113 * @var string $method The API's "path" element to ascend into
114 * @var array $args Array of arguments forwarded to \seekat\API::get()
115 * @return \React\Promise\ExtendedPromiseInterface
117 function __call(string $method, array $args) : ExtendedPromiseInterface
{
118 /* We cannot implement an explicit then() method,
119 * because the Promise implementation might think
120 * we're actually implementing Thenable,
121 * which might cause an infite loop.
123 if ($method === "then") {
124 return $this->get()->then(...$args);
128 * very short-hand version:
129 * ->users->m6w6->gists->get()->then(...)
131 * ->users->m6w6->gists(...)
133 if (is_callable(current($args))) {
134 return $this->$method->get()->then(current($args));
137 /* standard access */
138 if ($this->exists($method)) {
139 return $this->$method->get(...$args);
142 /* fetch resource, unless already localized, and try for {$method}_url */
143 return $this->$method->get(...$args)->otherwise(function($error) use($method, $args) {
144 if ($this->exists($method."_url", $url)) {
146 $this->__log
->info(__FUNCTION__
."($method): ". $error->getMessage(), [
147 "url" => (string) $this->__url
150 $url = new Url(uri_template($url, (array) current($args)));
151 return $this->withUrl($url)->get(...$args);
154 $this->__log
->error(__FUNCTION__
."($method): ". $error->getMessage(), [
155 "url" => (string) $this->__url
163 * Clone handler ensuring the underlying url will be cloned, too
166 $this->__url
= clone $this->__url
;
170 * The string handler for the endpoint's data
174 function __toString() : string {
175 if (is_scalar($this->__data
)) {
176 return (string) $this->__data
;
180 return json_encode($this->__data
);
184 * Import handler for the endpoint's underlying data
186 * \seekat\Deferred will call this when the request will have finished.
188 * @var \http\Client\Response $response
189 * @return \seekat\API self
191 function import(Response
$response) : API
{
192 //addcslashes($response, "\0..\40\42\47\134\140\177..\377")
194 $this->__log
->info(__FUNCTION__
.": ". $response->getInfo(), [
195 "url" => (string) $this->__url
198 if ($response->getResponseCode() >= 400) {
199 $e = new RequestException($response);
201 $this->__log
->critical(__FUNCTION__
.": ".$e->getMessage(), [
202 "url" => (string) $this->__url
,
208 if (!($type = $response->getHeader("Content-Type", Header
::class))) {
209 $e = new RequestException($response);
211 __FUNCTION__
.": Empty Content-Type -> ".$e->getMessage(), [
212 "url" => (string) $this->__url
,
218 $this->__type
= new ContentType($type);
219 $this->__data
= $this->__type
->parseBody($response->getBody());
221 if (($link = $response->getHeader("Link", Header
::class))) {
222 $this->__links
= new API\
Links($link);
224 } catch (\Exception
$e) {
225 $this->__log
->error(__FUNCTION__
.": ".$e->getMessage(), [
226 "url" => (string) $this->__url
236 * Export the endpoint's underlying data
240 function export(&$type = null) {
241 $type = clone $this->__type
;
242 return $this->__data
;
246 * Create a copy of the endpoint with specific data
249 * @return \seekat\API clone
251 function withData($data) : API
{
253 $that->__data
= $data;
258 * Create a copy of the endpoint with a specific Url, but with data reset
260 * @var \http\Url $url
261 * @return \seekat\API clone
263 function withUrl(Url
$url) : API
{
264 $that = $this->withData(null);
270 * Create a copy of the endpoint with a specific header added/replaced
274 * @return \seekat\API clone
276 function withHeader(string $name, $value) : API
{
279 $that->__headers
[$name] = $value;
281 unset($that->__headers
[$name]);
287 * Create a copy of the endpoint with a customized accept header
289 * Changes the returned endpoint's accept header to
290 * "application/vnd.github.v3.{$type}"
292 * @var string $type The expected return data type, e.g. "raw", "html", etc.
293 * @var bool $keepdata Whether to keep already fetched data
294 * @return \seekat\API clone
296 function as(string $type, bool $keepdata = true) : API
{
297 switch(substr($type, 0, 1)) {
306 $vapi = ContentType
::version();
307 $that = $this->withHeader("Accept", "application/vnd.github.v$vapi$type");
309 $that->__data
= null;
315 * Create an iterator over the endpoint's underlying data
317 * @return \seekat\API\Iterator
319 function getIterator() : API\Iterator
{
320 return new API\
Iterator($this);
324 * Count the underlying data's entries
328 function count() : int {
329 return count($this->__data
);
333 * Perform a GET request against the endpoint's underlying URL
335 * @var mixed $args The HTTP query string parameters
336 * @var array $headers The request's additional HTTP headers
337 * @return \React\Promise\ExtendedPromiseInterface
339 function get($args = null, array $headers = null) : ExtendedPromiseInterface
{
340 return $this->__xfer("GET", $args, null, $headers);
344 * Perform a DELETE request against the endpoint's underlying URL
346 * @var mixed $args The HTTP query string parameters
347 * @var array $headers The request's additional HTTP headers
348 * @return \React\Promise\ExtendedPromiseInterface
350 function delete($args = null, array $headers = null) : ExtendedPromiseInterface
{
351 return $this->__xfer("DELETE", $args, null, $headers);
355 * Perform a POST request against the endpoint's underlying URL
357 * @var mixed $body The HTTP message's body
358 * @var mixed $args The HTTP query string parameters
359 * @var array $headers The request's additional HTTP headers
360 * @return \React\Promise\ExtendedPromiseInterface
362 function post($body = null, $args = null, array $headers = null) : ExtendedPromiseInterface
{
363 return $this->__xfer("POST", $args, $body, $headers);
367 * Perform a PUT request against the endpoint's underlying URL
369 * @var mixed $body The HTTP message's body
370 * @var mixed $args The HTTP query string parameters
371 * @var array $headers The request's additional HTTP headers
372 * @return \React\Promise\ExtendedPromiseInterface
374 function put($body = null, $args = null, array $headers = null) : ExtendedPromiseInterface
{
375 return $this->__xfer("PUT", $args, $body, $headers);
379 * Perform a PATCH request against the endpoint's underlying URL
381 * @var mixed $body The HTTP message's body
382 * @var mixed $args The HTTP query string parameters
383 * @var array $headers The request's additional HTTP headers
384 * @return \React\Promise\ExtendedPromiseInterface
386 function patch($body = null, $args = null, array $headers = null) : ExtendedPromiseInterface
{
387 return $this->__xfer("PATCH", $args, $body, $headers);
391 * Accessor to any hypermedia links
393 * @return null|\seekat\API\Links
396 return $this->__links
;
400 * Perform a GET request against the link's "first" relation
402 * @return \React\Promise\ExtendedPromiseInterface
404 function first() : ExtendedPromiseInterface
{
405 if ($this->links() && ($first = $this->links()->getFirst())) {
406 return $this->withUrl($first)->get();
408 return reject($this->links());
412 * Perform a GET request against the link's "prev" relation
414 * @return \React\Promise\ExtendedPromiseInterface
416 function prev() : ExtendedPromiseInterface
{
417 if ($this->links() && ($prev = $this->links()->getPrev())) {
418 return $this->withUrl($prev)->get();
420 return reject($this->links());
424 * Perform a GET request against the link's "next" relation
426 * @return \React\Promise\ExtendedPromiseInterface
428 function next() : ExtendedPromiseInterface
{
429 if ($this->links() && ($next = $this->links()->getNext())) {
430 return $this->withUrl($next)->get();
432 return reject($this->links());
436 * Perform a GET request against the link's "last" relation
438 * @return \React\Promise\ExtendedPromiseInterface
440 function last() : ExtendedPromiseInterface
{
441 if ($this->links() && ($last = $this->links()->getLast())) {
442 return $this->withUrl($last)->get();
444 return reject($this->links());
448 * Perform all queued HTTP transfers
450 * @return \seekat\API self
452 function send() : API
{
453 $this->__log
->debug(__FUNCTION__
.": start loop");
454 while (count($this->__client
)) {
455 $this->__client
->send();
457 $this->__log
->debug(__FUNCTION__
.": end loop");
462 * Run the send loop once
464 * @param callable $timeout as function(\seekat\API $api) : float, returning any applicable select timeout
467 function __invoke(callable
$timeout = null) : bool {
468 $this->__log
->debug(__FUNCTION__
);
470 if (count($this->__client
)) {
471 if ($this->__client
->once()) {
473 $timeout = $timeout($this);
476 $this->__log
->debug(__FUNCTION__
.": wait", compact("timeout"));
478 $this->__client
->wait($timeout);
479 return 0 < count($this->__client
);
486 * Check for a specific key in the endpoint's underlying data
492 function exists($seg, &$val = null) : bool {
493 if (is_array($this->__data
) && array_key_exists($seg, $this->__data
)) {
494 $val = $this->__data
[$seg];
496 } elseif (is_object($this->__data
) && property_exists($this->__data
, $seg)) {
497 $val = $this->__data
->$seg;
504 $this->__log
->debug(__FUNCTION__
."($seg) in ".(
505 is_object($this->__data
)
506 ?
get_class($this->__data
)
507 : gettype($this->__data
)
513 "url" => (string) $this->__url
,
521 * Queue the actual HTTP transfer through \seekat\API\Deferred and return the promise
523 * @var string $method The HTTP request method
524 * @var mixed $args The HTTP query string parameters
525 * @var mixed $body Thee HTTP message's body
526 * @var array $headers The request's additional HTTP headers
527 * @return \React\Promise\ExtendedPromiseInterface
529 private function __xfer(string $method, $args = null, $body = null, array $headers = null) : ExtendedPromiseInterface
{
530 if (isset($this->__data
)) {
531 $this->__log
->debug(__FUNCTION__
."($method) -> resolve", [
532 "url" => (string) $this->__url
,
535 "headers" => $headers,
538 return resolve($this);
541 $url = $this->__url
->mod(["query" => new QueryString($args)]);
542 $request = new Request($method, $url, ((array) $headers) +
$this->__headers
,
543 $body = is_array($body) ?
json_encode($body) : (
544 is_resource($body) ?
new Body($body) : (
545 is_scalar($body) ?
(new Body
)->append($body) :
548 $this->__log
->info(__FUNCTION__
."($method) -> request", [
549 "url" => (string) $this->__url
,
550 "args" => $this->__url
->query
,
552 "headers" => $headers,
555 return (new API\
Deferred($this, $this->__client
, $request))->promise();