From 8ef054b51c681e7822133b38f7c5ed9dd2a0f29c Mon Sep 17 00:00:00 2001 From: Michael Wallner Date: Thu, 12 May 2016 18:27:57 +0200 Subject: [PATCH] initial checkin --- .editorconfig | 23 ++ .gitignore | 1 + AUTHORS | 1 + BUGS | 1 + CONTRIBUTING.md | 39 ++ LICENSE | 22 ++ README.md | 43 +++ THANKS | 1 + TODO | 0 composer.json | 30 ++ lib/API.php | 557 +++++++++++++++++++++++++++++ lib/API/ContentType.php | 88 +++++ lib/API/Deferred.php | 117 ++++++ lib/API/Iterator.php | 86 +++++ lib/API/Links.php | 135 +++++++ lib/Exception.php | 7 + lib/Exception/RequestException.php | 69 ++++ peridot.php | 108 ++++++ tests/api.php | 90 +++++ 19 files changed, 1418 insertions(+) create mode 100644 .editorconfig create mode 100644 AUTHORS create mode 100644 BUGS create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 THANKS create mode 100644 TODO create mode 100644 composer.json create mode 100644 lib/API.php create mode 100644 lib/API/ContentType.php create mode 100644 lib/API/Deferred.php create mode 100644 lib/API/Iterator.php create mode 100644 lib/API/Links.php create mode 100644 lib/Exception.php create mode 100644 lib/Exception/RequestException.php create mode 100644 peridot.php create mode 100644 tests/api.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9b444ae --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitignore b/.gitignore index 792d600..7bd4f22 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ # +/nbproject/private/ \ No newline at end of file diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000..67bbd91 --- /dev/null +++ b/AUTHORS @@ -0,0 +1 @@ +Michael Wallner diff --git a/BUGS b/BUGS new file mode 100644 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 index 0000000..968bd44 --- /dev/null +++ b/CONTRIBUTING.md @@ -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 index 0000000..f52c89a --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2015, Michael Wallner . +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 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 +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 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 index 0000000..e69de29 diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..ecc96f9 --- /dev/null +++ b/composer.json @@ -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 index 0000000..390771e --- /dev/null +++ b/lib/API.php @@ -0,0 +1,557 @@ + "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 index 0000000..e8f75dc --- /dev/null +++ b/lib/API/ContentType.php @@ -0,0 +1,88 @@ + "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 index 0000000..b55cb9d --- /dev/null +++ b/lib/API/Deferred.php @@ -0,0 +1,117 @@ +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 index 0000000..8f57b09 --- /dev/null +++ b/lib/API/Iterator.php @@ -0,0 +1,86 @@ +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 index 0000000..5546e65 --- /dev/null +++ b/lib/API/Links.php @@ -0,0 +1,135 @@ +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 index 0000000..27c7d53 --- /dev/null +++ b/lib/Exception.php @@ -0,0 +1,7 @@ +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 index 0000000..e4ce997 --- /dev/null +++ b/peridot.php @@ -0,0 +1,108 @@ +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 index 0000000..fc0e26a --- /dev/null +++ b/tests/api.php @@ -0,0 +1,90 @@ +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); + }); +}); -- 2.30.2