compatibility with 2.6.0 and 3.1.0
[m6w6/seekat] / lib / API.php
1 <?php
2
3 namespace seekat;
4
5 use Countable;
6 use Exception;
7 use Generator;
8 use http\{
9 Client,
10 Client\Request,
11 Client\Response,
12 Header,
13 Message\Body,
14 QueryString,
15 Url
16 };
17 use InvalidArgumentException;
18 use IteratorAggregate;
19 use Psr\Log\{
20 LoggerInterface,
21 NullLogger
22 };
23 use seekat\{
24 API\Call,
25 API\ContentType,
26 API\Invoker,
27 API\Iterator,
28 API\Links,
29 Exception\RequestException
30 };
31 use React\Promise\{
32 ExtendedPromiseInterface,
33 function reject,
34 function resolve
35 };
36 use Throwable;
37 use UnexpectedValueException;
38
39 class API implements IteratorAggregate, Countable {
40 /**
41 * The current API endpoint URL
42 * @var Url
43 */
44 private $__url;
45
46 /**
47 * Logger
48 * @var LoggerInterface
49 */
50 private $__log;
51
52 /**
53 * The HTTP client
54 * @var Client
55 */
56 private $__client;
57
58 /**
59 * Default headers to send out to the API endpoint
60 * @var array
61 */
62 private $__headers;
63
64 /**
65 * Current endpoint data's Content-Type
66 * @var Header
67 */
68 private $__type;
69
70 /**
71 * Current endpoint's data
72 * @var array|object
73 */
74 private $__data;
75
76 /**
77 * Current endpoints links
78 * @var Links
79 */
80 private $__links;
81
82 /**
83 * Create a new API endpoint root
84 *
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
89 */
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"
96 ];
97 }
98
99 /**
100 * Ascend one level deep into the API endpoint
101 *
102 * @param string|int $seg The "path" element to ascend into
103 * @return API Endpoint clone referring to {$parent}/{$seg}
104 */
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);
110 } else {
111 $that = clone $this;
112 $that->__url->path .= "/".urlencode($seg);
113 $this->exists($seg, $that->__data);
114 }
115
116 $this->__log->debug(__FUNCTION__."($seg)", [
117 "url" => [
118 (string) $this->__url,
119 (string) $that->__url
120 ],
121 ]);
122
123 return $that;
124 }
125
126 /**
127 * Call handler that actually queues a data fetch and returns a promise
128 *
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
132 */
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.
138 */
139 if ($method === "then") {
140 return $this->get()->then(...$args);
141 }
142
143 /*
144 * very short-hand version:
145 * ->users->m6w6->gists->get()->then(...)
146 * vs:
147 * ->users->m6w6->gists(...)
148 */
149 if (is_callable(current($args))) {
150 return $this->$method->get()->then(current($args));
151 }
152
153 /* standard access */
154 if ($this->exists($method)) {
155 return $this->$method->get(...$args);
156 }
157
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();
162 } else {
163 $message = $error;
164 $error = new Exception($error);
165 }
166 if ($this->exists($method."_url", $url)) {
167
168 $this->__log->info(__FUNCTION__."($method): ". $message, [
169 "url" => (string) $this->__url
170 ]);
171
172 $url = new Url(uri_template($url, (array) current($args)));
173 return $this->withUrl($url)->get(...$args);
174 }
175
176 $this->__log->error(__FUNCTION__."($method): ". $message, [
177 "url" => (string) $this->__url
178 ]);
179
180 throw $error;
181 });
182 }
183
184 /**
185 * Clone handler ensuring the underlying url will be cloned, too
186 */
187 function __clone() {
188 $this->__url = clone $this->__url;
189 }
190
191 /**
192 * The string handler for the endpoint's data
193 *
194 * @return string
195 */
196 function __toString() : string {
197 if (is_scalar($this->__data)) {
198 return (string) $this->__data;
199 }
200
201 /* FIXME */
202 return json_encode($this->__data);
203 }
204
205 /**
206 * Import handler for the endpoint's underlying data
207 *
208 * \seekat\Call will call this when the request will have finished.
209 *
210 * @param Response $response
211 * @return API self
212 * @throws UnexpectedValueException
213 * @throws RequestException
214 * @throws \Exception
215 */
216 function import(Response $response) : API {
217 $this->__log->info(__FUNCTION__.": ". $response->getInfo(), [
218 "url" => (string) $this->__url
219 ]);
220
221 if ($response->getResponseCode() >= 400) {
222 $e = new RequestException($response);
223
224 $this->__log->critical(__FUNCTION__.": ".$e->getMessage(), [
225 "url" => (string) $this->__url,
226 ]);
227
228 throw $e;
229 }
230
231 if (!($type = $response->getHeader("Content-Type", Header::class))) {
232 $e = new RequestException($response);
233 $this->__log->error(
234 __FUNCTION__.": Empty Content-Type -> ".$e->getMessage(), [
235 "url" => (string) $this->__url,
236 ]);
237 throw $e;
238 }
239
240 try {
241 $this->__type = new ContentType($type);
242 $this->__data = $this->__type->parseBody($response->getBody());
243
244 if (($link = $response->getHeader("Link", Header::class))) {
245 $this->__links = new Links($link);
246 }
247 } catch (\Exception $e) {
248 $this->__log->error(__FUNCTION__.": ".$e->getMessage(), [
249 "url" => (string) $this->__url
250 ]);
251
252 throw $e;
253 }
254
255 return $this;
256 }
257
258 /**
259 * Export the endpoint's underlying data
260 *
261 * @param
262 * @return mixed
263 */
264 function export(&$type = null) {
265 $type = clone $this->__type;
266 return $this->__data;
267 }
268
269 /**
270 * Create a copy of the endpoint with specific data
271 *
272 * @param mixed $data
273 * @return API clone
274 */
275 function withData($data) : API {
276 $that = clone $this;
277 $that->__data = $data;
278 return $that;
279 }
280
281 /**
282 * Create a copy of the endpoint with a specific Url, but with data reset
283 *
284 * @param Url $url
285 * @return API clone
286 */
287 function withUrl(Url $url) : API {
288 $that = $this->withData(null);
289 $that->__url = $url;
290 return $that;
291 }
292
293 /**
294 * Create a copy of the endpoint with a specific header added/replaced
295 *
296 * @param string $name
297 * @param mixed $value
298 * @return API clone
299 */
300 function withHeader(string $name, $value) : API {
301 $that = clone $this;
302 if (isset($value)) {
303 $that->__headers[$name] = $value;
304 } else {
305 unset($that->__headers[$name]);
306 }
307 return $that;
308 }
309
310 /**
311 * Create a copy of the endpoint with a customized accept header
312 *
313 * Changes the returned endpoint's accept header to "application/vnd.github.v3.{$type}"
314 *
315 * @param string $type The expected return data type, e.g. "raw", "html", etc.
316 * @param bool $keepdata Whether to keep already fetched data
317 * @return API clone
318 */
319 function as(string $type, bool $keepdata = true) : API {
320 switch(substr($type, 0, 1)) {
321 case "+":
322 case ".":
323 case "":
324 break;
325 default:
326 $type = ".$type";
327 break;
328 }
329 $vapi = ContentType::version();
330 $that = $this->withHeader("Accept", "application/vnd.github.v$vapi$type");
331 if (!$keepdata) {
332 $that->__data = null;
333 }
334 return $that;
335 }
336
337 /**
338 * Create an iterator over the endpoint's underlying data
339 *
340 * @return Iterator
341 */
342 function getIterator() : Iterator {
343 return new Iterator($this);
344 }
345
346 /**
347 * Count the underlying data's entries
348 *
349 * @return int
350 */
351 function count() : int {
352 return count($this->__data);
353 }
354
355 /**
356 * Perform a GET request against the endpoint's underlying URL
357 *
358 * @param mixed $args The HTTP query string parameters
359 * @param array $headers The request's additional HTTP headers
360 * @return ExtendedPromiseInterface
361 */
362 function get($args = null, array $headers = null) : ExtendedPromiseInterface {
363 return $this->__xfer("GET", $args, null, $headers);
364 }
365
366 /**
367 * Perform a DELETE request against the endpoint's underlying URL
368 *
369 * @param mixed $args The HTTP query string parameters
370 * @param array $headers The request's additional HTTP headers
371 * @return ExtendedPromiseInterface
372 */
373 function delete($args = null, array $headers = null) : ExtendedPromiseInterface {
374 return $this->__xfer("DELETE", $args, null, $headers);
375 }
376
377 /**
378 * Perform a POST request against the endpoint's underlying URL
379 *
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
384 */
385 function post($body = null, $args = null, array $headers = null) : ExtendedPromiseInterface {
386 return $this->__xfer("POST", $args, $body, $headers);
387 }
388
389 /**
390 * Perform a PUT request against the endpoint's underlying URL
391 *
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
396 */
397 function put($body = null, $args = null, array $headers = null) : ExtendedPromiseInterface {
398 return $this->__xfer("PUT", $args, $body, $headers);
399 }
400
401 /**
402 * Perform a PATCH request against the endpoint's underlying URL
403 *
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
408 */
409 function patch($body = null, $args = null, array $headers = null) : ExtendedPromiseInterface {
410 return $this->__xfer("PATCH", $args, $body, $headers);
411 }
412
413 /**
414 * Accessor to any hypermedia links
415 *
416 * @return null|Links
417 */
418 function links() {
419 return $this->__links;
420 }
421
422 /**
423 * Perform a GET request against the link's "first" relation
424 *
425 * @return ExtendedPromiseInterface
426 */
427 function first() : ExtendedPromiseInterface {
428 if ($this->links() && ($first = $this->links()->getFirst())) {
429 return $this->withUrl($first)->get();
430 }
431 return reject($this->links());
432 }
433
434 /**
435 * Perform a GET request against the link's "prev" relation
436 *
437 * @return ExtendedPromiseInterface
438 */
439 function prev() : ExtendedPromiseInterface {
440 if ($this->links() && ($prev = $this->links()->getPrev())) {
441 return $this->withUrl($prev)->get();
442 }
443 return reject($this->links());
444 }
445
446 /**
447 * Perform a GET request against the link's "next" relation
448 *
449 * @return ExtendedPromiseInterface
450 */
451 function next() : ExtendedPromiseInterface {
452 if ($this->links() && ($next = $this->links()->getNext())) {
453 return $this->withUrl($next)->get();
454 }
455 return reject($this->links());
456 }
457
458 /**
459 * Perform a GET request against the link's "last" relation
460 *
461 * @return ExtendedPromiseInterface
462 */
463 function last() : ExtendedPromiseInterface {
464 if ($this->links() && ($last = $this->links()->getLast())) {
465 return $this->withUrl($last)->get();
466 }
467 return reject($this->links());
468 }
469
470 /**
471 * Perform all queued HTTP transfers
472 *
473 * @return API self
474 */
475 function send() : API {
476 $this->__log->debug(__FUNCTION__.": start loop");
477 while (count($this->__client)) {
478 $this->__client->send();
479 }
480 $this->__log->debug(__FUNCTION__.": end loop");
481 return $this;
482 }
483
484 /**
485 * Run the send loop through a generator
486 *
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
490 */
491 function __invoke($cbg) : ExtendedPromiseInterface {
492 $this->__log->debug(__FUNCTION__);
493
494 $invoker = new Invoker($this->__client);
495
496 if ($cbg instanceof Generator) {
497 return $invoker->iterate($cbg)->promise();
498 }
499
500 if (is_callable($cbg)) {
501 return $invoker->invoke(function() use($cbg) {
502 return $cbg($this);
503 })->promise();
504 }
505
506 throw InvalidArgumentException(
507 "Expected callable or Generator, got ".(
508 is_object($cbg)
509 ? "instance of ".get_class($cbg)
510 : gettype($cbg).": ".var_export($cbg, true)
511 )
512 );
513 }
514
515 /**
516 * Check for a specific key in the endpoint's underlying data
517 *
518 * @param string $seg
519 * @param mixed &$val
520 * @return bool
521 */
522 function exists($seg, &$val = null) : bool {
523 if (is_array($this->__data) && array_key_exists($seg, $this->__data)) {
524 $val = $this->__data[$seg];
525 $exists = true;
526 } elseif (is_object($this->__data) && property_exists($this->__data, $seg)) {
527 $val = $this->__data->$seg;
528 $exists = true;
529 } else {
530 $val = null;
531 $exists = false;
532 }
533
534 $this->__log->debug(__FUNCTION__."($seg) in ".(
535 is_object($this->__data)
536 ? get_class($this->__data)
537 : gettype($this->__data)
538 )." -> ".(
539 $exists
540 ? "true"
541 : "false"
542 ), [
543 "url" => (string) $this->__url,
544 "val" => $val,
545 ]);
546
547 return $exists;
548 }
549
550 /**
551 * Queue the actual HTTP transfer through \seekat\API\Deferred and return the promise
552 *
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
558 */
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,
563 "args" => $args,
564 "body" => $body,
565 "headers" => $headers,
566 ]);
567
568 return resolve($this);
569 }
570
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) :
576 $body)));
577
578 $this->__log->info(__FUNCTION__."($method) -> request", [
579 "url" => (string) $this->__url,
580 "args" => $this->__url->query,
581 "body" => $body,
582 "headers" => $headers,
583 ]);
584
585 return (new Call($this, $this->__client, $request))->promise();
586 }
587 }