From d3cbe06cb04fc55bc92265d369ac64cb72271a09 Mon Sep 17 00:00:00 2001 From: Timothy J Warren Date: Fri, 8 Dec 2017 22:32:00 -0500 Subject: [PATCH] Update Artax, and update other code to work with new version --- app/views/collection/edit.php | 2 +- .../Sniffs/Strings/DoubleQuoteUsageSniff.php | 6 +- composer.json | 6 +- index.php | 4 + public/js.php | 23 +- src/API/APIRequestBuilder.php | 89 +- src/API/HummingbirdClient.php | 1155 +++++++++++++++++ src/API/Kitsu/Auth.php | 6 +- src/API/Kitsu/KitsuRequestBuilder.php | 13 +- src/API/Kitsu/KitsuTrait.php | 56 +- src/API/Kitsu/ListItem.php | 20 +- src/API/Kitsu/Model.php | 41 +- src/API/MAL/ListItem.php | 6 +- src/API/MAL/MALTrait.php | 20 +- src/API/ParallelAPIRequest.php | 41 +- src/Controller/AnimeCollection.php | 1 - src/Controller/Index.php | 25 +- src/Model/Anime.php | 16 +- src/Model/Manga.php | 16 +- tests/API/APIRequestBuilderTest.php | 27 +- tests/API/MAL/MALTraitTest.php | 1 - tests/AnimeClientTestCase.php | 6 +- 22 files changed, 1420 insertions(+), 160 deletions(-) create mode 100644 src/API/HummingbirdClient.php diff --git a/app/views/collection/edit.php b/app/views/collection/edit.php index 71bffc41..6b7af08f 100644 --- a/app/views/collection/edit.php +++ b/app/views/collection/edit.php @@ -13,7 +13,7 @@
- img($item['cover_image']); ?> + img($urlGenerator->assetUrl("images/anime/{$item['hummingbird_id']}.jpg")); ?>
diff --git a/build/CodeIgniter/Sniffs/Strings/DoubleQuoteUsageSniff.php b/build/CodeIgniter/Sniffs/Strings/DoubleQuoteUsageSniff.php index 419d3b14..d3eab4c7 100755 --- a/build/CodeIgniter/Sniffs/Strings/DoubleQuoteUsageSniff.php +++ b/build/CodeIgniter/Sniffs/Strings/DoubleQuoteUsageSniff.php @@ -393,12 +393,12 @@ class DoubleQuoteUsageSniff extends VariableUsageSniff && false === $smpl_qt_at ) { $error = 'Single-quoted strings should be used unless it contains variables, special chars like \n or single quotes.'; - $phpcsFile->addError($error, $stackPtr); + $phpcsFile->addError($error, $stackPtr, 111); } else if (false !== $smpl_qt_at && false !== $dbl_qt_at && false === $has_variable && false === $has_specific_sequence ) { $warning = 'It is encouraged to use a single-quoted string, since it doesn\'t contain any variable nor special char though it mixes single and double quotes.'; - $phpcsFile->addWarning($warning, $stackPtr); + $phpcsFile->addWarning($warning, $stackPtr, 222); } }//end processDoubleQuotedString() @@ -426,7 +426,7 @@ class DoubleQuoteUsageSniff extends VariableUsageSniff $smpl_qt_at = strpos($qtString, "'"); if (false === $has_variable && false !== $smpl_qt_at && false === $dbl_qt_at) { $warning = 'You may also use double-quoted strings if the string contains single quotes, so you do not have to use escape characters.'; - $phpcsFile->addWarning($warning, $stackPtr); + $phpcsFile->addWarning($warning, $stackPtr, 333); } }//end processSingleQuotedString() diff --git a/composer.json b/composer.json index 1bebffb8..7f13f0ca 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,7 @@ } }, "require": { - "amphp/artax": "^2.0", + "amphp/artax": "^3.0", "aura/html": "^2.0", "aura/router": "^3.0", "aura/session": "^2.0", @@ -42,7 +42,9 @@ "sebastian/phpcpd": "^3.0", "spatie/phpunit-snapshot-assertions": "^1.2.0", "squizlabs/php_codesniffer": "^3.0.0@beta", - "theseer/phpdox": "^0.10.1" + "symfony/var-dumper": "^4.0.1", + "theseer/phpdox": "^0.10.1", + "filp/whoops": "^2.1" }, "scripts": { "build": "vendor/bin/robo build", diff --git a/index.php b/index.php index cd54fb08..d59043cf 100644 --- a/index.php +++ b/index.php @@ -28,6 +28,10 @@ if ($timezone === '' || $timezone === FALSE) // Load composer autoloader require_once __DIR__ . '/vendor/autoload.php'; +$whoops = new \Whoops\Run; +$whoops->pushHandler(new \Whoops\Handler\PrettyPageHandler); +$whoops->register(); + // Define base directories $APP_DIR = _dir(__DIR__, 'app'); $APPCONF_DIR = _dir($APP_DIR, 'appConf'); diff --git a/public/js.php b/public/js.php index 305d8863..cc9eaed6 100644 --- a/public/js.php +++ b/public/js.php @@ -16,8 +16,9 @@ namespace Aviat\EasyMin; -use function Amp\wait; -use Amp\Artax\{Client, FormBody, Request}; +use function Amp\Promise\wait; +use Amp\Artax\Request; +use Aviat\AnimeClient\API\HummingbirdClient; use Aviat\Ion\{Json, JsonException}; // Include Amp and Artax @@ -113,24 +114,24 @@ class JSMin { * Makes a call to google closure compiler service * * @param array $options - Form parameters + * @throws \TypeError * @return object */ protected function closureCall(array $options) { $formFields = http_build_query($options); - $request = (new Request) - ->setMethod('POST') - ->setUri('https://closure-compiler.appspot.com/compile') - ->setAllHeaders([ + $request = (new Request('https://closure-compiler.appspot.com/compile')) + ->withMethod('POST') + ->withHeaders([ 'Accept' => 'application/json', 'Accept-Encoding' => 'gzip', 'Content-type' => 'application/x-www-form-urlencoded' ]) - ->setBody($formFields); + ->withBody($formFields); - $response = wait((new Client)->request($request, [ - Client::OP_AUTO_ENCODING => false + $response = wait((new HummingbirdClient)->request($request, [ + HummingbirdClient::OP_AUTO_ENCODING => false ])); return $response; @@ -147,7 +148,7 @@ class JSMin { try { $errorRes = $this->closureCall($options); - $errorJson = $errorRes->getBody(); + $errorJson = wait($errorRes->getBody()); $errorObj = Json::decode($errorJson) ?: (object)[]; @@ -237,7 +238,7 @@ class JSMin { // Now actually retrieve the compiled code $options['output_info'] = 'compiled_code'; $res = $this->closureCall($options); - $json = $res->getBody(); + $json = wait($res->getBody()); $obj = Json::decode($json); //return $obj; diff --git a/src/API/APIRequestBuilder.php b/src/API/APIRequestBuilder.php index 7b85405f..17b91060 100644 --- a/src/API/APIRequestBuilder.php +++ b/src/API/APIRequestBuilder.php @@ -16,6 +16,8 @@ namespace Aviat\AnimeClient\API; +use function Amp\Promise\wait; + use Amp; use Amp\Artax\{FormBody, Request}; use Aviat\Ion\Json; @@ -23,7 +25,7 @@ use InvalidArgumentException; use Psr\Log\LoggerAwareTrait; /** - * Wrapper around Artex to make it easier to build API requests + * Wrapper around Artax to make it easier to build API requests */ class APIRequestBuilder { use LoggerAwareTrait; @@ -60,7 +62,7 @@ class APIRequestBuilder { /** * The current request - * @var \Amp\Promise + * @var \Amp\Artax\Request */ protected $request; @@ -96,11 +98,12 @@ class APIRequestBuilder { * Set the request body * * @param FormBody|string $body + * @throws \TypeError * @return self */ public function setBody($body): self { - $this->request->setBody($body); + $this->request = $this->request->withBody($body); return $this; } @@ -108,13 +111,26 @@ class APIRequestBuilder { * Set body as form fields * * @param array $fields Mapping of field names to values + * @throws \TypeError * @return self */ public function setFormFields(array $fields): self { - $this->setHeader("Content-Type", "application/x-www-form-urlencoded"); - $body = (new FormBody)->addFields($fields); - $this->setBody($body); + $body = new FormBody(); + $body->addFields($fields); + + return $this->setBody($body); + } + + /** + * Unset a request header + * + * @param string $name + * @return self + */ + public function unsetHeader(string $name): self + { + $this->request = $this->request->withoutHeader($name); return $this; } @@ -125,9 +141,17 @@ class APIRequestBuilder { * @param string $value * @return self */ - public function setHeader(string $name, string $value): self + public function setHeader(string $name, string $value = NULL): self { - $this->request->setHeader($name, $value); + if (NULL === $value) + { + $this->unsetHeader($name); + } + else + { + $this->request = $this->request->withHeader($name, $value); + } + return $this; } @@ -153,6 +177,7 @@ class APIRequestBuilder { * Set the request body * * @param mixed $body + * @throws \TypeError * @return self */ public function setJsonBody($body): self @@ -160,7 +185,7 @@ class APIRequestBuilder { $requestBody = ( ! is_scalar($body)) ? Json::encode($body) : $body; - + return $this->setBody($requestBody); } @@ -179,9 +204,10 @@ class APIRequestBuilder { /** * Return the promise for the current request * - * @return \Amp\Promise + * @throws \Throwable + * @return \Amp\Artax\Request */ - public function getFullRequest() + public function getFullRequest(): Request { $this->buildUri(); @@ -189,8 +215,12 @@ class APIRequestBuilder { { $this->logger->debug('API Request', [ 'request_url' => $this->request->getUri(), - 'request_headers' => $this->request->getAllHeaders(), - 'request_body' => $this->request->getBody() + 'request_headers' => $this->request->getHeaders(), + 'request_body' => wait( + $this->request->getBody() + ->createBodyStream() + ->read() + ) ]); } @@ -207,19 +237,21 @@ class APIRequestBuilder { */ public function newRequest(string $type, string $uri): self { - if ( ! in_array($type, $this->validMethods)) + if ( ! \in_array($type, $this->validMethods, TRUE)) { - throw new InvalidArgumentException('Invalid HTTP methods'); + throw new InvalidArgumentException('Invalid HTTP method'); } - $this->resetState(); - - $this->request - ->setMethod($type) - ->setProtocol('1.1'); + $realUrl = (strpos($uri, '//') !== FALSE) + ? $uri + : $this->baseUrl . $uri; + $this->resetState($realUrl, $type); $this->path = $uri; + // Actually create the full url! + $this->buildUri(); + if ( ! empty($this->defaultHeaders)) { $this->setHeaders($this->defaultHeaders); @@ -231,9 +263,9 @@ class APIRequestBuilder { /** * Create the full request url * - * @return void + * @return Request */ - private function buildUri() + private function buildUri(): Request { $url = (strpos($this->path, '//') !== FALSE) ? $this->path @@ -244,18 +276,25 @@ class APIRequestBuilder { $url .= '?' . $this->query; } - $this->request->setUri($url); + $this->request = $this->request->withUri($url); + + return $this->request; } /** * Reset the class state for a new request * + * @param string $url + * @param string $type * @return void */ - private function resetState() + private function resetState($url, $type = 'GET') { + $requestUrl = $url ?: $this->baseUrl; + $this->path = ''; $this->query = ''; - $this->request = new Request(); + $this->request = (new Request($requestUrl)) + ->withMethod($type); } } \ No newline at end of file diff --git a/src/API/HummingbirdClient.php b/src/API/HummingbirdClient.php new file mode 100644 index 00000000..01b8b8f1 --- /dev/null +++ b/src/API/HummingbirdClient.php @@ -0,0 +1,1155 @@ + true, + self::OP_TRANSFER_TIMEOUT => 15000, + self::OP_MAX_REDIRECTS => 5, + self::OP_AUTO_REFERER => true, + self::OP_DISCARD_BODY => false, + self::OP_DEFAULT_HEADERS => [], + self::OP_MAX_HEADER_BYTES => Parser::DEFAULT_MAX_HEADER_BYTES, + self::OP_MAX_BODY_BYTES => Parser::DEFAULT_MAX_BODY_BYTES, + ]; + + public function __construct( + CookieJar $cookieJar = null, + HttpSocketPool $socketPool = null, + ClientTlsContext $tlsContext = null + ) + { + $this->cookieJar = $cookieJar ?? new NullCookieJar; + $this->tlsContext = $tlsContext ?? new ClientTlsContext; + $this->socketPool = $socketPool ?? new HttpSocketPool; + $this->hasZlib = extension_loaded('zlib'); + } + + /** @inheritdoc */ + public function request($uriOrRequest, array $options = [], CancellationToken $cancellation = null): Promise + { + return call(function () use ($uriOrRequest, $options, $cancellation) { + $cancellation = $cancellation ?? new NullCancellationToken; + + foreach ($options as $option => $value) { + $this->validateOption($option, $value); + } + + /** @var Request $request */ + list($request, $uri) = $this->generateRequestFromUri($uriOrRequest); + $options = $options ? array_merge($this->options, $options) : $this->options; + + foreach ($this->options[self::OP_DEFAULT_HEADERS] as $name => $header) { + if (!$request->hasHeader($name)) { + $request = $request->withHeaders([$name => $header]); + } + } + + /** @var array $headers */ + $headers = yield $request->getBody()->getHeaders(); + foreach ($headers as $name => $header) { + if (!$request->hasHeader($name)) { + $request = $request->withHeaders([$name => $header]); + } + } + + $originalUri = $uri; + $previousResponse = null; + + $maxRedirects = $options[self::OP_MAX_REDIRECTS]; + $requestNr = 1; + + do { + /** @var Request $request */ + $request = yield from $this->normalizeRequestBodyHeaders($request); + $request = $this->normalizeRequestHeaders($request, $uri, $options); + + // Always normalize this as last item, because we need to strip sensitive headers + $request = $this->normalizeTraceRequest($request); + + /** @var Response $response */ + $response = yield $this->doRequest($request, $uri, $options, $previousResponse, $cancellation); + + // Explicit $maxRedirects !== 0 check to not consume redirect bodies if redirect following is disabled + if ($maxRedirects !== 0 && $redirectUri = $this->getRedirectUri($response)) { + // Discard response body of redirect responses + $body = $response->getBody(); + while (null !== yield $body->read()) ; + + /** + * If this is a 302/303 we need to follow the location with a GET if the original request wasn't + * GET. Otherwise we need to send the body again. + * + * We won't resend the body nor any headers on redirects to other hosts for security reasons. + * + * @link http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.3.3 + */ + $method = $request->getMethod(); + $status = $response->getStatus(); + $isSameHost = $redirectUri->getAuthority(false) === $originalUri->getAuthority(false); + + if ($isSameHost) { + $request = $request->withUri($redirectUri); + + if ($status >= 300 && $status <= 303 && $method !== 'GET') { + $request = $request->withMethod('GET'); + $request = $request->withoutHeader('Transfer-Encoding'); + $request = $request->withoutHeader('Content-Length'); + $request = $request->withoutHeader('Content-Type'); + $request = $request->withBody(null); + } + } else { + // We ALWAYS follow with a GET and without any set headers or body for redirects to other hosts. + $optionsWithoutHeaders = $options; + unset($optionsWithoutHeaders[self::OP_DEFAULT_HEADERS]); + + $request = new Request((string)$redirectUri); + $request = $this->normalizeRequestHeaders($request, $redirectUri, $optionsWithoutHeaders); + } + + if ($options[self::OP_AUTO_REFERER]) { + $request = $this->assignRedirectRefererHeader($request, $originalUri, $redirectUri); + } + + $previousResponse = $response; + $originalUri = $redirectUri; + $uri = $redirectUri; + } else { + break; + } + } while (++$requestNr <= $maxRedirects + 1); + + if ($maxRedirects !== 0 && $redirectUri = $this->getRedirectUri($response)) { + throw new TooManyRedirectsException($response); + } + + return $response; + }); + } + + private function validateOption(string $option, $value) + { + switch ($option) { + case self::OP_AUTO_ENCODING: + if (!\is_bool($value)) { + throw new \TypeError("Invalid value for OP_AUTO_ENCODING, bool expected"); + } + + break; + + case self::OP_TRANSFER_TIMEOUT: + if (!\is_int($value) || $value < 0) { + throw new \Error("Invalid value for OP_TRANSFER_TIMEOUT, int >= 0 expected"); + } + + break; + + case self::OP_MAX_REDIRECTS: + if (!\is_int($value) || $value < 0) { + throw new \Error("Invalid value for OP_MAX_REDIRECTS, int >= 0 expected"); + } + + break; + + case self::OP_AUTO_REFERER: + if (!\is_bool($value)) { + throw new \TypeError("Invalid value for OP_AUTO_REFERER, bool expected"); + } + + break; + + case self::OP_DISCARD_BODY: + if (!\is_bool($value)) { + throw new \TypeError("Invalid value for OP_DISCARD_BODY, bool expected"); + } + + break; + + case self::OP_DEFAULT_HEADERS: + // We attempt to set the headers here, because they're automatically validated then. + (new Request("https://example.com/"))->withHeaders($value); + + break; + + case self::OP_MAX_HEADER_BYTES: + if (!\is_int($value) || $value < 0) { + throw new \Error("Invalid value for OP_MAX_HEADER_BYTES, int >= 0 expected"); + } + + break; + + case self::OP_MAX_BODY_BYTES: + if (!\is_int($value) || $value < 0) { + throw new \Error("Invalid value for OP_MAX_BODY_BYTES, int >= 0 expected"); + } + + break; + + default: + throw new \Error( + sprintf("Unknown option: %s", $option) + ); + } + } + + private function generateRequestFromUri($uriOrRequest) + { + if (is_string($uriOrRequest)) { + $uri = $this->buildUriFromString($uriOrRequest); + $request = new Request($uri); + } elseif ($uriOrRequest instanceof Request) { + $uri = $this->buildUriFromString($uriOrRequest->getUri()); + $request = $uriOrRequest; + } else { + throw new HttpException( + 'Request must be a valid HTTP URI or Amp\Artax\Request instance' + ); + } + + return [$request, $uri]; + } + + private function buildUriFromString($str): Uri + { + try { + $uri = new Uri($str); + $scheme = $uri->getScheme(); + + if (($scheme === "http" || $scheme === "https") && $uri->getHost()) { + return $uri; + } + + throw new HttpException("Request must specify a valid HTTP URI"); + } catch (InvalidUriException $e) { + throw new HttpException("Request must specify a valid HTTP URI", 0, $e); + } + } + + private function normalizeRequestBodyHeaders(Request $request): \Generator + { + if ($request->hasHeader("Transfer-Encoding")) { + return $request->withoutHeader("Content-Length"); + } + + if ($request->hasHeader("Content-Length")) { + return $request; + } + + /** @var RequestBody $body */ + $body = $request->getBody(); + $bodyLength = yield $body->getBodyLength(); + + if ($bodyLength === 0) { + $request = $request->withHeader('Content-Length', '0'); + $request = $request->withoutHeader('Transfer-Encoding'); + } else { + if ($bodyLength > 0) { + $request = $request->withHeader("Content-Length", $bodyLength); + $request = $request->withoutHeader("Transfer-Encoding"); + } else { + $request = $request->withHeader("Transfer-Encoding", "chunked"); + } + } + + return $request; + } + + private function normalizeRequestHeaders($request, $uri, $options) + { + $request = $this->normalizeRequestEncodingHeaderForZlib($request, $options); + $request = $this->normalizeRequestHostHeader($request, $uri); + $request = $this->normalizeRequestUserAgent($request); + $request = $this->normalizeRequestAcceptHeader($request); + $request = $this->assignApplicableRequestCookies($request); + + return $request; + } + + private function normalizeRequestEncodingHeaderForZlib(Request $request, array $options): Request + { + $autoEncoding = $options[self::OP_AUTO_ENCODING]; + + if (!$autoEncoding) { + return $request; + } + + if ($this->hasZlib) { + return $request->withHeader('Accept-Encoding', 'gzip, deflate, identity'); + } + + return $request->withoutHeader('Accept-Encoding'); + } + + private function normalizeRequestHostHeader(Request $request, Uri $uri): Request + { + if ($request->hasHeader('Host')) { + return $request; + } + + $authority = $this->generateAuthorityFromUri($uri); + $request = $request->withHeader('Host', $this->normalizeHostHeader($authority)); + + return $request; + } + + private function generateAuthorityFromUri(Uri $uri): string + { + $host = $uri->getHost(); + $port = $uri->getPort(); + + return "{$host}:{$port}"; + } + + private function normalizeHostHeader(string $host): string + { + // Though servers are supposed to be able to handle standard port names on the end of the + // Host header some fail to do this correctly. As a result, we strip the port from the end + // if it's a standard 80 or 443 + if (strpos($host, ':80') === strlen($host) - 3) { + return substr($host, 0, -3); + } elseif (strpos($host, ':443') === strlen($host) - 4) { + return substr($host, 0, -4); + } + + return $host; + } + + private function normalizeRequestUserAgent(Request $request): Request + { + if ($request->hasHeader('User-Agent')) { + return $request; + } + + return $request->withHeader('User-Agent', self::DEFAULT_USER_AGENT); + } + + private function normalizeRequestAcceptHeader(Request $request): Request + { + if ($request->hasHeader('Accept')) { + return $request; + } + + return $request->withHeader('Accept', '*/*'); + } + + private function assignApplicableRequestCookies(Request $request): Request + { + $uri = new Uri($request->getUri()); + + $domain = $uri->getHost(); + $path = $uri->getPath(); + + if (!$applicableCookies = $this->cookieJar->get($domain, $path)) { + // No cookies matched our request; we're finished. + return $request->withoutHeader("Cookie"); + } + + $isRequestSecure = strcasecmp($uri->getScheme(), "https") === 0; + $cookiePairs = []; + + /** @var Cookie $cookie */ + foreach ($applicableCookies as $cookie) { + if (!$cookie->isSecure() || $isRequestSecure) { + $cookiePairs[] = $cookie->getName() . "=" . $cookie->getValue(); + } + } + + if ($cookiePairs) { + return $request->withHeader("Cookie", \implode("; ", $cookiePairs)); + } + + return $request->withoutHeader("Cookie"); + } + + private function normalizeTraceRequest(Request $request): Request + { + $method = $request->getMethod(); + + if ($method !== 'TRACE') { + return $request; + } + + // https://tools.ietf.org/html/rfc7231#section-4.3.8 + /** @var Request $request */ + $request = $request->withBody(null); + + // Remove all body and sensitive headers + $request = $request->withHeaders([ + "Transfer-Encoding" => [], + "Content-Length" => [], + "Authorization" => [], + "Proxy-Authorization" => [], + "Cookie" => [], + ]); + + return $request; + } + + private function doRequest(Request $request, Uri $uri, array $options, Response $previousResponse = null, CancellationToken $cancellation): Promise + { + $deferred = new Deferred; + + $requestCycle = new RequestCycle; + $requestCycle->request = $request; + $requestCycle->uri = $uri; + $requestCycle->options = $options; + $requestCycle->previousResponse = $previousResponse; + $requestCycle->deferred = $deferred; + $requestCycle->bodyDeferred = new Deferred; + $requestCycle->body = new Emitter; + $requestCycle->cancellation = $cancellation; + + $protocolVersions = $request->getProtocolVersions(); + + if (\in_array("1.1", $protocolVersions, true)) { + $requestCycle->protocolVersion = "1.1"; + } elseif (\in_array("1.0", $protocolVersions, true)) { + $requestCycle->protocolVersion = "1.0"; + } else { + return new Failure(new HttpException( + "None of the requested protocol versions are supported: " . \implode(", ", $protocolVersions) + )); + } + + asyncCall(function () use ($requestCycle) { + try { + yield from $this->doWrite($requestCycle); + } catch (\Throwable $e) { + $this->fail($requestCycle, $e); + } + }); + + return $deferred->promise(); + } + + private function doWrite(RequestCycle $requestCycle) + { + $timeout = $requestCycle->options[self::OP_TRANSFER_TIMEOUT]; + $timeoutToken = new NullCancellationToken; + + if ($timeout > 0) { + $transferTimeoutWatcher = Loop::delay($timeout, function () use ($requestCycle, $timeout) { + $this->fail($requestCycle, new TimeoutException( + sprintf('Allowed transfer timeout exceeded: %d ms', $timeout) + )); + }); + + $requestCycle->bodyDeferred->promise()->onResolve(static function () use ($transferTimeoutWatcher) { + Loop::cancel($transferTimeoutWatcher); + }); + + $timeoutToken = new TimeoutCancellationToken($timeout); + } + + $authority = $this->generateAuthorityFromUri($requestCycle->uri); + $socketCheckoutUri = $requestCycle->uri->getScheme() . "://{$authority}"; + $connectTimeoutToken = new CombinedCancellationToken($requestCycle->cancellation, $timeoutToken); + + try { + /** @var ClientSocket $socket */ + $socket = yield $this->socketPool->checkout($socketCheckoutUri, $connectTimeoutToken); + $requestCycle->socket = $socket; + } catch (ResolutionException $dnsException) { + throw new DnsException(\sprintf("Resolving the specified domain failed: '%s'", $requestCycle->uri->getHost()), 0, $dnsException); + } catch (ConnectException $e) { + throw new SocketException(\sprintf("Connection to '%s' failed", $authority), 0, $e); + } catch (CancelledException $e) { + // In case of a user cancellation request, throw the expected exception + $requestCycle->cancellation->throwIfRequested(); + + // Otherwise we ran into a timeout of our TimeoutCancellationToken + throw new SocketException(\sprintf("Connection to '%s' timed out", $authority), 0, $e); + } + + $cancellation = $requestCycle->cancellation->subscribe(function ($error) use ($requestCycle) { + $this->fail($requestCycle, $error); + }); + + try { + if ($requestCycle->uri->getScheme() === 'https') { + $tlsContext = $this->tlsContext + ->withPeerName($requestCycle->uri->getHost()) + ->withPeerCapturing(); + + yield $socket->enableCrypto($tlsContext); + } + + // Collect this here, because it fails in case the remote closes the connection directly. + $connectionInfo = $this->collectConnectionInfo($socket); + + $rawHeaders = $this->generateRawRequestHeaders($requestCycle->request, $requestCycle->protocolVersion); + yield $socket->write($rawHeaders); + + $body = $requestCycle->request->getBody()->createBodyStream(); + $chunking = $requestCycle->request->getHeader("transfer-encoding") === "chunked"; + $remainingBytes = $requestCycle->request->getHeader("content-length"); + + if ($chunking && $requestCycle->protocolVersion === "1.0") { + throw new HttpException("Can't send chunked bodies over HTTP/1.0"); + } + + // We always buffer the last chunk to make sure we don't write $contentLength bytes if the body is too long. + $buffer = ""; + + while (null !== $chunk = yield $body->read()) { + $requestCycle->cancellation->throwIfRequested(); + + if ($chunk === "") { + continue; + } + + if ($chunking) { + $chunk = \dechex(\strlen($chunk)) . "\r\n" . $chunk . "\r\n"; + }/* elseif ($remainingBytes !== null) { + $remainingBytes -= \strlen($chunk); + + if ($remainingBytes < 0) { + throw new HttpException("Body contained more bytes than specified in Content-Length, aborting request"); + } + }*/ + + yield $socket->write($buffer); + $buffer = $chunk; + } + + // Flush last buffered chunk. + yield $socket->write($buffer); + + if ($chunking) { + yield $socket->write("0\r\n\r\n"); + }/* elseif ($remainingBytes !== null && $remainingBytes > 0) { + throw new HttpException("Body contained fewer bytes than specified in Content-Length, aborting request"); + }*/ + + yield from $this->doRead($requestCycle, $socket, $connectionInfo); + } finally { + $requestCycle->cancellation->unsubscribe($cancellation); + } + } + + private function fail(RequestCycle $requestCycle, \Throwable $error) + { + $toFails = []; + $socket = null; + + if ($requestCycle->deferred) { + $toFails[] = $requestCycle->deferred; + $requestCycle->deferred = null; + } + + if ($requestCycle->body) { + $toFails[] = $requestCycle->body; + $requestCycle->body = null; + } + + if ($requestCycle->bodyDeferred) { + $toFails[] = $requestCycle->bodyDeferred; + $requestCycle->bodyDeferred = null; + } + + if ($requestCycle->socket) { + $this->socketPool->clear($requestCycle->socket); + $socket = $requestCycle->socket; + $requestCycle->socket = null; + $socket->close(); + } + + foreach ($toFails as $toFail) { + $toFail->fail($error); + } + } + + private function collectConnectionInfo(ClientSocket $socket): ConnectionInfo + { + $crypto = \stream_get_meta_data($socket->getResource())["crypto"] ?? null; + + return new ConnectionInfo( + $socket->getLocalAddress(), + $socket->getRemoteAddress(), + $crypto ? TlsInfo::fromMetaData($crypto, \stream_context_get_options($socket->getResource())["ssl"]) : null + ); + } + + /** + * @param Request $request + * @param string $protocolVersion + * + * @return string + * + * @TODO Send absolute URIs in the request line when using a proxy server + * Right now this doesn't matter because all proxy requests use a CONNECT + * tunnel but this likely will not always be the case. + */ + private function generateRawRequestHeaders(Request $request, string $protocolVersion): string + { + $uri = $request->getUri(); + $uri = new Uri($uri); + + $requestUri = $uri->getPath() ?: '/'; + + if ($query = $uri->getQuery()) { + $requestUri .= '?' . $query; + } + + $head = $request->getMethod() . ' ' . $requestUri . ' HTTP/' . $protocolVersion . "\r\n"; + + $headers = $request->getHeaders(true); + /*$newHeaders = []; + + foreach($headers as $key => $val) + { + if ($key !== 'Content-Length') + { + $newHeaders[$key] = $val; + } + }*/ + + // Curse you Kitsu, for this stupid work-around because the login API endpoint doesn't allow for a Content-Length header! + //unset($headers['Content-Length']); + + foreach ($headers as $field => $values) { + if (\strcspn($field, "\r\n") !== \strlen($field)) { + throw new HttpException("Blocked header injection attempt for header '{$field}'"); + } + + foreach ($values as $value) { + if (\strcspn($value, "\r\n") !== \strlen($value)) { + throw new HttpException("Blocked header injection attempt for header '{$field}' with value '{$value}'"); + } + + $head .= "{$field}: {$value}\r\n"; + } + } + + $head .= "\r\n"; + + return $head; + } + + private function doRead(RequestCycle $requestCycle, ClientSocket $socket, ConnectionInfo $connectionInfo): \Generator + { + try { + $backpressure = new Success; + $bodyCallback = $requestCycle->options[self::OP_DISCARD_BODY] + ? null + : static function ($data) use ($requestCycle, &$backpressure) { + $backpressure = $requestCycle->body->emit($data); + }; + + $parser = new Parser($bodyCallback); + + $parser->enqueueResponseMethodMatch($requestCycle->request->getMethod()); + $parser->setAllOptions([ + Parser::OP_MAX_HEADER_BYTES => $requestCycle->options[self::OP_MAX_HEADER_BYTES], + Parser::OP_MAX_BODY_BYTES => $requestCycle->options[self::OP_MAX_BODY_BYTES], + ]); + + while (null !== $chunk = yield $socket->read()) { + $requestCycle->cancellation->throwIfRequested(); + + $parseResult = $parser->parse($chunk); + + if (!$parseResult) { + continue; + } + + $parseResult["headers"] = \array_change_key_case($parseResult["headers"], \CASE_LOWER); + + $response = $this->finalizeResponse($requestCycle, $parseResult, $connectionInfo); + $shouldCloseSocketAfterResponse = $this->shouldCloseSocketAfterResponse($response); + $ignoreIncompleteBodyCheck = false; + $responseHeaders = $response->getHeaders(); + + if ($requestCycle->deferred) { + $deferred = $requestCycle->deferred; + $requestCycle->deferred = null; + $deferred->resolve($response); + $response = null; // clear references + $deferred = null; // there's also a reference in the deferred + } else { + return; + } + + // Required, otherwise responses without body hang + if ($parseResult["headersOnly"]) { + // Directly parse again in case we already have the full body but aborted parsing + // to resolve promise with headers. + $chunk = null; + + do { + try { + $parseResult = $parser->parse($chunk); + } catch (ParseException $e) { + $this->fail($requestCycle, $e); + throw $e; + } + + if ($parseResult) { + break; + } + + if (!$backpressure instanceof Success) { + yield $this->withCancellation($backpressure, $requestCycle->cancellation); + } + + if ($requestCycle->bodyTooLarge) { + throw new HttpException("Response body exceeded the specified size limit"); + } + } while (null !== $chunk = yield $socket->read()); + + $parserState = $parser->getState(); + if ($parserState !== Parser::AWAITING_HEADERS) { + // Ignore check if neither content-length nor chunked encoding are given. + $ignoreIncompleteBodyCheck = $parserState === Parser::BODY_IDENTITY_EOF && + !isset($responseHeaders["content-length"]) && + strcasecmp('identity', $responseHeaders['transfer-encoding'][0] ?? ""); + + if (!$ignoreIncompleteBodyCheck) { + throw new SocketException(sprintf( + 'Socket disconnected prior to response completion (Parser state: %s)', + $parserState + )); + } + } + } + + if ($shouldCloseSocketAfterResponse || $ignoreIncompleteBodyCheck) { + $this->socketPool->clear($socket); + $socket->close(); + } else { + $this->socketPool->checkin($socket); + } + + $requestCycle->socket = null; + + // Complete body AFTER socket checkin, so the socket can be reused for a potential redirect + $body = $requestCycle->body; + $requestCycle->body = null; + + $bodyDeferred = $requestCycle->bodyDeferred; + $requestCycle->bodyDeferred = null; + + $body->complete(); + $bodyDeferred->resolve(); + + return; + } + } catch (\Throwable $e) { + $this->fail($requestCycle, $e); + + return; + } + + if ($socket->getResource() !== null) { + $requestCycle->socket = null; + $this->socketPool->clear($socket); + $socket->close(); + } + + // Required, because if the write fails, the read() call immediately resolves. + yield new Delayed(0); + + if ($requestCycle->deferred === null) { + return; + } + + $parserState = $parser->getState(); + + if ($parserState === Parser::AWAITING_HEADERS && $requestCycle->retryCount < 1) { + $requestCycle->retryCount++; + yield from $this->doWrite($requestCycle); + } else { + $this->fail($requestCycle, new SocketException(sprintf( + 'Socket disconnected prior to response completion (Parser state: %s)', + $parserState + ))); + } + } + + private function finalizeResponse(RequestCycle $requestCycle, array $parserResult, ConnectionInfo $connectionInfo) + { + $body = new IteratorStream($requestCycle->body->iterate()); + + if ($encoding = $this->determineCompressionEncoding($parserResult["headers"])) { + $body = new ZlibInputStream($body, $encoding); + } + + // Wrap the input stream so we can discard the body in case it's destructed but hasn't been consumed. + // This allows reusing the connection for further requests. It's important to have __destruct in InputStream and + // not in Message, because an InputStream might be pulled out of Message and used separately. + $body = new class($body, $requestCycle, $this->socketPool) implements InputStream + { + private $body; + private $bodySize = 0; + private $requestCycle; + private $socketPool; + private $successfulEnd = false; + + public function __construct(InputStream $body, RequestCycle $requestCycle, HttpSocketPool $socketPool) + { + $this->body = $body; + $this->requestCycle = $requestCycle; + $this->socketPool = $socketPool; + } + + public function read(): Promise + { + $promise = $this->body->read(); + $promise->onResolve(function ($error, $value) { + if ($value !== null) { + $this->bodySize += \strlen($value); + $maxBytes = $this->requestCycle->options[Client::OP_MAX_BODY_BYTES]; + if ($maxBytes !== 0 && $this->bodySize >= $maxBytes) { + $this->requestCycle->bodyTooLarge = true; + } + } elseif ($error === null) { + $this->successfulEnd = true; + } + }); + + return $promise; + } + + public function __destruct() + { + if (!$this->successfulEnd && $this->requestCycle->socket) { + $this->socketPool->clear($this->requestCycle->socket); + $socket = $this->requestCycle->socket; + $this->requestCycle->socket = null; + $socket->close(); + } + } + }; + + $response = new class($parserResult["protocol"], $parserResult["status"], $parserResult["reason"], $parserResult["headers"], $body, $requestCycle->request, $requestCycle->previousResponse, new MetaInfo($connectionInfo)) implements Response + { + private $protocolVersion; + private $status; + private $reason; + private $request; + private $previousResponse; + private $headers; + private $body; + private $metaInfo; + + public function __construct( + string $protocolVersion, + int $status, + string $reason, + array $headers, + InputStream $body, + Request $request, + Response $previousResponse = null, + MetaInfo $metaInfo + ) + { + $this->protocolVersion = $protocolVersion; + $this->status = $status; + $this->reason = $reason; + $this->headers = $headers; + $this->body = new Message($body); + $this->request = $request; + $this->previousResponse = $previousResponse; + $this->metaInfo = $metaInfo; + } + + public function getProtocolVersion(): string + { + return $this->protocolVersion; + } + + public function getStatus(): int + { + return $this->status; + } + + public function getReason(): string + { + return $this->reason; + } + + public function getRequest(): Request + { + return $this->request; + } + + public function getOriginalRequest(): Request + { + if (empty($this->previousResponse)) { + return $this->request; + } + + return $this->previousResponse->getOriginalRequest(); + } + + public function getPreviousResponse() + { + return $this->previousResponse; + } + + public function hasHeader(string $field): bool + { + return isset($this->headers[\strtolower($field)]); + } + + public function getHeader(string $field) + { + return $this->headers[\strtolower($field)][0] ?? null; + } + + public function getHeaderArray(string $field): array + { + return $this->headers[\strtolower($field)] ?? []; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function getBody(): Message + { + return $this->body; + } + + public function getMetaInfo(): MetaInfo + { + return $this->metaInfo; + } + }; + + if ($response->hasHeader('Set-Cookie')) { + $requestDomain = $requestCycle->uri->getHost(); + $cookies = $response->getHeaderArray('Set-Cookie'); + + foreach ($cookies as $rawCookieStr) { + $this->storeResponseCookie($requestDomain, $rawCookieStr); + } + } + + return $response; + } + + private function determineCompressionEncoding(array $responseHeaders): int + { + if (!$this->hasZlib) { + return 0; + } + + if (!isset($responseHeaders["content-encoding"])) { + return 0; + } + + $contentEncodingHeader = \trim(\current($responseHeaders["content-encoding"])); + + if (strcasecmp($contentEncodingHeader, 'gzip') === 0) { + return \ZLIB_ENCODING_GZIP; + } + + if (strcasecmp($contentEncodingHeader, 'deflate') === 0) { + return \ZLIB_ENCODING_DEFLATE; + } + + return 0; + } + + private function storeResponseCookie(string $requestDomain, string $rawCookieStr) + { + try { + $cookie = Cookie::fromString($rawCookieStr); + + if (!$cookie->getDomain()) { + $cookie = $cookie->withDomain($requestDomain); + } else { + // https://tools.ietf.org/html/rfc6265#section-4.1.2.3 + $cookieDomain = $cookie->getDomain(); + + // If a domain is set, left dots are ignored and it's always a wildcard + $cookieDomain = \ltrim($cookieDomain, "."); + + if ($cookieDomain !== $requestDomain) { + // ignore cookies on domains that are public suffixes + if (PublicSuffixList::isPublicSuffix($cookieDomain)) { + return; + } + + // cookie origin would not be included when sending the cookie + if (\substr($requestDomain, 0, -\strlen($cookieDomain) - 1) . "." . $cookieDomain !== $requestDomain) { + return; + } + } + + // always add the dot, it's used internally for wildcard matching when an explicit domain is sent + $cookie = $cookie->withDomain("." . $cookieDomain); + } + + $this->cookieJar->store($cookie); + } catch (CookieFormatException $e) { + // Ignore malformed Set-Cookie headers + } + } + + private function shouldCloseSocketAfterResponse(Response $response) + { + $request = $response->getRequest(); + + $requestConnHeader = $request->getHeader('Connection'); + $responseConnHeader = $response->getHeader('Connection'); + + if ($requestConnHeader && !strcasecmp($requestConnHeader, 'close')) { + return true; + } elseif ($responseConnHeader && !strcasecmp($responseConnHeader, 'close')) { + return true; + } elseif ($response->getProtocolVersion() === '1.0' && !$responseConnHeader) { + return true; + } + + return false; + } + + private function withCancellation(Promise $promise, CancellationToken $cancellationToken): Promise + { + $deferred = new Deferred; + $newPromise = $deferred->promise(); + + $promise->onResolve(function ($error, $value) use (&$deferred) { + if ($deferred) { + if ($error) { + $deferred->fail($error); + $deferred = null; + } else { + $deferred->resolve($value); + $deferred = null; + } + } + }); + + $cancellationSubscription = $cancellationToken->subscribe(function ($e) use (&$deferred) { + if ($deferred) { + $deferred->fail($e); + $deferred = null; + } + }); + + $newPromise->onResolve(function () use ($cancellationToken, $cancellationSubscription) { + $cancellationToken->unsubscribe($cancellationSubscription); + }); + + return $newPromise; + } + + private function getRedirectUri(Response $response) + { + if (!$response->hasHeader('Location')) { + return null; + } + + $request = $response->getRequest(); + + $status = $response->getStatus(); + $method = $request->getMethod(); + + if ($status < 300 || $status > 399 || $method === 'HEAD') { + return null; + } + + $requestUri = new Uri($request->getUri()); + $redirectLocation = $response->getHeader('Location'); + + try { + return $requestUri->resolve($redirectLocation); + } catch (InvalidUriException $e) { + return null; + } + } + + /** + * Clients must not add a Referer header when leaving an unencrypted resource and redirecting to an encrypted + * resource. + * + * @param Request $request + * @param string $refererUri + * @param string $newUri + * + * @return Request + * + * @link http://www.w3.org/Protocols/rfc2616/rfc2616-sec15.html#sec15.1.3 + */ + private function assignRedirectRefererHeader(Request $request, string $refererUri, string $newUri): Request + { + $refererIsEncrypted = (\stripos($refererUri, 'https') === 0); + $destinationIsEncrypted = (\stripos($newUri, 'https') === 0); + + if (!$refererIsEncrypted || $destinationIsEncrypted) { + return $request->withHeader('Referer', $refererUri); + } + + return $request->withoutHeader('Referer'); + } + + /** + * Set multiple options at once. + * + * @param array $options An array of the form [OP_CONSTANT => $value] + * + * @throws \Error On unknown option key or invalid value. + */ + public function setOptions(array $options) + { + foreach ($options as $option => $value) { + $this->setOption($option, $value); + } + } + + /** + * Set an option. + * + * @param string $option A Client option constant + * @param mixed $value The option value to assign + * + * @throws \Error On unknown option key or invalid value. + */ + public function setOption(string $option, $value) + { + $this->validateOption($option, $value); + $this->options[$option] = $value; + } +} diff --git a/src/API/Kitsu/Auth.php b/src/API/Kitsu/Auth.php index 743c1d95..7ff53ad3 100644 --- a/src/API/Kitsu/Auth.php +++ b/src/API/Kitsu/Auth.php @@ -73,14 +73,14 @@ class Auth { $config = $this->container->get('config'); $username = $config->get(['kitsu_username']); - try + // try { $auth = $this->model->authenticate($username, $password); } - catch (Exception $e) + /* catch (Exception $e) { return FALSE; - } + }*/ if (FALSE !== $auth) diff --git a/src/API/Kitsu/KitsuRequestBuilder.php b/src/API/Kitsu/KitsuRequestBuilder.php index a7a0f585..46e7bf97 100644 --- a/src/API/Kitsu/KitsuRequestBuilder.php +++ b/src/API/Kitsu/KitsuRequestBuilder.php @@ -16,10 +16,7 @@ namespace Aviat\AnimeClient\API\Kitsu; -use Aviat\AnimeClient\API\{ - APIRequestBuilder, - Kitsu as K -}; +use Aviat\AnimeClient\API\APIRequestBuilder; class KitsuRequestBuilder extends APIRequestBuilder { @@ -27,7 +24,7 @@ class KitsuRequestBuilder extends APIRequestBuilder { * The base url for api requests * @var string $base_url */ - protected $baseUrl = "https://kitsu.io/api/edge/"; + protected $baseUrl = 'https://kitsu.io/api/edge/'; /** * HTTP headers to send with every request @@ -36,9 +33,9 @@ class KitsuRequestBuilder extends APIRequestBuilder { */ protected $defaultHeaders = [ 'User-Agent' => "Tim's Anime Client/4.0", - 'Accept-Encoding' => 'application/vnd.api+json', + 'Accept' => 'application/vnd.api+json', 'Content-Type' => 'application/vnd.api+json', - 'client_id' => 'dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd', - 'client_secret' => '54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151', + 'CLIENT_ID' => 'dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd', + 'CLIENT_SECRET' => '54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151', ]; } \ No newline at end of file diff --git a/src/API/Kitsu/KitsuTrait.php b/src/API/Kitsu/KitsuTrait.php index fe2167b7..215c9bcc 100644 --- a/src/API/Kitsu/KitsuTrait.php +++ b/src/API/Kitsu/KitsuTrait.php @@ -18,12 +18,17 @@ namespace Aviat\AnimeClient\API\Kitsu; use const Aviat\AnimeClient\SESSION_SEGMENT; -use function Amp\wait; +use function Amp\Promise\wait; -use Amp\Artax\{Client, Request}; +use Amp\Artax\Request; use Aviat\AnimeClient\AnimeClient; -use Aviat\AnimeClient\API\{FailedResponseException, Kitsu as K}; +use Aviat\AnimeClient\API\{ + FailedResponseException, + HummingbirdClient, + Kitsu as K +}; use Aviat\Ion\Json; +use Aviat\Ion\JsonException; trait KitsuTrait { @@ -80,24 +85,29 @@ trait KitsuTrait { $token = $cacheItem->get(); } - if ( ! is_null($token)) + if (NULL !== $token) { $request = $request->setAuth('bearer', $token); } if (array_key_exists('form_params', $options)) { - $request->setFormFields($options['form_params']); + $request = $request->setFormFields($options['form_params']); } if (array_key_exists('query', $options)) { - $request->setQuery($options['query']); + $request = $request->setQuery($options['query']); } if (array_key_exists('body', $options)) { - $request->setJsonBody($options['body']); + $request = $request->setJsonBody($options['body']); + } + + if (array_key_exists('headers', $options)) + { + $request = $request->setHeaders($options['headers']); } return $request->getFullRequest(); @@ -113,9 +123,24 @@ trait KitsuTrait { */ private function getResponse(string $type, string $url, array $options = []) { + $logger = NULL; + if ($this->getContainer()) + { + $logger = $this->container->getLogger('kitsu-request'); + } + $request = $this->setUpRequest($type, $url, $options); - $response = wait((new Client)->request($request)); + $response = wait((new HummingbirdClient)->request($request)); + + if ($logger) + { + $logger->debug('Kitsu API Response', [ + 'response_status' => $response->getStatus(), + 'request_headers' => $response->getOriginalRequest()->getHeaders(), + 'response_headers' => $response->getHeaders() + ]); + } return $response; } @@ -126,6 +151,8 @@ trait KitsuTrait { * @param string $type * @param string $url * @param array $options + * @throws \Aviat\Ion\JsonException + * @throws FailedResponseException * @return array */ private function request(string $type, string $url, array $options = []): array @@ -148,7 +175,16 @@ trait KitsuTrait { throw new FailedResponseException('Failed to get the proper response from the API'); } - return Json::decode($response->getBody(), TRUE); + try + { + return Json::decode(wait($response->getBody()), TRUE); + } + catch (JsonException $e) + { + print_r($e); + die(); + } + } /** @@ -198,7 +234,7 @@ trait KitsuTrait { } } - return JSON::decode($response->getBody(), TRUE); + return JSON::decode(wait($response->getBody()), TRUE); } /** diff --git a/src/API/Kitsu/ListItem.php b/src/API/Kitsu/ListItem.php index bd852daa..71bef41a 100644 --- a/src/API/Kitsu/ListItem.php +++ b/src/API/Kitsu/ListItem.php @@ -18,8 +18,10 @@ namespace Aviat\AnimeClient\API\Kitsu; use const Aviat\AnimeClient\SESSION_SEGMENT; +use function Amp\Promise\wait; + use Amp\Artax\Request; -use Aviat\AnimeClient\API\AbstractListItem; +use Aviat\AnimeClient\API\{AbstractListItem, HummingbirdClient}; use Aviat\Ion\Di\ContainerAware; use Aviat\Ion\Json; use RuntimeException; @@ -113,21 +115,21 @@ class ListItem extends AbstractListItem { public function get(string $id): array { $authHeader = $this->getAuthHeader(); - + $request = $this->requestBuilder->newRequest('GET', "library-entries/{$id}") ->setQuery([ 'include' => 'media,media.genres,media.mappings' ]); - + if ($authHeader !== FALSE) { $request = $request->setHeader('Authorization', $authHeader); } - + $request = $request->getFullRequest(); - $response = \Amp\wait((new \Amp\Artax\Client)->request($request)); - return Json::decode($response->getBody()); + $response = wait((new HummingbirdClient)->request($request)); + return Json::decode(wait($response->getBody())); } public function update(string $id, array $data): Request @@ -140,15 +142,15 @@ class ListItem extends AbstractListItem { 'attributes' => $data ] ]; - + $request = $this->requestBuilder->newRequest('PATCH', "library-entries/{$id}") ->setJsonBody($requestData); - + if ($authHeader !== FALSE) { $request = $request->setHeader('Authorization', $authHeader); } - + return $request->getFullRequest(); } } \ No newline at end of file diff --git a/src/API/Kitsu/Model.php b/src/API/Kitsu/Model.php index 74d9917b..f25e9be2 100644 --- a/src/API/Kitsu/Model.php +++ b/src/API/Kitsu/Model.php @@ -16,9 +16,9 @@ namespace Aviat\AnimeClient\API\Kitsu; -use function Amp\{all, wait}; +use function Amp\Promise\wait; -use Amp\Artax\{Client, Request}; +use Amp\Artax\Request; use Aviat\AnimeClient\API\{ CacheTrait, JsonAPI, @@ -101,22 +101,37 @@ class Model { */ public function authenticate(string $username, string $password) { + // K::AUTH_URL $response = $this->getResponse('POST', K::AUTH_URL, [ - 'headers' => [], + 'headers' => [ + 'accept' => NULL, + 'Content-type' => 'application/x-www-form-urlencoded', + 'client_id' => NULL, + 'client_secret' => NULL + ], 'form_params' => [ 'grant_type' => 'password', 'username' => $username, 'password' => $password ] ]); - - $data = Json::decode((string)$response->getBody()); + $data = Json::decode(wait($response->getBody())); + + //dump($response); if (array_key_exists('access_token', $data)) { return $data; } + if (array_key_exists('error', $data)) + { + dump($data['error']); + dump($response); + die(); + //throw new \Exception('auth error'); + } + return FALSE; } @@ -129,14 +144,17 @@ class Model { public function reAuthenticate(string $token) { $response = $this->getResponse('POST', K::AUTH_URL, [ - 'headers' => [], + 'headers' => [ + 'Accept-encoding' => '*' + + ], 'form_params' => [ 'grant_type' => 'refresh_token', 'refresh_token' => $token ] ]); - $data = Json::decode((string)$response->getBody()); + $data = Json::decode(wait($response->getBody())); if (array_key_exists('access_token', $data)) { @@ -186,7 +204,7 @@ class Model { */ public function getCharacter(string $slug): array { - $data = $this->getRequest('/characters', [ + $data = $this->getRequest('characters', [ 'query' => [ 'filter' => [ 'slug' => $slug, @@ -211,7 +229,7 @@ class Model { public function getUserData(string $username): array { // $userId = $this->getUserIdByUsername($username); - $data = $this->getRequest("/users", [ + $data = $this->getRequest("users", [ 'query' => [ 'filter' => [ 'name' => $username, @@ -425,7 +443,7 @@ class Model { foreach($responses as $response) { - $data = Json::decode($response->getBody()); + $data = Json::decode($response); $output = array_merge_recursive($output, $data); } @@ -522,7 +540,6 @@ class Model { */ public function getRawAnimeList(string $status): array { - $options = [ 'filter' => [ 'user_id' => $this->getUserIdByUsername($this->getUsername()), @@ -689,7 +706,7 @@ class Model { foreach($responses as $response) { - $data = Json::decode($response->getBody()); + $data = Json::decode($response); $output = array_merge_recursive($output, $data); } diff --git a/src/API/MAL/ListItem.php b/src/API/MAL/ListItem.php index e46199cf..27de85b3 100644 --- a/src/API/MAL/ListItem.php +++ b/src/API/MAL/ListItem.php @@ -94,9 +94,9 @@ class ListItem { $config = $this->container->get('config'); $xml = XML::toXML(['entry' => $data]); - $body = (new FormBody) - ->addField('id', $id) - ->addField('data', $xml); + $body = new FormBody(); + $body->addField('id', $id); + $body->addField('data', $xml); return $this->requestBuilder->newRequest('POST', "{$type}list/update/{$id}.xml") ->setFormFields([ diff --git a/src/API/MAL/MALTrait.php b/src/API/MAL/MALTrait.php index 32bd299c..b82ffa2c 100644 --- a/src/API/MAL/MALTrait.php +++ b/src/API/MAL/MALTrait.php @@ -16,15 +16,13 @@ namespace Aviat\AnimeClient\API\MAL; -use Amp\Artax\{Client, FormBody, Request}; +use function Amp\Promise\wait; + use Aviat\AnimeClient\API\{ + HummingbirdClient, MAL as M, - APIRequestBuilder, XML }; -use Aviat\AnimeClient\API\MALRequestBuilder; -use Aviat\Ion\Json; -use InvalidArgumentException; trait MALTrait { @@ -82,12 +80,12 @@ trait MALTrait { if (array_key_exists('query', $options)) { - $request->setQuery($options['query']); + $request = $request->setQuery($options['query']); } if (array_key_exists('body', $options)) { - $request->setBody($options['body']); + $request = $request->setBody($options['body']); } return $request->getFullRequest(); @@ -110,14 +108,14 @@ trait MALTrait { } $request = $this->setUpRequest($type, $url, $options); - $response = \Amp\wait((new Client)->request($request)); + $response = wait((new HummingbirdClient)->request($request)); $logger->debug('MAL api response', [ 'status' => $response->getStatus(), 'reason' => $response->getReason(), 'body' => $response->getBody(), - 'headers' => $response->getAllHeaders(), - 'requestHeaders' => $request->getAllHeaders(), + 'headers' => $response->getHeaders(), + 'requestHeaders' => $request->getHeaders(), ]); return $response; @@ -149,7 +147,7 @@ trait MALTrait { } } - return XML::toArray((string) $response->getBody()); + return XML::toArray(wait($response->getBody())); } /** diff --git a/src/API/ParallelAPIRequest.php b/src/API/ParallelAPIRequest.php index a05ebec0..b7c1dc95 100644 --- a/src/API/ParallelAPIRequest.php +++ b/src/API/ParallelAPIRequest.php @@ -16,21 +16,21 @@ namespace Aviat\AnimeClient\API; -use Amp; -use Amp\Artax\Client; +use function Amp\call; +use function Amp\Promise\{all, wait}; /** * Class to simplify making and validating simultaneous requests */ class ParallelAPIRequest { - + /** * Set of requests to make in parallel * * @var array */ protected $requests = []; - + /** * Add a request * @@ -45,11 +45,11 @@ class ParallelAPIRequest { $this->requests[$key] = $request; return $this; } - + $this->requests[] = $request; return $this; } - + /** * Add multiple requests * @@ -61,22 +61,27 @@ class ParallelAPIRequest { array_walk($requests, [$this, 'addRequest']); return $this; } - + /** * Actually make the requests * - * @param bool $allowFailingRequests - * @return array + * @return array */ - public function makeRequests(bool $allowFailingRequests = FALSE): array + public function makeRequests(): array { - $client = new Client(); - $promises = $client->requestMulti($this->requests); - - $func = ($allowFailingRequests) ? '\Amp\some' : '\Amp\all'; - - $results = Amp\wait($func($promises)); - - return $results; + $client = new HummingbirdClient(); + $promises = []; + + foreach ($this->requests as $key => $url) + { + $promises[$key] = call(function () use ($client, $url) { + $response = yield $client->request($url); + $body = yield $response->getBody(); + + return $body; + }); + } + + return wait(all($promises)); } } \ No newline at end of file diff --git a/src/Controller/AnimeCollection.php b/src/Controller/AnimeCollection.php index 2d3c1624..0bb096a8 100644 --- a/src/Controller/AnimeCollection.php +++ b/src/Controller/AnimeCollection.php @@ -21,7 +21,6 @@ use Aviat\AnimeClient\Model\{ Anime as AnimeModel, AnimeCollection as AnimeCollectionModel }; -use Aviat\AnimeClient\UrlGenerator; use Aviat\Ion\Di\ContainerInterface; /** diff --git a/src/Controller/Index.php b/src/Controller/Index.php index 6eb48148..439522c5 100644 --- a/src/Controller/Index.php +++ b/src/Controller/Index.php @@ -16,11 +16,10 @@ namespace Aviat\AnimeClient\Controller; -use function Amp\wait; +use function Amp\Promise\wait; -use Amp\Artax\Client; use Aviat\AnimeClient\Controller as BaseController; -use Aviat\AnimeClient\API\JsonAPI; +use Aviat\AnimeClient\API\{HummingbirdClient, JsonAPI}; use Aviat\Ion\View\HtmlView; /** @@ -109,13 +108,17 @@ class Index extends BaseController { $username = $this->config->get(['kitsu_username']); $model = $this->container->get('kitsu-model'); $data = $model->getUserData($username); - $orgData = JsonAPI::organizeData($data); + $orgData = JsonAPI::organizeData($data)[0]; + $rels = $orgData['relationships'] ?? []; + $favorites = array_key_exists('favorites', $rels) ? $rels['favorites'] : []; + + $this->outputHTML('me', [ 'title' => 'About ' . $this->config->get('whose_list'), - 'data' => $orgData[0], - 'attributes' => $orgData[0]['attributes'], - 'relationships' => $orgData[0]['relationships'], - 'favorites' => $this->organizeFavorites($orgData[0]['relationships']['favorites']), + 'data' => $orgData, + 'attributes' => $orgData['attributes'], + 'relationships' => $rels, + 'favorites' => $this->organizeFavorites($favorites), ]); } @@ -151,14 +154,14 @@ class Index extends BaseController { return; } - $promise = (new Client)->request($kitsuUrl); + $promise = (new HummingbirdClient)->request($kitsuUrl); $response = wait($promise); - $data = (string) $response->getBody(); + $data = wait($response->getBody()); $baseSavePath = $this->config->get('img_cache_path'); file_put_contents("{$baseSavePath}/{$type}/{$id}.{$ext}", $data); header('Content-type: ' . $response->getHeader('content-type')[0]); - echo (string) $response->getBody(); + echo $data; } private function organizeFavorites(array $rawfavorites): array diff --git a/src/Model/Anime.php b/src/Model/Anime.php index eccc2b3f..43c18d35 100644 --- a/src/Model/Anime.php +++ b/src/Model/Anime.php @@ -158,9 +158,9 @@ class Anime extends API { $requester->addRequest($this->kitsuModel->createListItem($data), 'kitsu'); - $results = $requester->makeRequests(TRUE); + $results = $requester->makeRequests(); - return count($results[1]) > 0; + return count($results) > 0; } /** @@ -180,11 +180,13 @@ class Anime extends API { $requester->addRequest($this->kitsuModel->updateListItem($data), 'kitsu'); - $results = $requester->makeRequests(TRUE); + $results = $requester->makeRequests(); + $body = Json::decode($results['kitsu']); + $statusCode = (array_key_exists('error', $body)) ? 400: 200; return [ - 'body' => Json::decode($results[1]['kitsu']->getBody()), - 'statusCode' => $results[1]['kitsu']->getStatus() + 'body' => Json::decode($results['kitsu']), + 'statusCode' => $statusCode ]; } @@ -206,8 +208,8 @@ class Anime extends API { $requester->addRequest($this->kitsuModel->deleteListItem($id), 'kitsu'); - $results = $requester->makeRequests(TRUE); + $results = $requester->makeRequests(); - return count($results[1]) > 0; + return count($results) > 0; } } \ No newline at end of file diff --git a/src/Model/Manga.php b/src/Model/Manga.php index 1fcad1a0..dbd5bc89 100644 --- a/src/Model/Manga.php +++ b/src/Model/Manga.php @@ -131,9 +131,9 @@ class Manga extends API $requester->addRequest($this->kitsuModel->createListItem($data), 'kitsu'); - $results = $requester->makeRequests(TRUE); + $results = $requester->makeRequests(); - return count($results[1]) > 0; + return count($results) > 0; } /** @@ -153,11 +153,13 @@ class Manga extends API $requester->addRequest($this->kitsuModel->updateListItem($data), 'kitsu'); - $results = $requester->makeRequests(TRUE); + $results = $requester->makeRequests(); + $body = Json::decode($results['kitsu']); + $statusCode = (array_key_exists('error', $body)) ? 400: 200; return [ - 'body' => Json::decode($results[1]['kitsu']->getBody()), - 'statusCode' => $results[1]['kitsu']->getStatus() + 'body' => Json::decode($results['kitsu']), + 'statusCode' => $statusCode ]; } @@ -179,9 +181,9 @@ class Manga extends API $requester->addRequest($this->kitsuModel->deleteListItem($id), 'kitsu'); - $results = $requester->makeRequests(TRUE); + $results = $requester->makeRequests(); - return count($results[1]) > 0; + return count($results) > 0; } /** diff --git a/tests/API/APIRequestBuilderTest.php b/tests/API/APIRequestBuilderTest.php index 6b92eacf..1f0020dc 100644 --- a/tests/API/APIRequestBuilderTest.php +++ b/tests/API/APIRequestBuilderTest.php @@ -16,15 +16,14 @@ namespace Aviat\AnimeClient\Tests\API; -use Amp; -use Amp\Artax\Client; -use Aviat\AnimeClient\API\APIRequestBuilder; +use function Amp\Promise\wait; +use Aviat\AnimeClient\API\{APIRequestBuilder, HummingbirdClient}; use Aviat\Ion\Json; use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; class APIRequestBuilderTest extends TestCase { - + protected $builder; public function setUp() @@ -42,8 +41,8 @@ class APIRequestBuilderTest extends TestCase { { $request = $this->builder->newRequest('GET', 'gzip') ->getFullRequest(); - $response = Amp\wait((new Client)->request($request)); - $body = Json::decode($response->getBody()); + $response = wait((new HummingbirdClient)->request($request)); + $body = Json::decode(wait($response->getBody())); $this->assertEquals(1, $body['gzipped']); } @@ -60,8 +59,8 @@ class APIRequestBuilderTest extends TestCase { ->setBasicAuth('username', 'password') ->getFullRequest(); - $response = Amp\wait((new Client)->request($request)); - $body = Json::decode($response->getBody()); + $response = wait((new HummingbirdClient)->request($request)); + $body = Json::decode(wait($response->getBody())); $this->assertEquals('Basic dXNlcm5hbWU6cGFzc3dvcmQ=', $body['headers']['Authorization']); } @@ -88,8 +87,8 @@ class APIRequestBuilderTest extends TestCase { ->setQuery($query) ->getFullRequest(); - $response = Amp\wait((new Client)->request($request)); - $body = Json::decode($response->getBody()); + $response = wait((new HummingbirdClient)->request($request)); + $body = Json::decode(wait($response->getBody())); $this->assertEquals($expected, $body['args']); } @@ -105,8 +104,8 @@ class APIRequestBuilderTest extends TestCase { ->setFormFields($formValues) ->getFullRequest(); - $response = Amp\wait((new Client)->request($request)); - $body = Json::decode($response->getBody()); + $response = wait((new HummingbirdClient)->request($request)); + $body = Json::decode(wait($response->getBody())); $this->assertEquals($formValues, $body['form']); } @@ -129,8 +128,8 @@ class APIRequestBuilderTest extends TestCase { ->setJsonBody($data) ->getFullRequest(); - $response = Amp\wait((new Client)->request($request)); - $body = Json::decode($response->getBody()); + $response = wait((new HummingbirdClient)->request($request)); + $body = Json::decode(wait($response->getBody())); $this->assertEquals($data, $body['json']); } diff --git a/tests/API/MAL/MALTraitTest.php b/tests/API/MAL/MALTraitTest.php index 10ec1151..5c239eb9 100644 --- a/tests/API/MAL/MALTraitTest.php +++ b/tests/API/MAL/MALTraitTest.php @@ -46,6 +46,5 @@ class MALTraitTest extends AnimeClientTestCase { ]); $this->assertInstanceOf(\Amp\Artax\Request::class, $request); $this->assertEquals($request->getUri(), 'https://myanimelist.net/api/foo?foo=bar'); - $this->assertEquals($request->getBody(), ''); } } \ No newline at end of file diff --git a/tests/AnimeClientTestCase.php b/tests/AnimeClientTestCase.php index 7c4e0804..4418e787 100644 --- a/tests/AnimeClientTestCase.php +++ b/tests/AnimeClientTestCase.php @@ -53,9 +53,9 @@ class AnimeClientTestCase extends TestCase { public static function setUpBeforeClass() { // Use mock session handler - $session_handler = new TestSessionHandler(); - session_set_save_handler($session_handler, TRUE); - self::$session_handler = $session_handler; + //$session_handler = new TestSessionHandler(); + //session_set_save_handler($session_handler, TRUE); + //self::$session_handler = $session_handler; // Remove test cache files $files = glob(_dir(TEST_DATA_DIR, 'cache', '*.json'));