From: Michael Wallner Date: Thu, 12 May 2016 16:27:57 +0000 (+0200) Subject: initial checkin X-Git-Url: https://git.m6w6.name/?p=m6w6%2Fseekat;a=commitdiff_plain;h=8ef054b51c681e7822133b38f7c5ed9dd2a0f29c initial checkin --- 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); + }); +});