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