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