PHP8
[m6w6/seekat] / lib / API.php
1 <?php
2
3 namespace seekat;
4
5 use Countable;
6 use Generator;
7 use http\{Client, Client\Request, Message\Body, QueryString, Url};
8 use Iterator;
9 use IteratorAggregate;
10 use Psr\Log\{LoggerInterface, NullLogger};
11 use seekat\API\{Call, Consumer, ContentType, Future, Links};
12 use seekat\Exception\InvalidArgumentException;
13
14 class API implements IteratorAggregate, Countable {
15 /**
16 * API version
17 * @var int
18 */
19 private $version = 3;
20
21 /**
22 * The current API endpoint URL
23 * @var Url
24 */
25 private $url;
26
27 /**
28 * Default headers to send out to the API endpoint
29 * @var array
30 */
31 private $headers;
32
33 /**
34 * Current endpoints links
35 * @var Links
36 */
37 private $links;
38
39 /**
40 * Current endpoint data's Content-Type
41 * @var API\ContentType
42 */
43 private $type;
44
45 /**
46 * Current endpoint's data
47 * @var array|object
48 */
49 private $data;
50
51 /**
52 * Logger
53 * @var LoggerInterface
54 */
55 private $logger;
56
57 /**
58 * Cache
59 * @var Call\Cache\Service
60 */
61 private $cache;
62
63 /**
64 * Promisor
65 * @var Future
66 */
67 private $future;
68
69 /**
70 * The HTTP client
71 * @var Client
72 */
73 private $client;
74
75 /**
76 * Create a new API endpoint root
77 *
78 * @codeCoverageIgnore
79 *
80 * @param Future $future pretending to fulfill promises
81 * @param array $headers Standard request headers, defaults to ["Accept" => "application/vnd.github.v3+json"]
82 * @param Url $url The API's endpoint, defaults to https://api.github.com
83 * @param Client $client The HTTP client to use for executing requests
84 * @param LoggerInterface $log A logger
85 * @param Call\Cache\Service $cache A cache
86 */
87 function __construct(Future $future, array $headers = null, Url $url = null, Client $client = null, LoggerInterface $log = null, Call\Cache\Service $cache = null) {
88 $this->future = $future;
89 $this->cache = $cache;
90 $this->logger = $log ?? new NullLogger;
91 $this->url = $url ?? new Url("https://api.github.com");
92 $this->client = $client ?? new Client;
93 $this->type = new ContentType($this->version, "json");
94 $this->headers = (array) $headers + [
95 "Accept" => $this->type->getContentType()
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->logger->debug("get($seg)", [
117 "url" => [
118 (string) $this->url,
119 (string) $that->url
120 ],
121 "data" => $that->data
122 ]);
123
124 return $that;
125 }
126
127 /**
128 * Call handler that actually queues a data fetch and returns a promise
129 *
130 * @param string $method The API's "path" element to ascend into
131 * @param array $args Array of arguments forwarded to \seekat\API::get()
132 * @return mixed promise
133 */
134 function __call(string $method, array $args) {
135 /* We cannot implement an explicit then() method,
136 * because the Promise implementation might think
137 * we're actually implementing Thenable,
138 * which might cause an infinite loop.
139 */
140 if ($method === "then"
141 /*
142 * very short-hand version:
143 * ->users->m6w6->gists->get()->then(...)
144 * vs:
145 * ->users->m6w6->gists(...)
146 */
147 || is_callable(current($args))) {
148 return $this->future->handlePromise($this->get(), ...$args);
149 }
150
151 return (new Call($this, $method))($args);
152 }
153
154 /**
155 * Run the send loop through a generator
156 *
157 * @param callable|Generator $cbg A \Generator or a factory of a \Generator yielding promises
158 * @return mixed The promise of the generator's return value
159 * @throws InvalidArgumentException
160 */
161 function __invoke($cbg) {
162 $this->logger->debug(__FUNCTION__);
163
164 $consumer = new Consumer($this->getFuture(), function() {
165 $this->client->send();
166 });
167
168 invoke:
169 if ($cbg instanceof Generator) {
170 return $consumer($cbg);
171 }
172
173 if (is_callable($cbg)) {
174 $cbg = $cbg($this);
175 goto invoke;
176 }
177
178 throw new InvalidArgumentException(
179 "Expected callable or Generator, got ".typeof($cbg, true)
180 );
181 }
182
183 /**
184 * Clone handler ensuring the underlying url will be cloned, too
185 */
186 function __clone() {
187 $this->url = clone $this->url;
188 }
189
190 /**
191 * The string handler for the endpoint's data
192 *
193 * @return string
194 */
195 function __toString() : string {
196 return (string) $this->type->encode($this->data);
197 }
198
199 /**
200 * Create an iterator over the endpoint's underlying data
201 *
202 * @return Iterator
203 */
204 function getIterator() : Iterator {
205 foreach ($this->data as $key => $cur) {
206 if ($this->__get($key)->exists("url", $url)) {
207 $url = new Url($url);
208 $val = $this->withUrl($url)->withData($cur);
209 } else {
210 $val = $this->__get($key)->withData($cur);
211 }
212 yield $key => $val;
213 }
214 }
215
216 /**
217 * Count the underlying data's entries
218 *
219 * @return int
220 */
221 function count() : int {
222 if (is_array($this->data)) {
223 $count = count($this->data);
224 } else if ($this->data instanceof Countable) {
225 $count = count($this->data);
226 } else if (is_object($this->data)) {
227 $count = count((array) $this->data);
228 } else {
229 $count = 0;
230 }
231 $this->logger->debug("count()", [
232 "of type" => typeof($this->data),
233 "count" => $count
234 ]);
235 return $count;
236 }
237
238 /**
239 * @return Url
240 */
241 function getUrl() : Url {
242 return $this->url;
243 }
244
245 /**
246 * @return LoggerInterface
247 */
248 function getLogger() : LoggerInterface {
249 return $this->logger;
250 }
251
252 /**
253 * @return Future
254 */
255 function getFuture() {
256 return $this->future;
257 }
258
259 /**
260 * @return Client
261 */
262 public function getClient(): Client {
263 return $this->client;
264 }
265
266 /**
267 * @return array|object
268 */
269 function getData() {
270 return $this->data;
271 }
272
273 /**
274 * Accessor to any hypermedia links
275 *
276 * @return null|Links
277 */
278 function getLinks() {
279 return $this->links;
280 }
281
282 /**
283 * @return int
284 */
285 function getVersion() : int {
286 return $this->version;
287 }
288
289 /**
290 * Export the endpoint's underlying data
291 *
292 * @return array ["url", "data", "type", "links", "headers"]
293 */
294 function export() : array {
295 $data = $this->data;
296 $url = clone $this->url;
297 $type = $this->type ? clone $this->type : null;
298 $links = $this->links ? clone $this->links : null;
299 $headers = $this->headers;
300 return compact("url", "data", "type", "links", "headers");
301 }
302
303 /**
304 * @param $export
305 * @return API
306 */
307 function with($export) : API {
308 $that = clone $this;
309 if (is_array($export) || ($export instanceof \ArrayAccess)) {
310 isset($export["url"]) && $that->url = $export["url"];
311 isset($export["data"]) && $that->data = $export["data"];
312 isset($export["type"]) && $that->type = $export["type"];
313 isset($export["links"]) && $that->links = $export["links"];
314 isset($export["headers"]) && $that->headers = $export["headers"];
315 }
316 return $that;
317 }
318
319 /**
320 * Create a copy of the endpoint with specific data
321 *
322 * @param mixed $data
323 * @return API clone
324 */
325 function withData($data) : API {
326 $that = clone $this;
327 $that->data = $data;
328 return $that;
329 }
330
331 /**
332 * Create a copy of the endpoint with a specific Url, but with data reset
333 *
334 * @param Url $url
335 * @return API clone
336 */
337 function withUrl(Url $url) : API {
338 $that = clone $this;
339 $that->url = $url;
340 $that->data = null;
341 #$that->links = null;
342 return $that;
343 }
344
345 /**
346 * Create a copy of the endpoint with a specific header added/replaced
347 *
348 * @param string $name
349 * @param mixed $value
350 * @return API clone
351 */
352 function withHeader(string $name, $value) : API {
353 $that = clone $this;
354 if (isset($value)) {
355 $that->headers[$name] = $value;
356 } else {
357 unset($that->headers[$name]);
358 }
359 return $that;
360 }
361
362 /**
363 * Create a copy of the endpoint with a customized accept header
364 *
365 * Changes the returned endpoint's accept header to "application/vnd.github.v3.{$type}"
366 *
367 * @param string $type The expected return data type, e.g. "raw", "html", ..., or a complete content type
368 * @param bool $keepdata Whether to keep already fetched data
369 * @return API clone
370 */
371 function as(string $type, bool $keepdata = true) : API {
372 $ct = new ContentType($this->version, $type);
373
374 $that = $ct->apply($this);
375 $that->type = $ct;
376
377 if (!$keepdata) {
378 $that->data = null;
379 }
380 return $that;
381 }
382
383 /**
384 * Perform a HEAD request against the endpoint's underlying URL
385 *
386 * @param mixed $args The HTTP query string parameters
387 * @param array $headers The request's additional HTTP headers
388 * @return mixed promise
389 */
390 function head($args = null, array $headers = null, $cache = null) {
391 return $this->request("HEAD", $args, null, $headers, $cache);
392 }
393
394 /**
395 * Perform a GET request against the endpoint's underlying URL
396 *
397 * @param mixed $args The HTTP query string parameters
398 * @param array $headers The request's additional HTTP headers
399 * @return mixed promise
400 */
401 function get($args = null, array $headers = null, $cache = null) {
402 return $this->request("GET", $args, null, $headers, $cache);
403 }
404
405 /**
406 * Perform a DELETE request against the endpoint's underlying URL
407 *
408 * @param mixed $args The HTTP query string parameters
409 * @param array $headers The request's additional HTTP headers
410 * @return mixed promise
411 */
412 function delete($args = null, array $headers = null) {
413 return $this->request("DELETE", $args, null, $headers);
414 }
415
416 /**
417 * Perform a POST request against the endpoint's underlying URL
418 *
419 * @param mixed $body The HTTP message's body
420 * @param mixed $args The HTTP query string parameters
421 * @param array $headers The request's additional HTTP headers
422 * @return mixed promise
423 */
424 function post($body = null, $args = null, array $headers = null) {
425 return $this->request("POST", $args, $body, $headers);
426 }
427
428 /**
429 * Perform a PUT request against the endpoint's underlying URL
430 *
431 * @param mixed $body The HTTP message's body
432 * @param mixed $args The HTTP query string parameters
433 * @param array $headers The request's additional HTTP headers
434 * @return mixed promise
435 */
436 function put($body = null, $args = null, array $headers = null) {
437 return $this->request("PUT", $args, $body, $headers);
438 }
439
440 /**
441 * Perform a PATCH request against the endpoint's underlying URL
442 *
443 * @param mixed $body The HTTP message's body
444 * @param mixed $args The HTTP query string parameters
445 * @param array $headers The request's additional HTTP headers
446 * @return mixed promise
447 */
448 function patch($body = null, $args = null, array $headers = null) {
449 return $this->request("PATCH", $args, $body, $headers);
450 }
451
452 /**
453 * Perform all queued HTTP transfers
454 *
455 * @return API self
456 */
457 function send() : API {
458 $this->logger->debug("send: start loop");
459 while (count($this->client)) {
460 $this->client->send();
461 }
462 $this->logger->debug("send: end loop");
463 return $this;
464 }
465
466 /**
467 * Check for a specific key in the endpoint's underlying data
468 *
469 * @param string $seg
470 * @param mixed &$val
471 * @return bool
472 */
473 function exists($seg, &$val = null) : bool {
474 if (is_array($this->data) && array_key_exists($seg, $this->data)) {
475 $val = $this->data[$seg];
476 $exists = true;
477 } elseif (is_object($this->data) && property_exists($this->data, $seg)) {
478 $val = $this->data->$seg;
479 $exists = true;
480 } else {
481 $val = null;
482 $exists = false;
483 }
484
485 $this->logger->debug(sprintf("exists(%s) in %s -> %s",
486 $seg, typeof($this->data, false), $exists ? "true" : "false"
487 ), [
488 "url" => (string) $this->url,
489 "val" => typeof($val, false),
490 ]);
491
492 return $exists;
493 }
494
495 /**
496 * Queue the actual HTTP transfer through \seekat\API\Deferred and return the promise
497 *
498 * @param string $method The HTTP request method
499 * @param mixed $args The HTTP query string parameters
500 * @param mixed $body Thee HTTP message's body
501 * @param array $headers The request's additional HTTP headers
502 * @param Call\Cache\Service $cache
503 * @return mixed promise
504 */
505 private function request(string $method, $args = null, $body = null, array $headers = null, Call\Cache\Service $cache = null) {
506 if (isset($this->data)) {
507 $this->logger->debug("request -> resolve", [
508 "method" => $method,
509 "url" => (string) $this->url,
510 "args" => $args,
511 "body" => $body,
512 "headers" => $headers,
513 ]);
514
515 return Future\resolve($this->future, $this);
516 }
517
518 $url = $this->url->mod(["query" => new QueryString($args)]);
519 $request = new Request($method, $url, ((array) $headers) + $this->headers,
520 $body = $this->type->encode(is_resource($body) ? new Body($body) : $body));
521
522 $this->logger->info("request -> deferred", [
523 "method" => $method,
524 "url" => (string) $this->url,
525 "args" => $this->url->query,
526 "body" => $body,
527 "headers" => $headers,
528 ]);
529
530 return (new Call\Deferred($this, $request, $cache ?: $this->cache))();
531 }
532 }