--- /dev/null
+; 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
#
+/nbproject/private/
\ No newline at end of file
--- /dev/null
+Michael Wallner <mike@php.net>
--- /dev/null
+Yay, no known and unresolved issues yet!
--- /dev/null
+# 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/.
--- /dev/null
+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.
--- /dev/null
+# 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).
--- /dev/null
+Thanks go to the following people, who have contributed to this project:
--- /dev/null
+{
+ "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"
+ }
+}
--- /dev/null
+<?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();
+ }
+}
--- /dev/null
+<?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'");
+ }
+}
--- /dev/null
+<?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");
+ }
+ }
+}
--- /dev/null
+<?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();
+ }
+ }
+}
--- /dev/null
+<?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;
+ }
+}
--- /dev/null
+<?php
+
+namespace seekat;
+
+interface Exception
+{
+}
--- /dev/null
+<?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();
+ }
+}
--- /dev/null
+<?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);
+ });
+};
--- /dev/null
+<?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);
+ });
+});