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