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