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