546dd88da0441c35332c504e5eba3158899c684a
[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 * Logger
29 * @var LoggerInterface
30 */
31 private $logger;
32
33 /**
34 * Cache
35 * @var Call\Cache\Service
36 */
37 private $cache;
38
39 /**
40 * Promisor
41 * @var Future
42 */
43 private $future;
44
45 /**
46 * The HTTP client
47 * @var Client
48 */
49 private $client;
50
51 /**
52 * Default headers to send out to the API endpoint
53 * @var array
54 */
55 private $headers;
56
57 /**
58 * Current endpoint data's Content-Type
59 * @var API\ContentType
60 */
61 private $type;
62
63 /**
64 * Current endpoint's data
65 * @var array|object
66 */
67 private $data;
68
69 /**
70 * Current endpoints links
71 * @var Links
72 */
73 private $links;
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->client);
169
170 invoke:
171 if ($cbg instanceof Generator) {
172 return $consumer($cbg);
173 }
174
175 if (is_callable($cbg)) {
176 $cbg = $cbg($this);
177 goto invoke;
178 }
179
180 throw new InvalidArgumentException(
181 "Expected callable or Generator, got ".typeof($cbg, true)
182 );
183 }
184
185 /**
186 * Clone handler ensuring the underlying url will be cloned, too
187 */
188 function __clone() {
189 $this->url = clone $this->url;
190 }
191
192 /**
193 * The string handler for the endpoint's data
194 *
195 * @return string
196 */
197 function __toString() : string {
198 if (is_scalar($this->data)) {
199 return (string) $this->data;
200 }
201
202 /* FIXME */
203 return json_encode($this->data);
204 }
205
206 /**
207 * Create an iterator over the endpoint's underlying data
208 *
209 * @return Iterator
210 */
211 function getIterator() : Iterator {
212 return new Iterator($this);
213 }
214
215 /**
216 * Count the underlying data's entries
217 *
218 * @return int
219 */
220 function count() : int {
221 return count($this->data);
222 }
223
224 /**
225 * @return Url
226 */
227 function getUrl() : Url {
228 return $this->url;
229 }
230
231 /**
232 * @return LoggerInterface
233 */
234 function getLogger() : LoggerInterface {
235 return $this->logger;
236 }
237
238 /**
239 * @return Future
240 */
241 function getFuture() {
242 return $this->future;
243 }
244
245 /**
246 * @return Client
247 */
248 public function getClient(): Client {
249 return $this->client;
250 }
251
252 /**
253 * @return array|object
254 */
255 function getData() {
256 return $this->data;
257 }
258
259 /**
260 * Accessor to any hypermedia links
261 *
262 * @return null|Links
263 */
264 function getLinks() {
265 return $this->links;
266 }
267
268 /**
269 * Export the endpoint's underlying data
270 *
271 * @return array ["url", "data", "type", "links", "headers"]
272 */
273 function export() : array {
274 $data = $this->data;
275 $url = clone $this->url;
276 $type = clone $this->type;
277 $links = $this->links ? clone $this->links : null;
278 $headers = $this->headers;
279 return compact("url", "data", "type", "links", "headers");
280 }
281
282 /**
283 * @param $export
284 * @return API
285 */
286 function with($export) : API {
287 $that = clone $this;
288 if (is_array($export) || ($export instanceof \ArrayAccess)) {
289 isset($export["url"]) && $that->url = $export["url"];
290 isset($export["data"]) && $that->data = $export["data"];
291 isset($export["type"]) && $that->type = $export["type"];
292 isset($export["links"]) && $that->links = $export["links"];
293 isset($export["headers"]) && $that->headers = $export["headers"];
294 }
295 return $that;
296 }
297
298 /**
299 * Create a copy of the endpoint with specific data
300 *
301 * @param mixed $data
302 * @return API clone
303 */
304 function withData($data) : API {
305 $that = clone $this;
306 $that->data = $data;
307 return $that;
308 }
309
310 /**
311 * Create a copy of the endpoint with a specific Url, but with data reset
312 *
313 * @param Url $url
314 * @return API clone
315 */
316 function withUrl(Url $url) : API {
317 $that = clone $this;
318 $that->url = $url;
319 $that->data = null;
320 #$that->links = null;
321 return $that;
322 }
323
324 /**
325 * Create a copy of the endpoint with a specific header added/replaced
326 *
327 * @param string $name
328 * @param mixed $value
329 * @return API clone
330 */
331 function withHeader(string $name, $value) : API {
332 $that = clone $this;
333 if (isset($value)) {
334 $that->headers[$name] = $value;
335 } else {
336 unset($that->headers[$name]);
337 }
338 return $that;
339 }
340
341 /**
342 * Create a copy of the endpoint with a customized accept header
343 *
344 * Changes the returned endpoint's accept header to "application/vnd.github.v3.{$type}"
345 *
346 * @param string $type The expected return data type, e.g. "raw", "html", ..., or a complete content type
347 * @param bool $keepdata Whether to keep already fetched data
348 * @return API clone
349 */
350 function as(string $type, bool $keepdata = true) : API {
351 $that = ContentType::apply($this, $type);
352 if (!$keepdata) {
353 $that->data = null;
354 }
355 return $that;
356 }
357
358 /**
359 * Perform a GET request against the endpoint's underlying URL
360 *
361 * @param mixed $args The HTTP query string parameters
362 * @param array $headers The request's additional HTTP headers
363 * @return Promise
364 */
365 function get($args = null, array $headers = null, $cache = null) : Promise {
366 return $this->request("GET", $args, null, $headers, $cache);
367 }
368
369 /**
370 * Perform a DELETE request against the endpoint's underlying URL
371 *
372 * @param mixed $args The HTTP query string parameters
373 * @param array $headers The request's additional HTTP headers
374 * @return Promise
375 */
376 function delete($args = null, array $headers = null) : Promise {
377 return $this->request("DELETE", $args, null, $headers);
378 }
379
380 /**
381 * Perform a POST 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 Promise
387 */
388 function post($body = null, $args = null, array $headers = null) : Promise {
389 return $this->request("POST", $args, $body, $headers);
390 }
391
392 /**
393 * Perform a PUT 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 Promise
399 */
400 function put($body = null, $args = null, array $headers = null) : Promise {
401 return $this->request("PUT", $args, $body, $headers);
402 }
403
404 /**
405 * Perform a PATCH request against the endpoint's underlying URL
406 *
407 * @param mixed $body The HTTP message's body
408 * @param mixed $args The HTTP query string parameters
409 * @param array $headers The request's additional HTTP headers
410 * @return Promise
411 */
412 function patch($body = null, $args = null, array $headers = null) : Promise {
413 return $this->request("PATCH", $args, $body, $headers);
414 }
415
416 /**
417 * Perform all queued HTTP transfers
418 *
419 * @return API self
420 */
421 function send() : API {
422 $this->logger->debug("send: start loop");
423 while (count($this->client)) {
424 $this->client->send();
425 }
426 $this->logger->debug("send: end loop");
427 return $this;
428 }
429
430 /**
431 * Check for a specific key in the endpoint's underlying data
432 *
433 * @param string $seg
434 * @param mixed &$val
435 * @return bool
436 */
437 function exists($seg, &$val = null) : bool {
438 if (is_array($this->data) && array_key_exists($seg, $this->data)) {
439 $val = $this->data[$seg];
440 $exists = true;
441 } elseif (is_object($this->data) && property_exists($this->data, $seg)) {
442 $val = $this->data->$seg;
443 $exists = true;
444 } else {
445 $val = null;
446 $exists = false;
447 }
448
449 $this->logger->debug(sprintf("exists(%s) in %s -> %s",
450 $seg, typeof($this->data, false), $exists ? "true" : "false"
451 ), [
452 "url" => (string) $this->url,
453 "val" => $val,
454 ]);
455
456 return $exists;
457 }
458
459 /**
460 * Queue the actual HTTP transfer through \seekat\API\Deferred and return the promise
461 *
462 * @param string $method The HTTP request method
463 * @param mixed $args The HTTP query string parameters
464 * @param mixed $body Thee HTTP message's body
465 * @param array $headers The request's additional HTTP headers
466 * @param Call\Cache\Service $cache
467 * @return Promise
468 */
469 private function request(string $method, $args = null, $body = null, array $headers = null, Call\Cache\Service $cache = null) : Promise {
470 if (isset($this->data)) {
471 $this->logger->debug("request -> resolve", [
472 "method" => $method,
473 "url" => (string) $this->url,
474 "args" => $args,
475 "body" => $body,
476 "headers" => $headers,
477 ]);
478
479 return Future\resolve($this->future, $this);
480 }
481
482 $url = $this->url->mod(["query" => new QueryString($args)]);
483 $request = new Request($method, $url, ((array) $headers) + $this->headers,
484 $body = is_array($body) ? json_encode($body) : (
485 is_resource($body) ? new Body($body) : (
486 is_scalar($body) ? (new Body)->append($body) :
487 $body)));
488
489 $this->logger->info("request -> deferred", [
490 "method" => $method,
491 "url" => (string) $this->url,
492 "args" => $this->url->query,
493 "body" => $body,
494 "headers" => $headers,
495 ]);
496
497 return (new Call\Deferred($this, $request, $cache ?: $this->cache))();
498 }
499 }