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