7 use http\
{Client
, Client\Request
, Message\Body
, QueryString
, Url
};
10 use Psr\Log\
{LoggerInterface
, NullLogger
};
11 use seekat\API\
{Call
, Consumer
, ContentType
, Future
, Links
};
12 use seekat\Exception\InvalidArgumentException
;
14 class API
implements IteratorAggregate
, Countable
{
18 private int $version = 3;
21 * Default headers to send out to the API endpoint
23 private array $headers;
26 * Current endpoints links
28 private ?Links
$links = null;
31 * Current endpoint data's Content-Type
33 private API\ContentType
$type;
36 * Current endpoint's data
38 private mixed $data = null;
41 * Create a new API endpoint root
43 * @param Future $future pretending to fulfill promises
44 * @param array $headers Standard request headers, defaults to ["Accept" => "application/vnd.github.v3+json"]
45 * @param Url $url The API's endpoint, defaults to https://api.github.com
46 * @param Client $client The HTTP client to use for executing requests
47 * @param LoggerInterface $log A logger
48 * @param Call\Cache\Service $cache A cache
50 function __construct(private readonly Future
$future,
51 array $headers = null,
52 private Url
$url = new Url("https://api.github.com"),
53 private readonly Client
$client = new Client
,
54 private readonly LoggerInterface
$logger = new NullLogger
,
55 private readonly Call\Cache\Service
$cache = new Call\Cache\Service\Hollow
) {
56 $this->type
= new ContentType($this->version
, "json");
57 $this->headers
= (array) $headers +
[
58 "Accept" => $this->type
->getContentType()
63 * Ascend one level deep into the API endpoint
65 * @param string|int $seg The "path" element to ascend into
66 * @return API Endpoint clone referring to {$parent}/{$seg}
68 function __get(string|
int $seg) : API
{
69 if (str_ends_with($seg, "_url")) {
70 $url = new Url(uri_template($this->data
->$seg));
71 $that = $this->withUrl($url);
72 $seg = basename($that->url
->path
);
75 $that->url
->path
.= "/".urlencode($seg);
76 $this->exists($seg, $that->data
);
79 $this->logger
->debug("get($seg)", [
91 * Call handler that actually queues a data fetch and returns a promise
93 * @param string $method The API's "path" element to ascend into
94 * @param array $args Array of arguments forwarded to \seekat\API::get()
95 * @return mixed promise
97 function __call(string $method, array $args) {
98 /* We cannot implement an explicit then() method,
99 * because the Promise implementation might think
100 * we're actually implementing Thenable,
101 * which might cause an infinite loop.
103 if ($method === "then"
105 * very short-hand version:
106 * ->users->m6w6->gists->get()->then(...)
108 * ->users->m6w6->gists(...)
110 ||
is_callable(current($args))) {
111 return $this->future
->handlePromise($this->get(), ...$args);
114 return (new Call($this, $method))($args);
118 * Run the send loop through a generator
120 * @param callable|Generator $cbg A \Generator or a factory of a \Generator yielding promises
121 * @return mixed The promise of the generator's return value
122 * @throws InvalidArgumentException
124 function __invoke(callable|Generator
$cbg) {
125 $this->logger
->debug(__METHOD__
, [$cbg]);
127 $consumer = new Consumer($this->getFuture(), function() {
128 $this->client
->send();
132 if ($cbg instanceof Generator
) {
133 return $consumer($cbg);
136 if (is_callable($cbg)) {
141 throw new InvalidArgumentException(
142 "Expected callable or Generator, got ".typeof($cbg, true)
147 * Clone handler ensuring the underlying url will be cloned, too
150 $this->url
= clone $this->url
;
154 * The string handler for the endpoint's data
158 function __toString() : string {
159 return (string) $this->type
->encode($this->data
);
163 * Create an iterator over the endpoint's underlying data
167 function getIterator() : Iterator
{
168 foreach ($this->data
as $key => $cur) {
169 if ($this->__get($key)->exists("url", $url)) {
170 $url = new Url($url);
171 $val = $this->withUrl($url)->withData($cur);
173 $val = $this->__get($key)->withData($cur);
180 * Count the underlying data's entries
184 function count() : int {
185 if (is_array($this->data
)) {
186 $count = count($this->data
);
187 } else if ($this->data
instanceof Countable
) {
188 $count = count($this->data
);
189 } else if (is_object($this->data
)) {
190 $count = count((array) $this->data
);
194 $this->logger
->debug("count()", [
195 "of type" => typeof($this->data
),
201 function getUrl() : Url
{
205 function getLogger() : LoggerInterface
{
206 return $this->logger
;
209 function getFuture() : Future
{
210 return $this->future
;
213 public function getClient(): Client
{
214 return $this->client
;
217 public function getCache() : Call\Cache\Service
{
221 function getData() : mixed {
226 * Accessor to any hypermedia links
230 function getLinks() : ?Links
{
237 function getVersion() : int {
238 return $this->version
;
242 * Export the endpoint's underlying data
244 * @return array ["url", "data", "type", "links", "headers"]
246 function export() : array {
248 $url = clone $this->url
;
249 $type = clone $this->type
;
250 $links = $this->links ?
clone $this->links
: null;
251 $headers = $this->headers
;
252 return compact("url", "data", "type", "links", "headers");
259 function with($export) : API
{
261 if (is_array($export) ||
($export instanceof \ArrayAccess
)) {
262 isset($export["url"]) && $that->url
= $export["url"];
263 isset($export["data"]) && $that->data
= $export["data"];
264 isset($export["type"]) && $that->type
= $export["type"];
265 isset($export["links"]) && $that->links
= $export["links"];
266 isset($export["headers"]) && $that->headers
= $export["headers"];
272 * Create a copy of the endpoint with specific data
277 function withData(mixed $data) : API
{
284 * Create a copy of the endpoint with a specific Url, but with data reset
289 function withUrl(Url
$url) : API
{
293 #$that->links = null;
298 * Create a copy of the endpoint with a specific header added/replaced
300 * @param string $name
301 * @param mixed $value
304 function withHeader(string $name, mixed $value) : API
{
307 $that->headers
[$name] = $value;
309 unset($that->headers
[$name]);
315 * Create a copy of the endpoint with a customized accept header
317 * Changes the returned endpoint's accept header to "application/vnd.github.v3.{$type}"
319 * @param string $type The expected return data type, e.g. "raw", "html", ..., or a complete content type
320 * @param bool $keepdata Whether to keep already fetched data
323 function as(string $type, bool $keepdata = true) : API
{
324 $ct = new ContentType($this->version
, $type);
326 $that = $ct->apply($this);
336 * Perform a HEAD request against the endpoint's underlying URL
338 * @param mixed $args The HTTP query string parameters
339 * @param array $headers The request's additional HTTP headers
340 * @return mixed promise
342 function head($args = null, array $headers = null) {
343 return $this->request("HEAD", $args, null, $headers);
347 * Perform a GET request against the endpoint's underlying URL
349 * @param mixed $args The HTTP query string parameters
350 * @param array $headers The request's additional HTTP headers
351 * @return mixed promise
353 function get($args = null, array $headers = null) {
354 return $this->request("GET", $args, null, $headers);
358 * Perform a DELETE request against the endpoint's underlying URL
360 * @param mixed $args The HTTP query string parameters
361 * @param array $headers The request's additional HTTP headers
362 * @return mixed promise
364 function delete($args = null, array $headers = null) {
365 return $this->request("DELETE", $args, null, $headers);
369 * Perform a POST request against the endpoint's underlying URL
371 * @param mixed $body The HTTP message's body
372 * @param mixed $args The HTTP query string parameters
373 * @param array $headers The request's additional HTTP headers
374 * @return mixed promise
376 function post($body = null, $args = null, array $headers = null) {
377 return $this->request("POST", $args, $body, $headers);
381 * Perform a PUT request against the endpoint's underlying URL
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 mixed promise
388 function put($body = null, $args = null, array $headers = null) {
389 return $this->request("PUT", $args, $body, $headers);
393 * Perform a PATCH request against the endpoint's underlying URL
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 mixed promise
400 function patch($body = null, $args = null, array $headers = null) {
401 return $this->request("PATCH", $args, $body, $headers);
405 * Perform all queued HTTP transfers
409 function send() : API
{
410 $this->logger
->debug("send: start loop");
411 while (count($this->client
)) {
412 $this->client
->send();
414 $this->logger
->debug("send: end loop");
419 * Check for a specific key in the endpoint's underlying data
425 function exists($seg, &$val = null) : bool {
426 if (is_array($this->data
) && array_key_exists($seg, $this->data
)) {
427 $val = $this->data
[$seg];
429 } elseif (is_object($this->data
) && property_exists($this->data
, $seg)) {
430 $val = $this->data
->$seg;
437 $this->logger
->debug(sprintf("exists(%s) in %s -> %s",
438 $seg, typeof($this->data
, false), $exists ?
"true" : "false"
440 "url" => (string) $this->url
,
441 "val" => typeof($val, false),
448 * Queue the actual HTTP transfer through \seekat\API\Deferred and return the promise
450 * @param string $method The HTTP request method
451 * @param mixed $args The HTTP query string parameters
452 * @param mixed $body The HTTP message's body
453 * @param ?array $headers The request's additional HTTP headers
454 * @return mixed promise
456 private function request(string $method, $args = null, $body = null, array $headers = null) {
457 if (isset($this->data
)) {
458 $this->logger
->debug("request -> resolve", [
460 "url" => (string) $this->url
,
463 "headers" => $headers,
466 return $this->future
->resolve($this);
469 $url = $this->url
->mod(["query" => new QueryString($args)]);
470 $request = new Request($method, $url, ((array) $headers) +
$this->headers
,
471 $body = $this->type
->encode(is_resource($body) ?
new Body($body) : $body));
473 $this->logger
->info("request -> deferred", [
475 "url" => (string) $this->url
,
476 "args" => $this->url
->query
,
478 "headers" => $headers,
481 return (new Call\
Deferred($this, $request, $this->cache
))();