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