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