initial checkin
authorMichael Wallner <mike@php.net>
Thu, 12 May 2016 16:27:57 +0000 (18:27 +0200)
committerMichael Wallner <mike@php.net>
Thu, 12 May 2016 16:27:57 +0000 (18:27 +0200)
19 files changed:
.editorconfig [new file with mode: 0644]
.gitignore
AUTHORS [new file with mode: 0644]
BUGS [new file with mode: 0644]
CONTRIBUTING.md [new file with mode: 0644]
LICENSE [new file with mode: 0644]
README.md [new file with mode: 0644]
THANKS [new file with mode: 0644]
TODO [new file with mode: 0644]
composer.json [new file with mode: 0644]
lib/API.php [new file with mode: 0644]
lib/API/ContentType.php [new file with mode: 0644]
lib/API/Deferred.php [new file with mode: 0644]
lib/API/Iterator.php [new file with mode: 0644]
lib/API/Links.php [new file with mode: 0644]
lib/Exception.php [new file with mode: 0644]
lib/Exception/RequestException.php [new file with mode: 0644]
peridot.php [new file with mode: 0644]
tests/api.php [new file with mode: 0644]

diff --git a/.editorconfig b/.editorconfig
new file mode 100644 (file)
index 0000000..9b444ae
--- /dev/null
@@ -0,0 +1,23 @@
+; see http://editorconfig.org
+root = true
+
+[*]
+end_of_line = lf
+insert_final_newline = true
+indent_style = tab
+charset = utf-8
+trim_trailing_whitespace = true
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.json]
+indent_style = space
+indent_size = 4
+
+[package.xml]
+indent_style = space
+indent_size = 1
+
+[config.w32]
+end_of_line = crlf
index 792d6005489ebee62cde02066f19c5521e620451..7bd4f2283d05f41235ce7eedfeed90e6832125e3 100644 (file)
@@ -1 +1,2 @@
 #
+/nbproject/private/
\ No newline at end of file
diff --git a/AUTHORS b/AUTHORS
new file mode 100644 (file)
index 0000000..67bbd91
--- /dev/null
+++ b/AUTHORS
@@ -0,0 +1 @@
+Michael Wallner <mike@php.net>
diff --git a/BUGS b/BUGS
new file mode 100644 (file)
index 0000000..ebbf227
--- /dev/null
+++ b/BUGS
@@ -0,0 +1 @@
+Yay, no known and unresolved issues yet!
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644 (file)
index 0000000..968bd44
--- /dev/null
@@ -0,0 +1,39 @@
+# Contributor Code of Conduct
+
+As contributors and maintainers of this project, and in the interest of
+fostering an open and welcoming community, we pledge to respect all people who
+contribute through reporting issues, posting feature requests, updating
+documentation, submitting pull requests or patches, and other activities.
+
+We are committed to making participation in this project a harassment-free
+experience for everyone, regardless of level of experience, gender, gender
+identity and expression, sexual orientation, disability, personal appearance,
+body size, race, ethnicity, age, religion, or nationality.
+
+Examples of unacceptable behavior by participants include:
+
+* The use of sexualized language or imagery
+* Personal attacks
+* Trolling or insulting/derogatory comments
+* Public or private harassment
+* Publishing other's private information, such as physical or electronic
+  addresses, without explicit permission
+* Other unethical or unprofessional conduct.
+
+Project maintainers have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct. By adopting this Code of Conduct, project
+maintainers commit themselves to fairly and consistently applying these
+principles to every aspect of managing this project. Project maintainers who do
+not follow or enforce the Code of Conduct may be permanently removed from the
+project team.
+
+This code of conduct applies both within project spaces and in public spaces
+when an individual is representing the project or its community.
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported by opening an issue or contacting one or more of the project maintainers.
+
+This Code of Conduct is adapted from the
+[Contributor Covenant](http://contributor-covenant.org), version 1.2.0,
+available at http://contributor-covenant.org/version/1/2/0/.
diff --git a/LICENSE b/LICENSE
new file mode 100644 (file)
index 0000000..f52c89a
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,22 @@
+Copyright (c) 2015, Michael Wallner <mike@php.net>.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright notice,
+      this list of conditions and the following disclaimer.
+    * Redistributions in binary form must reproduce the above copyright
+      notice, this list of conditions and the following disclaimer in the
+      documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..6d09262
--- /dev/null
+++ b/README.md
@@ -0,0 +1,43 @@
+# seekat
+
+Fluent Github API access with PHP-7 and [ext-http](https://github.com/m6w6/ext-http).
+
+```php
+<?php
+
+use seekat\API;
+
+(new API)->repos->m6w6->seekat->readme->as("html")->then(function($readme) {
+       echo $readme;
+}, function($error) {
+       echo $error;
+});
+
+$api->send();
+```
+
+> ***Note:*** WIP
+
+
+## Installing
+
+### Composer
+
+       composer require m6w6/seekat
+
+## ChangeLog
+
+A comprehensive list of changes can be obtained from the
+[releases overview](./releases).
+
+## License
+
+seekat is licensed under the 2-Clause-BSD license, which can be found in
+the accompanying [LICENSE](./LICENSE) file.
+
+## Contributing
+
+All forms of contribution are welcome! Please see the bundled
+[CONTRIBUTING](./CONTRIBUTING.md) note for the general principles followed.
+
+The list of past and current contributors is maintained in [THANKS](./THANKS).
diff --git a/THANKS b/THANKS
new file mode 100644 (file)
index 0000000..d0eae43
--- /dev/null
+++ b/THANKS
@@ -0,0 +1 @@
+Thanks go to the following people, who have contributed to this project:
diff --git a/TODO b/TODO
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/composer.json b/composer.json
new file mode 100644 (file)
index 0000000..ecc96f9
--- /dev/null
@@ -0,0 +1,30 @@
+{
+    "name": "m6w6/seekat",
+    "description": "seekat wraps github api in php",
+    "keywords": ["seekat", "github api", "api", "php"],
+    "license": "BSD-2-Clause",
+    "authors": [
+        {
+            "name": "Michael Wallner",
+            "email": "mike@php.net"
+        }
+    ],
+    "autoload": {
+        "psr-4": {
+            "seekat\\": "lib/"
+        }
+    },
+    "require": {
+        "php": "^7.0",
+        "ext-http": "^3.0",
+        "react/promise": "^2.4",
+        "seebz/uri-template": "dev-master",
+               "psr/log": "^1.0"
+    },
+    "require-dev": {
+        "peridot-php/peridot": "^1.15",
+        "monolog/monolog": "^1.19",
+        "peridot-php/leo": "^1.5",
+        "peridot-php/peridot-code-coverage-reporters": "^2.0"
+    }
+}
diff --git a/lib/API.php b/lib/API.php
new file mode 100644 (file)
index 0000000..390771e
--- /dev/null
@@ -0,0 +1,557 @@
+<?php
+
+namespace seekat;
+
+use seekat\API\ContentType;
+use seekat\Exception\RequestException;
+
+use http\Url;
+use http\Header;
+use http\Client;
+use http\Client\Request;
+use http\Client\Response;
+use http\Message\Body;
+use http\QueryString;
+
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+
+use React\Promise\ExtendedPromiseInterface;
+use function React\Promise\resolve;
+use function React\Promise\reject;
+
+class API implements \IteratorAggregate, \Countable {
+       /**
+        * The current API endpoint URL
+        * @var \http\Url
+        */
+       private $__url;
+       
+       /**
+        * Logger
+        * @var \Psr\Log\LoggerInterface
+        */
+       private $__log;
+       
+       /**
+        * The HTTP client
+        * @var \http\Client
+        */
+       private $__client;
+       
+       /**
+        * Default headers to send out to the API endpoint
+        * @var array
+        */
+       private $__headers;
+       
+       /**
+        * Current endpoint data's Content-Type
+        * @var \http\Header
+        */
+       private $__type;
+       
+       /**
+        * Current endpoint's data
+        * @var array|object
+        */
+       private $__data;
+       
+       /**
+        * Current endpoints links
+        * @var seekat\API\Links
+        */
+       private $__links;
+       
+       /**
+        * Create a new API endpoint root
+        *
+        * @var array $headers Standard request headers, defaults to ["Accept" => "application/vnd.github.v3+json"]
+        * @var \http\Url The API's endpoint, defaults to https://api.github.com
+        * @var \http\Client $client The HTTP client to use for executing requests
+        * @var \Psr\Log\LoggerInterface $log A logger
+        */
+       function __construct(array $headers = null, Url $url = null, Client $client = null, LoggerInterface $log = null) {
+               $this->__log = $log ?? new NullLogger;
+               $this->__url = $url ?? new Url("https://api.github.com");
+               $this->__client = $client ?? new Client;
+               $this->__headers = (array) $headers + [
+               "Accept" => "application/vnd.github.v3+json"
+               ];
+       }
+       
+       /**
+        * Ascend one level deep into the API endpoint
+        *
+        * @var string|int $seg The "path" element to ascend into
+ * @return \seekat\API Endpoint clone referring to {$parent}/{$seg}
+        */
+       function __get($seg) : API {
+               if (substr($seg, -4) === "_url") {
+                       $url = new Url(uri_template($this->__data->$seg));
+                       $that = $this->withUrl($url);
+                       $seg = basename($that->__url->path);
+               } else {
+                       $that = clone $this;
+                       $that->__url->path .= "/".urlencode($seg);
+                       $this->exists($seg, $that->__data);
+               }
+               
+               $this->__log->debug(__FUNCTION__."($seg)", [
+                       "url" => [
+                               (string) $this->__url,
+                               (string) $that->__url
+                       ],
+               ]);
+               
+               return $that;
+       }
+       
+       /**
+        * Call handler that actually queues a data fetch and returns a promise
+        *
+        * @var string $method The API's "path" element to ascend into
+        * @var array $args Array of arguments forwarded to \seekat\API::get()
+        * @return \React\Promise\ExtendedPromiseInterface
+        */
+       function __call(string $method, array $args) : ExtendedPromiseInterface {
+               /* We cannot implement an explicit then() method,
+                * because the Promise implementation might think
+                * we're actually implementing Thenable,
+                * which might cause an infite loop.
+                */
+               if ($method === "then") {
+                       return $this->get()->then(...$args);
+               }
+               
+               /*
+                * very short-hand version:
+                * ->users->m6w6->gists->get()->then(...)
+                * vs:
+                * ->users->m6w6->gists(...)
+                */
+               if (is_callable(current($args))) {
+                       return $this->$method->get()->then(current($args));
+               }
+               
+               /* standard access */
+               if ($this->exists($method)) {
+                       return $this->$method->get(...$args);
+               }
+               
+       /* fetch resource, unless already localized, and try for {$method}_url */
+               return $this->$method->get(...$args)->otherwise(function($error) use($method, $args) {
+                       if ($this->exists($method."_url", $url)) {
+                               
+                               $this->__log->info(__FUNCTION__."($method): ". $error->getMessage(), [
+                                       "url" => (string) $this->__url
+                               ]);
+                               
+                               $url = new Url(uri_template($url, (array) current($args)));
+                               return $this->withUrl($url)->get(...$args);
+                       }
+                       
+                       $this->__log->error(__FUNCTION__."($method): ". $error->getMessage(), [
+                               "url" => (string) $this->__url
+                       ]);
+                       
+                       throw $error;
+               });
+       }
+       
+       /**
+        * Clone handler ensuring the underlying url will be cloned, too
+        */
+       function __clone() {
+               $this->__url = clone $this->__url;
+       }
+       
+       /**
+        * The string handler for the endpoint's data
+        *
+        * @return string
+        */
+       function __toString() : string {
+               if (is_scalar($this->__data)) {
+                       return (string) $this->__data;
+               }
+               
+               /* FIXME */
+               return json_encode($this->__data);
+       }
+       
+       /**
+        * Import handler for the endpoint's underlying data
+        *
+        * \seekat\Deferred will call this when the request will have finished.
+        *
+        * @var \http\Client\Response $response
+        * @return \seekat\API self
+        */
+       function import(Response $response) : API {
+               //addcslashes($response, "\0..\40\42\47\134\140\177..\377")
+               
+               $this->__log->info(__FUNCTION__.": ". $response->getInfo(), [
+                       "url" => (string) $this->__url
+               ]);
+               
+               if ($response->getResponseCode() >= 400) {
+                       $e = new RequestException($response);
+                       
+                       $this->__log->critical(__FUNCTION__.": ".$e->getMessage(), [
+                               "url" => (string) $this->__url,
+                       ]);
+                       
+                       throw $e;
+               }
+               
+               if (!($type = $response->getHeader("Content-Type", Header::class))) {
+                       $e = new RequestException($response);
+                       $this->__log->error(
+                               __FUNCTION__.": Empty Content-Type -> ".$e->getMessage(), [
+                               "url" => (string) $this->__url,
+                       ]);
+                       throw $e;
+               }
+               
+               try {
+                       $this->__type = new ContentType($type);
+                       $this->__data = $this->__type->parseBody($response->getBody());
+                       
+                       if (($link = $response->getHeader("Link", Header::class))) {
+                               $this->__links = new API\Links($link);
+                       }
+               } catch (\Exception $e) {
+                       $this->__log->error(__FUNCTION__.": ".$e->getMessage(), [
+                               "url" => (string) $this->__url
+                       ]);
+                       
+                       throw $e;
+               }
+               
+               return $this;
+       }
+       
+       /**
+        * Export the endpoint's underlying data
+        *
+        * @return mixed
+        */
+       function export(&$type = null) {
+               $type = clone $this->__type;
+               return $this->__data;
+       }
+       
+       /**
+        * Create a copy of the endpoint with specific data
+        *
+        * @var mixed $data
+        * @return \seekat\API clone
+        */
+       function withData($data) : API {
+               $that = clone $this;
+               $that->__data = $data;
+               return $that;
+       }
+       
+       /**
+        * Create a copy of the endpoint with a specific Url, but with data reset
+        *
+        * @var \http\Url $url
+        * @return \seekat\API clone
+        */
+       function withUrl(Url $url) : API {
+               $that = $this->withData(null);
+               $that->__url = $url;
+               return $that;
+       }
+       
+       /**
+        * Create a copy of the endpoint with a specific header added/replaced
+        *
+        * @var string $name
+        * @var mixed $value
+        * @return \seekat\API clone
+        */
+       function withHeader(string $name, $value) : API {
+               $that = clone $this;
+               if (isset($value)) {
+                       $that->__headers[$name] = $value;
+               } else {
+                       unset($that->__headers[$name]);
+               }
+               return $that;
+       }
+       
+       /**
+        * Create a copy of the endpoint with a customized accept header
+        *
+        * Changes the returned endpoint's accept header to
+ *     "application/vnd.github.v3.{$type}"
+        *
+        * @var string $type The expected return data type, e.g. "raw", "html", etc.
+        * @var bool $keepdata Whether to keep already fetched data
+        * @return \seekat\API clone
+        */
+       function as(string $type, bool $keepdata = true) : API {
+               switch(substr($type, 0, 1)) {
+               case "+":
+               case ".":
+               case "":
+                       break;
+               default:
+                       $type = ".$type";
+                       break;
+               }
+               $vapi = ContentType::version();
+               $that = $this->withHeader("Accept", "application/vnd.github.v$vapi$type");
+               if (!$keepdata) {
+                       $that->__data = null;
+               }
+               return $that;
+       }
+       
+       /**
+        * Create an iterator over the endpoint's underlying data
+        *
+        * @return \seekat\API\Iterator
+        */
+       function getIterator() : API\Iterator {
+               return new API\Iterator($this);
+       }
+       
+       /**
+        * Count the underlying data's entries
+        *
+        * @return int
+        */
+       function count() : int {
+               return count($this->__data);
+       }
+       
+       /**
+        * Perform a GET request against the endpoint's underlying URL
+        *
+        * @var mixed $args The HTTP query string parameters
+        * @var array $headers The request's additional HTTP headers
+        * @return \React\Promise\ExtendedPromiseInterface
+        */
+       function get($args = null, array $headers = null) : ExtendedPromiseInterface {
+               return $this->__xfer("GET", $args, null, $headers);
+       }
+       
+       /**
+        * Perform a DELETE request against the endpoint's underlying URL
+        *
+        * @var mixed $args The HTTP query string parameters
+        * @var array $headers The request's additional HTTP headers
+        * @return \React\Promise\ExtendedPromiseInterface
+        */
+       function delete($args = null, array $headers = null) : ExtendedPromiseInterface {
+               return $this->__xfer("DELETE", $args, null, $headers);
+       }
+       
+       /**
+        * Perform a POST request against the endpoint's underlying URL
+        *
+        * @var mixed $body The HTTP message's body
+        * @var mixed $args The HTTP query string parameters
+        * @var array $headers The request's additional HTTP headers
+        * @return \React\Promise\ExtendedPromiseInterface
+        */
+       function post($body = null, $args = null, array $headers = null) : ExtendedPromiseInterface {
+               return $this->__xfer("POST", $args, $body, $headers);
+       }
+       
+       /**
+        * Perform a PUT request against the endpoint's underlying URL
+        *
+        * @var mixed $body The HTTP message's body
+        * @var mixed $args The HTTP query string parameters
+        * @var array $headers The request's additional HTTP headers
+        * @return \React\Promise\ExtendedPromiseInterface
+        */
+       function put($body = null, $args = null, array $headers = null) : ExtendedPromiseInterface {
+               return $this->__xfer("PUT", $args, $body, $headers);
+       }
+       
+       /**
+        * Perform a PATCH request against the endpoint's underlying URL
+        *
+        * @var mixed $body The HTTP message's body
+        * @var mixed $args The HTTP query string parameters
+        * @var array $headers The request's additional HTTP headers
+        * @return \React\Promise\ExtendedPromiseInterface
+        */
+       function patch($body = null, $args = null, array $headers = null) : ExtendedPromiseInterface {
+               return $this->__xfer("PATCH", $args, $body, $headers);
+       }
+       
+       /**
+        * Accessor to any hypermedia links
+        *
+        * @return null|\seekat\API\Links
+        */
+       function links() {
+               return $this->__links;
+       }
+       
+       /**
+        * Perform a GET request against the link's "first" relation
+        *
+        * @return \React\Promise\ExtendedPromiseInterface
+        */
+       function first() : ExtendedPromiseInterface {
+               if ($this->links() && ($first = $this->links()->getFirst())) {
+                       return $this->withUrl($first)->get();
+               }
+               return reject($this->links());
+       }
+       
+       /**
+        * Perform a GET request against the link's "prev" relation
+        *
+        * @return \React\Promise\ExtendedPromiseInterface
+        */
+       function prev() : ExtendedPromiseInterface {
+               if ($this->links() && ($prev = $this->links()->getPrev())) {
+                       return $this->withUrl($prev)->get();
+               }
+               return reject($this->links());
+       }
+       
+       /**
+        * Perform a GET request against the link's "next" relation
+        *
+        * @return \React\Promise\ExtendedPromiseInterface
+        */
+       function next() : ExtendedPromiseInterface {
+               if ($this->links() && ($next = $this->links()->getNext())) {
+                       return $this->withUrl($next)->get();
+               }
+               return reject($this->links());
+       }
+       
+       /**
+        * Perform a GET request against the link's "last" relation
+        *
+        * @return \React\Promise\ExtendedPromiseInterface
+        */
+       function last() : ExtendedPromiseInterface {
+               if ($this->links() && ($last = $this->links()->getLast())) {
+                       return $this->withUrl($last)->get();
+               }
+               return reject($this->links());
+       }
+       
+       /**
+        * Perform all queued HTTP transfers
+        *
+        * @return \seekat\API self
+        */
+       function send() : API {
+               $this->__log->debug(__FUNCTION__.": start loop");
+               while (count($this->__client)) {
+                       $this->__client->send();
+               }
+               $this->__log->debug(__FUNCTION__.": end loop");
+               return $this;
+       }
+       
+       /**
+        * Run the send loop once
+        *
+        * @param callable $timeout as function(\seekat\API $api) : float, returning any applicable select timeout
+        * @return bool
+        */
+       function __invoke(callable $timeout = null) : bool {
+               $this->__log->debug(__FUNCTION__);
+               
+               if (count($this->__client)) {
+                       if ($this->__client->once()) {
+                               if ($timeout) {
+                                       $timeout = $timeout($this);
+                               }
+                               
+                               $this->__log->debug(__FUNCTION__.": wait", compact("timeout"));
+                               
+                               $this->__client->wait($timeout);
+                               return 0 < count($this->__client);
+                       }
+               }
+               return false;
+       }
+       
+       /**
+        * Check for a specific key in the endpoint's underlying data
+        *
+        * @var string $seg
+        * @var mixed &$val
+        * @return bool
+        */
+       function exists($seg, &$val = null) : bool {
+               if (is_array($this->__data) && array_key_exists($seg, $this->__data)) {
+                       $val = $this->__data[$seg];
+                       $exists = true;
+               } elseif (is_object($this->__data) && property_exists($this->__data, $seg)) {
+                       $val = $this->__data->$seg;
+                       $exists = true;
+               } else {
+                       $val = null;
+                       $exists = false;
+               }
+               
+               $this->__log->debug(__FUNCTION__."($seg) in ".(
+                       is_object($this->__data)
+                               ? get_class($this->__data)
+                               : gettype($this->__data)
+               )." -> ".(
+                       $exists
+                               ? "true"
+                               : "false"
+               ), [
+                       "url" => (string) $this->__url,
+                       "val" => $val,
+               ]);
+               
+               return $exists;
+       }
+       
+       /**
+        * Queue the actual HTTP transfer through \seekat\API\Deferred and return the promise
+        *
+        * @var string $method The HTTP request method
+        * @var mixed $args The HTTP query string parameters
+        * @var mixed $body Thee HTTP message's body
+        * @var array $headers The request's additional HTTP headers
+        * @return \React\Promise\ExtendedPromiseInterface
+        */
+       private function __xfer(string $method, $args = null, $body = null, array $headers = null) : ExtendedPromiseInterface {
+               if (isset($this->__data)) {
+                       $this->__log->debug(__FUNCTION__."($method) -> resolve", [
+                               "url" => (string) $this->__url,
+                               "args" => $args,
+                               "body" => $body,
+                               "headers" => $headers,
+                       ]);
+                       
+                       return resolve($this);
+               }
+               
+               $url = $this->__url->mod(["query" => new QueryString($args)]);
+               $request = new Request($method, $url, ((array) $headers) + $this->__headers,
+                        $body = is_array($body) ? json_encode($body) : (
+                               is_resource($body) ? new Body($body) : (
+                                       is_scalar($body) ? (new Body)->append($body) :
+                                               $body)));
+               
+               $this->__log->info(__FUNCTION__."($method) -> request", [
+                       "url" => (string) $this->__url,
+                       "args" => $this->__url->query,
+                       "body" => $body,
+                       "headers" => $headers,
+               ]);
+               
+               return (new API\Deferred($this, $this->__client, $request))->promise();
+       }
+}
diff --git a/lib/API/ContentType.php b/lib/API/ContentType.php
new file mode 100644 (file)
index 0000000..e8f75dc
--- /dev/null
@@ -0,0 +1,88 @@
+<?php
+
+namespace seekat\API;
+
+use http\Header;
+use http\Message\Body;
+
+class ContentType
+{
+       static private $version = 3;
+       
+       static private $types = [
+               "json"          => "self::fromJson",
+               "base64"        => "self::fromBase64",
+               "sha"           => "self::fromData",
+               "raw"           => "self::fromData",
+               "html"          => "self::fromData",
+               "diff"          => "self::fromData",
+               "patch"         => "self::fromData",
+       ];
+
+       private $type;
+
+       static function register(string $type, callable $handler) {
+               self::$types[$type] = $handler;
+       }
+
+       static function registered(string $type) : bool {
+               return isset(self::$types[$type]);
+       }
+
+       static function unregister(string $type) {
+               unset(self::$types[$type]);
+       }
+
+       static function version(int $v = null) : int {
+               $api = self::$version;
+               if (isset($v)) {
+                       self::$version = $v;
+               }
+               return $api;
+       }
+       
+       private static function fromJson(Body $json) {
+               $decoded = json_decode($json);
+               if (!isset($decoded) && json_last_error()) {
+                       throw new \UnexpectedValueException("Could not decode JSON: ".
+                               json_last_error_msg());
+               }
+               return $decoded;
+       }
+
+       private static function fromBase64(Body $base64) : string {
+               if (false === ($decoded = base64_decode($base64))) {
+                       throw new \UnexpectedValueExcpeption("Could not decode BASE64");
+               }
+       }
+
+       private static function fromData(Body $data) : string {
+               return (string) $data;
+       }
+
+       function __construct(Header $contentType) {
+               if (strcasecmp($contentType->name, "Content-Type")) {
+                       throw new \InvalidArgumentException(
+                               "Expected Content-Type header, got ". $contentType->name);
+               }
+               $vapi = static::version();
+               $this->type = preg_replace("/
+                       (?:application\/(?:vnd\.github(?:\.v$vapi)?)?)
+                       (?|
+                                       \.                                      ([^.+]+)
+                               |       (?:\.[^.+]+)?\+?        (json)
+                       )/x", "\\1", current(array_keys($contentType->getParams()->params)));
+       }
+
+       function getType() : string {
+               return $this->type;
+       }
+
+       function parseBody(Body $data) {
+               $type = $this->getType();
+               if (static::registered($type)) {
+                       return call_user_func(self::$types[$type], $data, $type);
+               }
+               throw new \UnexpectedValueException("Unhandled content type '$type'");
+       }
+}
diff --git a/lib/API/Deferred.php b/lib/API/Deferred.php
new file mode 100644 (file)
index 0000000..b55cb9d
--- /dev/null
@@ -0,0 +1,117 @@
+<?php
+
+namespace seekat\API;
+
+use seekat\API;
+use http\Client;
+use http\Client\Request;
+use http\Client\Response;
+
+class Deferred extends \React\Promise\Deferred implements \SplObserver {
+
+       /**
+        * The endpoint
+        * @var \seekat\API
+        */
+       private $api;
+
+       /**
+        * The HTTP client
+        * @var \http\Client
+        */
+       private $client;
+
+       /**
+        * The executed request
+        * @var \http\Client\Request
+        */
+       private $request;
+
+       /**
+        * The promised response
+        * @var \http\Client\Response
+        */
+       private $response;
+
+       /**
+        * Create a deferred promise for the response of $request
+        *
+        * @var \seekat\API $api The endpoint of the request
+        * @var \http\Client $client The HTTP client to send the request
+        * @var \http\Client\Request The request to execute
+        */
+       function __construct(API $api, Client $client, Request $request) {
+               $this->api = $api;
+               $this->client = $client;
+               $this->request = $request;
+
+               $client->attach($this);
+               $client->enqueue($request);
+
+               parent::__construct(function($resolve, $reject) {
+                       return $this->cancel($resolve, $reject);
+               });
+       }
+
+       /**
+        * Progress observer
+        *
+        * Import the response's data on success and resolve the promise.
+        *
+        * @var \SplSubject $client The observed HTTP client
+        * @var \http\Client\Request The request which generated the update
+        * @var object $progress The progress information
+        */
+       function update(\SplSubject $client, Request $request = null, $progress = null) {
+               if ($request !== $this->request) {
+                       return;
+               }
+
+               $this->notify((object) compact("client", "request", "progress"));
+
+               if ($progress->info === "finished") {
+                       $this->response = $this->client->getResponse();
+                       $this->complete(
+                               [$this, "resolve"],
+                               [$this, "reject"]
+                       );
+               }
+       }
+
+       /**
+        * Completion callback
+        * @param callable $resolve
+        * @param callable $reject
+        */
+       private function complete(callable $resolve, callable $reject) {
+               $this->client->detach($this);
+
+               if ($this->response) {
+                       try {
+                               $resolve($this->api->import($this->response));
+                       } catch (\Exception $e) {
+                               $reject($e);
+                       }
+               } else {
+                       $reject($this->client->getTransferInfo($this->request)["error"]);
+               }
+
+               $this->client->dequeue($this->request);
+       }
+
+       /**
+        * Cancellation callback
+        * @param callable $resolve
+        * @param callable $reject
+        */
+       private function cancel(callable $resolve, callable $reject) {
+               /* did we finish in the meantime? */
+               if ($this->response) {
+                       $this->complete($resolve, $reject);
+               } else {
+                       $this->client->detach($this);
+                       $this->client->dequeue($this->request);
+                       $reject("Cancelled");
+               }
+       }
+}
diff --git a/lib/API/Iterator.php b/lib/API/Iterator.php
new file mode 100644 (file)
index 0000000..8f57b09
--- /dev/null
@@ -0,0 +1,86 @@
+<?php
+
+namespace seekat\API;
+
+use seekat\API;
+
+class Iterator implements \Iterator {
+       /**
+        * The endpoint
+        * @var \seekat\API
+        */
+       private $api;
+
+       /**
+        * The iterator's data
+        * @var array
+        */
+       private $data;
+
+       /**
+        * The current key
+        * @var int|string
+        */
+       private $key;
+
+       /**
+        * The current data entry
+        * @var mixed
+        */
+       private $cur;
+
+       /**
+        * Create a new iterator over $data returning \seekat\API instances
+        *
+        * @var \seekat\API $api The endpoint
+        * @var array|object $data
+        */
+       function __construct(API $api) {
+               $this->api = $api;
+               $this->data = (array) $api->export();
+       }
+
+       /**
+        * Get the current key
+        *
+        * @return int|string
+        */
+       function key() {
+               return $this->key;
+       }
+
+       /**
+        * Get the current data entry
+        *
+        * @return \seekat\API
+        */
+       function current() {
+               return $this->cur;
+       }
+
+       function next() {
+               if (list($key, $cur) = each($this->data)) {
+                       $this->key = $key;
+                       if ($this->api->$key->exists("url", $url)) {
+                               $url = new \http\Url($url);
+                               $this->cur = $this->api->withUrl($url)->withData($cur);
+                       } else {
+                               $this->cur = $this->api->$key->withData($cur);
+                       }
+               } else {
+                       $this->key = null;
+                       $this->cur = null;
+               }
+       }
+
+       function valid() {
+               return isset($this->cur);
+       }
+
+       function rewind() {
+               if (is_array($this->data)) {
+                       reset($this->data);
+                       $this->next();
+               }
+       }
+}
diff --git a/lib/API/Links.php b/lib/API/Links.php
new file mode 100644 (file)
index 0000000..5546e65
--- /dev/null
@@ -0,0 +1,135 @@
+<?php
+
+namespace seekat\API;
+
+use http\Header;
+use http\Params;
+use http\QueryString;
+use http\Url;
+
+class Links implements \Serializable
+{
+       /**
+        * Parsed "Link" relations
+        * @var \http\Params
+        */
+       private $params;
+       
+       /**
+        * Parsed "Link" relations
+        * @var array
+        */
+       private $relations = [];
+
+       /**
+        * Parse the hypermedia link header
+        *
+        * @var string $header_value The value of the "Link" header
+        */
+       function __construct(Header $links) {
+               if (strcasecmp($links->name, "Link")) {
+                       throw new \UnexpectedValueException("Expected 'Link' header, got: '{$links->name}'");
+               }
+               $this->unserialize($links->value);
+       }
+
+       function __toString() : string {
+               return $this->serialize();
+       }
+
+       function serialize() {
+               return (string) $this->params;
+       }
+
+       function unserialize($links) {
+               $this->params = new Params($links, ",", ";", "=",
+                       Params::PARSE_RFC5988 | Params::PARSE_ESCAPED);
+               if ($this->params->params) {
+                       foreach ($this->params->params as $link => $param) {
+                               $this->relations[$param["arguments"]["rel"]] = new Url($link);
+                       }
+               }
+       }
+
+       /**
+        * Receive the link header's parsed relations
+        *
+        * @return array
+        */
+       function getRelations() : array {
+               return $this->relations;
+       }
+
+       /**
+        * Get the URL of the link's "next" relation
+        *
+        * Returns the link's "last" relation if it exists and "next" is not set.
+        *
+        * @return \http\Url
+        */
+       function getNext() {
+               if (isset($this->relations["next"])) {
+                       return $this->relations["next"];
+               }
+               if (isset($this->relations["last"])) {
+                       return $this->relations["last"];
+               }
+               return null;
+       }
+
+       /**
+        * Get the URL of the link's "prev" relation
+        *
+        * Returns the link's "first" relation if it exists and "prev" is not set.
+        *
+        * @return \http\Url
+        */
+       function getPrev() {
+               if (isset($this->relations["prev"])) {
+                       return $this->relations["prev"];
+               }
+               if (isset($this->relations["first"])) {
+                       return $this->relations["first"];
+               }
+               return null;
+       }
+
+       /**
+        * Get the URL of the link's "last" relation
+        *
+        * @return \http\Url
+        */
+       function getLast() {
+               if (isset($this->relations["last"])) {
+                       return $this->relations["last"];
+               }
+               return null;
+       }
+
+       /**
+        * Get the URL of the link's "first" relation
+        *
+        * @return \http\Url
+        */
+       function getFirst() {
+               if (isset($this->relations["first"])) {
+                       return $this->relations["first"];
+               }
+               return null;
+       }
+
+       /**
+        * Get the page sequence of the current link's relation
+        *
+        * @param string $which The relation of which to extract the page
+        * @return int The current page sequence
+        */
+       function getPage($which) {
+               if (($link = $this->{"get$which"}())) {
+                       $url = new Url($link, null, 0);
+                       $qry = new QueryString($url->query);
+                       return $qry->getInt("page", 1);
+               }
+               return 1;
+       }
+}
diff --git a/lib/Exception.php b/lib/Exception.php
new file mode 100644 (file)
index 0000000..27c7d53
--- /dev/null
@@ -0,0 +1,7 @@
+<?php
+
+namespace seekat;
+
+interface Exception
+{
+}
diff --git a/lib/Exception/RequestException.php b/lib/Exception/RequestException.php
new file mode 100644 (file)
index 0000000..877664e
--- /dev/null
@@ -0,0 +1,69 @@
+<?php
+
+namespace seekat\Exception;
+
+use seekat\Exception;
+
+use http\Header;
+use http\Client\Response;
+
+class RequestException extends \Exception implements Exception
+{
+       private $errors = [];
+       private $response;
+
+       function __construct(Response $response) {
+               $this->response = $response;
+
+               if (($h = $response->getHeader("Content-Type", Header::class))
+               &&      $h->match("application/json", Header::MATCH_WORD)
+               &&      $failure = json_decode($response->getBody())) {
+                       $message = $failure->message;
+                       if (isset($failure->errors)) {
+                               $this->errors = (array) $failure->errors;
+                       }
+               } else {
+                       $message = trim($response->getBody()->toString());
+               }
+
+               if (!strlen($message)) {
+                       $message = $response->getTransferInfo("error");
+               }
+               if (!strlen($message)) {
+                       $message = $response->getResponseStatus();
+               }
+
+               parent::__construct($message, $response->getResponseCode(), null);
+       }
+
+       function getErrors() : array {
+               return $this->errors;
+       }
+
+       function getErrorsAsString() {
+               static $reasons = [
+                       "missing" => "The resource %1\$s does not exist\n",
+                       "missing_field" => "Missing field %2\$s of resource %1\$s\n",
+                       "invalid" => "Invalid formatting of field %2\$s of resource %1\$s\n",
+                       "already_exists" => "A resource %1\$s with the same value of field %2\$s already exists\n",
+               ];
+
+               if (!$this->errors) {
+                       return $this->response;
+               }
+
+               $errors = "JSON errors:\n";
+               foreach ($this->errors as $error) {
+                       if ($error->code === "custom") {
+                               $errors .= $error->message . "\n";
+                       } else {
+                               $errors .= sprintf($reasons[$error->code], $error->resource, $error->field);
+                       }
+               }
+               return $errors;
+       }
+
+       function __toString() : string {
+               return parent::__toString() . "\n". $this->getErrorsAsString();
+       }
+}
diff --git a/peridot.php b/peridot.php
new file mode 100644 (file)
index 0000000..e4ce997
--- /dev/null
@@ -0,0 +1,108 @@
+<?php
+
+use Evenement\EventEmitterInterface as EventEmitter;
+use Monolog\Handler\AbstractProcessingHandler;
+use Monolog\Logger;
+use Peridot\Configuration;
+use Peridot\Console\Application;
+use Peridot\Console\Environment;
+use Peridot\Core\Suite;
+use Peridot\Core\Test;
+use seekat\API;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Peridot\Reporter\CodeCoverage\AbstractCodeCoverageReporter;
+use Peridot\Reporter\CodeCoverageReporters;
+use Peridot\Reporter\ReporterFactory;
+use Peridot\Reporter\AnonymousReporter;
+use Peridot\Reporter\AbstractBaseReporter;
+
+return function(EventEmitter $emitter) {
+       (new CodeCoverageReporters($emitter))->register();
+
+       $emitter->on('peridot.reporters', function(InputInterface $input, ReporterFactory $reporterFactory) {
+        $reporterFactory->register(
+            'seekat',
+            'Spec + Text Code coverage reporter',
+            function(AnonymousReporter $ar) use ($reporterFactory) {
+
+                               return new class($reporterFactory, $ar->getConfiguration(), $ar->getOutput(), $ar->getEventEmitter()) extends AbstractBaseReporter {
+                                       private $reporters = [];
+
+                                       function __construct(ReporterFactory $factory, Configuration $configuration, OutputInterface $output, EventEmitter $eventEmitter) {
+                                               fprintf(STDERR, "Creating reporters\n");
+                                               $this->reporters[] = $factory->create("spec");
+                                               $this->reporters[] = $factory->create("text-code-coverage");
+                                               parent::__construct($configuration, $output, $eventEmitter);
+                                       }
+
+                                       function init() {
+                                       }
+                                       function __2call($method, array $args) {
+                                               fprintf(STDERR, "Calling %s\n", $method);
+                                               foreach ($this->reporters as $reporter) {
+                                                       $output = $reporter->$method(...$args);
+                                               }
+                                               return $output;
+                                       }
+                               };
+                       }
+        );
+       });
+
+       $emitter->on('code-coverage.start', function (AbstractCodeCoverageReporter $reporter) {
+        $reporter->addDirectoryToWhitelist(__DIR__."/lib")
+                       ->addDirectoryToWhitelist(__DIR__."/tests");
+       });
+
+       $emitter->on("peridot.start", function(Environment $env, Application $app) {
+               $app->setCatchExceptions(false);
+               $definition = $env->getDefinition();
+               $definition->getArgument("path")
+                       ->setDefault(implode(" ", glob("tests/*")));
+               $definition->getOption("reporter")
+                       ->setDefault("seekat");
+       });
+
+       $log = new class extends AbstractProcessingHandler {
+               private $records = [];
+               protected function write(array $record) {
+                       $this->records[] = $record["formatted"];
+               }
+               function clean() {
+                       $this->records = [];
+               }
+               function dump(OutputInterface $output) {
+                       if ($this->records) {
+                               $output->writeln(["\n", "Debug log:", "==========="]);
+                               $output->write($this->records);
+                               $this->clean();
+                       }
+               }
+       };
+       $emitter->on("suite.start", function(Suite $suite) use($log) {
+               $headers = [];
+               if (($token = getenv("GITHUB_TOKEN"))) {
+                       $headers["Authentication"] = "token $token";
+               } elseif (function_exists("posix_isatty") && defined("STDIN") && posix_isatty(STDIN)) {
+                       fprintf(STDOUT, "GITHUB_TOKEN is not set in the environment, enter Y to continue without: ");
+                       fflush(STDOUT);
+                       if (strncasecmp(fgets(STDIN), "Y", 1)) {
+                               exit;
+                       }
+               } else {
+                       throw new Exception("GITHUB_TOKEN is not set in the environment");
+               }
+               $suite->getScope()->api = new API($headers, null, null, new Logger("seekat", [$log]));
+       });
+
+       $emitter->on("test.failed", function(Test $test, \Throwable $e) {
+
+       });
+       $emitter->on("test.passed", function() use($log) {
+               $log->clean();
+       });
+       $emitter->on("peridot.end", function($exitCode, InputInterface $input, OutputInterface $output) use($log) {
+               $log->dump($output);
+       });
+};
diff --git a/tests/api.php b/tests/api.php
new file mode 100644 (file)
index 0000000..fc0e26a
--- /dev/null
@@ -0,0 +1,90 @@
+<?php
+
+use seekat\API;
+use React\Promise\PromiseInterface;
+
+describe("API", function() {
+
+       it("should return API on property access", function() {
+               expect($this->api->users)->to->be->instanceof(API::class);
+       });
+
+       it("should return a clone on property access", function() {
+               expect($this->api->users)->to->not->equal($this->api);
+       });
+
+       it("should return PromiseInterface on function call", function() {
+               expect($this->api->users->m6w6())->to->be->instanceof(PromiseInterface::class);
+       });
+
+       it("should successfully request /users/m6w6", function() {
+               $this->api->users->m6w6()->then(function($json) use(&$m6w6) {
+                       $m6w6 = $json;
+               })->otherwise(function($error) use(&$errors) {
+                       $errors[] = (string) $error;
+               });
+
+               $this->api->send();
+
+               expect($errors)->to->be->empty;
+               expect($m6w6->login)->to->loosely->equal("m6w6");
+       });
+
+       it("should return the count of followers when accessing /users/m6w6->followers", function() {
+               $this->api->users->m6w6()->then(function($m6w6) use(&$followers) {
+                       $followers = $m6w6->followers;
+               })->otherwise(function($error) use(&$errors) {
+                       $errors[] = (string) $error;
+               });
+
+               $this->api->send();
+
+               expect($errors)->to->be->empty();
+               expect($followers->export())->to->be->an("integer");
+       });
+
+       it("should fetch followers_url when accessing /users/m6w6->followers_url", function() {
+               $this->api->users->m6w6()->then(function($m6w6) use(&$followers, &$errors) {
+                       $m6w6->followers_url()->then(function($json) use(&$followers) {
+                               $followers = $json;
+                       })->otherwise(function($error) use(&$errors) {
+                               $errors[] = (string) $error;
+                       });
+               })->otherwise(function($error) use(&$errors) {
+                       $errors[] = (string) $error;
+               });
+
+               $this->api->send();
+
+               expect($errors)->to->be->empty;
+               expect($followers->export())->to->be->an("array");
+               expect(count($followers))->to->be->above(0);
+       });
+
+       it("should handle a few requests in parallel", function() {
+               $this->api->users->m6w6()->then(function($m6w6) use(&$count, &$errors) {
+                       foreach ($m6w6 as $key => $val) {
+                               switch ($key) {
+                                       case "html_url":
+                                       case "avatar_url":
+                                               break;
+                                       default:
+                                               if (substr($key, -4) === "_url") {
+                                                       $val->get()->then(function() use(&$count) {
+                                                               ++$count;
+                                                       })->otherwise(function($error) use(&$errors) {
+                                                               $errors[] = (string) $error;
+                                                       });
+                                               }
+                               }
+                       }
+               })->otherwise(function($error) use(&$errors) {
+                       $errors[] = (string) $error;
+               });
+
+               $this->api->send();
+
+               expect($errors)->to->be->empty;
+               expect($count)->to->be->above(2);
+       });
+});