diff --git a/CHANGELOG.md b/CHANGELOG.md index e74e1ad..bc3c277 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [1.2.0] - 2017-09-24 + +### Added +- `JWTClient` to create requests to the JIRA/Confluence (closes #3) +- Pagination (used by `JWTClient`) +- Note about using route helper in the `AppServiceProvider` to the README + +### Fixed +- Typos and code style issues +- TODO section in the README + ## [1.1.0] - 2017-09-08 ### Added @@ -28,7 +39,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Fixed - Package keywords at composer.json -[Unreleased]: https://github.com/brezzhnev/atlassian-connect-core/compare/v1.0.0...HEAD +[Unreleased]: https://github.com/brezzhnev/atlassian-connect-core/compare/v1.2.0...HEAD +[1.2.0]: https://github.com/brezzhnev/atlassian-connect-core/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/brezzhnev/atlassian-connect-core/compare/v1.0.2...v1.1.0 [1.0.2]: https://github.com/brezzhnev/atlassian-connect-core/compare/v1.0.1...v1.0.2 [1.0.1]: https://github.com/brezzhnev/atlassian-connect-core/compare/v1.0.0...v1.0.1 \ No newline at end of file diff --git a/README.md b/README.md index 0be7891..1f43795 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ You can use `Descriptor` facade to customize or create from scratch your own des For example, you can customize it by adding to the `app\Providers\AppServiceProvider` in `boot` section the following: -``` +``` php Descriptor::base() // base descriptor contents ->setScopes(['admin' , 'act_as_user']) ->withModules([ @@ -166,6 +166,27 @@ Descriptor::base() // base descriptor contents ->set('version', $this->getLatestPluginVersion()); ``` +> Warning: if you are using `route` helper in the `AppServiceProvider` you should have `RouteServiceProvider` defined above `AppServiceProvider` in your `app.php` config. + +### Performing requests + +In most of cases in development add-on for Atlassian Product you need to perform requests to the instance. + +For this case you should use `JWTClient`. It uses [GuzzleHttp](https://github.com/guzzle/guzzle) as HTTP client and +if you want to have custom handling (middlewares etc.) you can pass client instance to the constructor. + +#### Pagination + +If you want to send a request to an endpoint with pagination you should use `JWTClient::paginate` method. In most cases +you don't need to pass paginator instance to the `JWTClient` constructor because it will instantiate automatically by resolving +your Tenant product type (JIRA or Confluence), but you always can use specific paginator. + +There are two paginators: +* `JiraPaginator` +* `ConfluencePaginator` + +Of course you can extend `Paginator` class and create your own. + ### Console commands * `plugin:install` is a helper command that creates "dummy" tenant with fake data and publishes package resources (config, views, assets) @@ -179,11 +200,11 @@ Run the following in the package folder: vendor/bin/phpunit ``` -## TODOs +## TODO -* Add OAuth authentication method * Implement descriptor builder and validator -* Implement webhooks gateway +* Implement webhooks manager +* Take out pagination and make more abstract ## Security diff --git a/composer.json b/composer.json index 9b0670f..406c8b0 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "require": { "php": ">=7.0", "firebase/php-jwt": "^5.0", + "guzzlehttp/guzzle": "^6.3", "illuminate/support": "~5.5" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 131ebac..3114a11 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "hash": "f78f22c743e05364d5bc1294c6fd84b9", - "content-hash": "6a2b4d722ccc57c80a0073deaa9d9669", + "hash": "8d891dc3e126afab7eb8289248c51349", + "content-hash": "8a9402428c0a6a593c9a4f2e78fa5ff2", "packages": [ { "name": "doctrine/inflector", @@ -273,6 +273,187 @@ "homepage": "https://github.com/firebase/php-jwt", "time": "2017-06-27 22:17:23" }, + { + "name": "guzzlehttp/guzzle", + "version": "6.3.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "f4db5a78a5ea468d4831de7f0bf9d9415e348699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/f4db5a78a5ea468d4831de7f0bf9d9415e348699", + "reference": "f4db5a78a5ea468d4831de7f0bf9d9415e348699", + "shasum": "" + }, + "require": { + "guzzlehttp/promises": "^1.0", + "guzzlehttp/psr7": "^1.4", + "php": ">=5.5" + }, + "require-dev": { + "ext-curl": "*", + "phpunit/phpunit": "^4.0 || ^5.0", + "psr/log": "^1.0" + }, + "suggest": { + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.2-dev" + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "homepage": "http://guzzlephp.org/", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "rest", + "web service" + ], + "time": "2017-06-22 18:50:49" + }, + { + "name": "guzzlehttp/promises", + "version": "v1.3.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/a59da6cf61d80060647ff4d3eb2c03a2bc694646", + "reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646", + "shasum": "" + }, + "require": { + "php": ">=5.5.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.4-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + }, + "files": [ + "src/functions_include.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "time": "2016-12-20 10:07:11" + }, + { + "name": "guzzlehttp/psr7", + "version": "1.4.2", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "f5b8a8512e2b58b0071a7280e39f14f72e05d87c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/f5b8a8512e2b58b0071a7280e39f14f72e05d87c", + "reference": "f5b8a8512e2b58b0071a7280e39f14f72e05d87c", + "shasum": "" + }, + "require": { + "php": ">=5.4.0", + "psr/http-message": "~1.0" + }, + "provide": { + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.4-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + }, + "files": [ + "src/functions_include.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Schultze", + "homepage": "https://github.com/Tobion" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "request", + "response", + "stream", + "uri", + "url" + ], + "time": "2017-03-20 17:10:46" + }, { "name": "laravel/framework", "version": "v5.5.1", @@ -760,6 +941,56 @@ ], "time": "2017-02-14 16:28:37" }, + { + "name": "psr/http-message", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "time": "2016-08-06 14:39:51" + }, { "name": "psr/log", "version": "1.0.2", diff --git a/src/Exceptions/PaginationException.php b/src/Exceptions/PaginationException.php new file mode 100644 index 0000000..f0b4605 --- /dev/null +++ b/src/Exceptions/PaginationException.php @@ -0,0 +1,12 @@ + $addOnKey, + 'iss' => $issuer, 'iat' => time(), 'exp' => time() + 86400, 'qsh' => static::qsh($url, $method) @@ -68,7 +68,10 @@ public static function qsh($url, $method) { $method = strtoupper($method); $parts = parse_url($url); - $path = $parts['path']; + + // Remove "/wiki" part from the path for the Confluence + // Really, I didn't find this part in the docs, but it works + $path = str_replace('/wiki', '', $parts['path']); $canonicalQuery = ''; @@ -101,4 +104,35 @@ public static function qsh($url, $method) return $qsh; } + + /** + * JWT Authentication middleware for Guzzle + * + * @param string $issuer Add-on key in most cases + * @param string $secret Shared secret + * + * @return callable + */ + public static function authTokenMiddleware(string $issuer, string $secret) + { + return \GuzzleHttp\Middleware::mapRequest( + function (\Psr\Http\Message\RequestInterface $request) + use ($issuer, $secret) + { + // Generate token + $token = static::create( + (string) $request->getUri(), + $request->getMethod(), + $issuer, + $secret + ); + + return new \GuzzleHttp\Psr7\Request( + $request->getMethod(), + $request->getUri(), + array_merge($request->getHeaders(), ['Authorization' => 'JWT ' . $token]), + $request->getBody() + ); + }); + } } \ No newline at end of file diff --git a/src/Http/Clients/JWTClient.php b/src/Http/Clients/JWTClient.php new file mode 100644 index 0000000..e2a1507 --- /dev/null +++ b/src/Http/Clients/JWTClient.php @@ -0,0 +1,267 @@ +tenant = $tenant; + $this->paginator = $paginator; + $this->client = $client ?? $this->createClient(); + } + + /** + * Create a HTTP client + * + * @return Client + */ + private function createClient() + { + $stack = new \GuzzleHttp\HandlerStack(); + $stack->setHandler(new \GuzzleHttp\Handler\CurlHandler()); + + $stack->push(JWTHelper::authTokenMiddleware( + $this->tenant->addon_key, + $this->tenant->shared_secret + )); + + return new Client(['handler' => $stack]); + } + + /** + * Send a request to the instance + * + * @param string $method HTTP method + * @param string $url Request URL + * @param array $config HTTP Client config + * @param bool $paginate Where request is paginated + * + * @return array|string + */ + public function sendRequest(string $method = 'get', string $url, array $config = [], bool $paginate = false) + { + // If URL has host we shouldn't use tenant baseUrl + $baseUrl = preg_match('/^https?\:\/\//', $url) ? null : $this->tenant->base_url; + + // If base url contains path, Guzzle client will truncate it + // So we need to keep it and append to the request URL + $baseUrlPath = parse_url($baseUrl, PHP_URL_PATH); + $url = rtrim($baseUrlPath, '/') . '/' . ltrim($url, '/'); + + $clientConfig = array_merge(['base_uri' => $baseUrl], $config); + + if($paginate && $this->paginator instanceof Paginator) { + $this->paginator->setConfig([ + 'url' => $url, + 'client' => $this->client, + 'clientConfig' => $clientConfig + ]); + + return iterator_to_array($this->paginator); + } + + $response = $this->client->request($method, $url, $clientConfig); + + $contents = $response->getBody() + ->getContents(); + + if($contents && $decoded = json_decode($contents, true)) { + return $decoded; + } + + return $contents; + } + + /** + * Send a GET request + * + * @param string $url Request URL + * @param array $config HTTP client config + * + * @return array|string + */ + public function get(string $url, array $config = []) + { + return $this->sendRequest('get', $url, $config); + } + + /** + * Send a POST request + * + * @param string $url Request URL + * @param array $body Request body params + * @param array $config HTTP client config + * + * @return array|string + */ + public function post(string $url, array $body = [], array $config = []) + { + return $this->sendRequest('post', $url, array_merge($config, [ + 'json' => $body + ])); + } + + /** + * Send a PUT request + * + * @param string $url Request URL + * @param array $body Request body params + * @param array $config HTTP client config + * + * @return array|string + */ + public function put(string $url, array $body = [], array $config = []) + { + $this->sendRequest('put', $url, array_merge($config, [ + 'json' => $body + ])); + } + + /** + * Send a DELETE request + * + * @param string $url Request URL + * @param array $config HTTP client config + * + * @return array|string + */ + public function delete(string $url, array $config = []) + { + $this->sendRequest('delete', $url, $config); + } + + /** + * Paginate a request + * + * @param string $url Request URL + * @param array $config HTTP client config + * @param array $paginatorConfig Paginator config + * + * @return array + * + * @throws \RuntimeException + */ + public function paginate(string $url, array $config = [], array $paginatorConfig = []): array + { + $this->loadPaginator($paginatorConfig); + + return $this->sendRequest('get', $url, $config, true); + } + + /** + * Send a file + * + * @param \Illuminate\Http\UploadedFile $file + * @param string $url Request URL + * @param array $config HTTP client config + * + * @return array|string + */ + public function sendFile(\Illuminate\Http\UploadedFile $file, string $url, array $config = []) + { + // Save file to the temporary folder + $stored = $file->move('/tmp/', $file->getClientOriginalName()); + + $resource = fopen($stored->getRealPath(), 'r'); + + unlink($stored->getRealPath()); + + return $this->sendRequest('post', $url, array_merge($config, [ + 'headers' => ['X-Atlassian-Token' => 'nocheck'], + 'multipart' => [[ + 'name' => 'file', + 'contents' => $resource + ]] + ])); + } + + /** + * Returns HTTP client + * + * @return Client + */ + public function getClient() + { + return $this->client; + } + + /** + * Returns paginator + * + * @return Paginator + */ + public function paginator() + { + return $this->paginator; + } + + /** + * Instantiate paginator class or apply config to existing + * + * @param array $config + * + * @throws \Exception + */ + private function loadPaginator(array $config = []) + { + if($this->paginator) { + $this->paginator->setConfig($config); + + return; + } + + $alias = $this->tenant->product_type; + + if(!$paginatorClass = array_get($this->paginators(), $alias)) { + throw new \Exception('Class for the paginator alias "' . $alias . '" could not be found'); + } + + $this->paginator = new $paginatorClass($config); + } + + /** + * The paginators with aliases + * + * @return array + */ + private function paginators(): array + { + return [ + 'jira' => \AtlassianConnectCore\Pagination\JiraPaginator::class, + 'confluence' => \AtlassianConnectCore\Pagination\ConfluencePaginator::class + ]; + } +} \ No newline at end of file diff --git a/src/Pagination/ConfluencePaginator.php b/src/Pagination/ConfluencePaginator.php new file mode 100644 index 0000000..30454bd --- /dev/null +++ b/src/Pagination/ConfluencePaginator.php @@ -0,0 +1,46 @@ +setConfig($config); + } + + /** + * Apply config params + * + * @param array $config + */ + public function setConfig(array $config) + { + foreach ($config as $item => $value) + { + if(!in_array($item, $this->fillable)) { + throw new PaginationException('Property `' . $item . '` should be fillable'); + } + + if(!property_exists($this, $item)) { + throw new PaginationException('Property `' . $item . '` should be defined'); + } + + $this->{$item} = $value; + } + } + + /** + * Validate config params + */ + public function validateConfig() + { + if(!$this->type || !in_array($this->type, [self::TYPE_PAGE, self::TYPE_OFFSET, self::TYPE_NEXT])) { + throw new PaginationException('Pagination type is undefined or invalid'); + } + + if(!$this->client) { + throw new PaginationException('HTTP Client should be defined'); + } + + if(!$this->url) { + throw new PaginationException('Request URL should be defined'); + } + } + + /** + * Get items + * + * @return array + */ + public function getItems() + { + return $this->items; + } + + /** + * Get last response + * + * @return array + */ + public function getLastResponse() + { + return $this->lastResponse; + } + + /** + * @inheritdoc + */ + public function rewind() + { + $this->position = 0; + + // Offset starts from 1 for "Page" type of pagination + $this->offset = $this->offset ?? ($this->type === self::TYPE_PAGE ? 1 : 0); + + // Prepare for the first fetching + $this->prepareNextPage(); + } + + /** + * @inheritdoc + */ + public function current() + { + return $this->items[$this->position]; + } + + /** + * @inheritdoc + */ + public function key() + { + return $this->position; + } + + /** + * @inheritdoc + */ + public function next() + { + ++$this->position; + } + + /** + * @inheritdoc + */ + public function valid() + { + $isFetched = array_key_exists($this->position, $this->items); + + // Check for reaching ends, firstly we need to check the equality between total value and current position + // For the NEXT type, next link should have appeared in the response + $isReached = $this->isReachedTotal() || $this->isNextTypeReached(); + + // Valid until total number not reached, requested item already fetched or response isn't absent + return $isFetched || (!$isReached && count($this->fetchPage($this->position)) > 0); + } + + /** + * Whether position is reached to total number of items + * + * @return bool + */ + protected function isReachedTotal() + { + if($this->total === null) { + return false; + } + + return $this->position === $this->total; + } + + /** + * Whether position is reached of the NEXT type of pagination + * + * @return bool + */ + protected function isNextTypeReached() + { + if($this->type !== self::TYPE_NEXT || $this->position === 0) { + return false; + } + + return !array_has($this->lastResponse, $this->nextKey); + } + + /** + * Fetch a page and retrieve items + * + * @param int $position Position of iteration (item number) + * + * @return array Fetched items + */ + protected function fetchPage($position): array + { + if(array_key_exists($position, $this->items)) { + return $this->items[$position]; + } + + $this->validateConfig(); + + $this->fetchedCount = 0; + + $response = $this->sendRequest($this->url, $this->clientConfig); + + $this->lastResponse = $response; + + // If duplicated response exist it means that forever loop there is a place to be + // So we need to abort further fetches + if($this->preventDuplicatedResponse()) { + return []; + } + + $items = array_get($response, $this->itemsKey, []); + + $this->fetchedCount = count($items); + + $this->items = array_merge($this->items, $items); + + $this->grabTotalCount(); + + $this->prepareNextPage(); + + return $items; + } + + /** + * Grab total count value from response + */ + protected function grabTotalCount() + { + if($this->total !== null || !$this->totalKey) { + return; + } + + $this->total = array_get($this->lastResponse, $this->totalKey); + } + + /** + * Prepare paginator for the next page fetching + */ + protected function prepareNextPage() + { + $this->increment(); + + if($this->type === self::TYPE_NEXT) { + + // Check for "next" key containing next page URL + if(strlen($this->nextKey) && array_has($this->lastResponse, $this->nextKey)) { + $this->url = array_get($this->lastResponse, $this->nextKey); + $this->clientConfig = $this->mergeClientConfig(['query' => $this->extractQueryParams($this->url)]); + } + } + else { + + // For other types of pagination we just increment offset key + $params = ['query' => [ + $this->perPageKey => $this->perPage, + $this->offsetKey => $this->offset + ]]; + } + + $this->clientConfig = $this->mergeClientConfig($params ?? []); + } + + /** + * Send a request and return contents + * + * @param string $url Request URL + * @param array $config Client config + * + * @return array + */ + protected function sendRequest(string $url, array $config): array + { + $response = $this->client->get($url, $config); + + $contents = $response + ->getBody() + ->getContents(); + + return \GuzzleHttp\json_decode($contents, true); + } + + /** + * Increment values for the next page fetching + */ + protected function increment() + { + if($this->type === self::TYPE_OFFSET) { + $this->offset += $this->fetchedCount; + } + else { + $this->offset++; + } + } + + /** + * Add request params by merge + * + * @param array $params + * + * @return array + */ + protected function mergeClientConfig(array $params): array + { + return array_merge($this->clientConfig, $params); + } + + /** + * Check for duplicated response + * + * @return bool Whether response is duplicated + */ + protected function preventDuplicatedResponse() + { + $hash = $this->hashResponse($this->lastResponse); + + if(in_array($hash, $this->hashedResponses)) { + return true; + } + + $this->hashedResponses[] = $hash; + + return false; + } + + /** + * Hash a response + * + * @param mixed $response + * + * @return int + */ + protected function hashResponse($response) + { + if(!is_string($response)) { + $response = json_encode($response); + } + + return md5($response); + } + + /** + * Extract query params from the URL to an associative array + * + * @param string $url + * + * @return array + */ + protected function extractQueryParams(string $url): array + { + parse_str(parse_url($url, PHP_URL_QUERY), $params); + + return $params; + } +} \ No newline at end of file diff --git a/tests/Clients/JWTClientTest.php b/tests/Clients/JWTClientTest.php new file mode 100644 index 0000000..aceb4ca --- /dev/null +++ b/tests/Clients/JWTClientTest.php @@ -0,0 +1,321 @@ + true, + 'watchingEnabled' => true, + 'timeTrackingConfiguration' => [ + 'workingHoursPerDay' => 8.0, + 'workingDaysPerWeek' => 5.0 + ], + ]; + + $client = $this->createClient([ + new \GuzzleHttp\Psr7\Response(200, [], json_encode($expected)) + ]); + + $actual = $client->sendRequest('get', '/rest/api/2/configuration'); + + static::assertInstanceOf(\GuzzleHttp\Client::class, $client->getClient()); + static::assertEquals($actual, $expected); + } + + /** + * @expectedException \GuzzleHttp\Exception\RequestException + */ + public function testSendRequestError() + { + $client = $this->createClient([ + new \GuzzleHttp\Exception\RequestException( + 'Error Communicating with Server', + new \GuzzleHttp\Psr7\Request('GET', 'test') + ) + ]); + + $client->sendRequest('get', '/rest/api/2/configuration'); + } + + public function testGet() + { + $expected = ['test' => true]; + + $client = $this->createClient([ + new \GuzzleHttp\Psr7\Response(200, [], json_encode($expected)) + ]); + + $actual = $client->get('/rest/api/2/configuration'); + + static::assertEquals($actual, $expected); + } + + public function testPost() + { + $expected = ['test' => true]; + + $client = $this->createClient([ + new \GuzzleHttp\Psr7\Response(200, [], json_encode($expected)) + ]); + + $actual = $client->post('/rest/api/2/configuration', ['body' => 'item']); + + static::assertEquals($actual, $expected); + } + + public function testPut() + { + $expected = ['test' => true]; + + $client = $this->createClient([ + new \GuzzleHttp\Psr7\Response(200, [], json_encode($expected)) + ]); + + $actual = $client->put('/rest/api/2/configuration', ['body' => 'item']); + + static::assertEmpty($actual); + } + + public function testDelete() + { + $expected = ['test' => true]; + + $client = $this->createClient([ + new \GuzzleHttp\Psr7\Response(200, [], json_encode($expected)) + ]); + + $actual = $client->delete('/rest/api/2/configuration'); + + static::assertEmpty($actual); + } + + public function testSendFile() + { + $expected = ['test' => true]; + + $client = $this->createClient([ + new \GuzzleHttp\Psr7\Response(200, [], json_encode($expected)) + ]); + + $file = \Illuminate\Http\UploadedFile::fake() + ->image('avatar.png'); + + $actual = $client->sendFile($file, '/rest/api/2/configuration'); + + static::assertEquals($actual, $expected); + } + + /** + * @covers JWTClient::loadPaginator + * @covers JWTClient::paginators + */ + public function testPaginateWithJiraTenant() + { + $responses = [ + [ + 'offset' => 0, + 'limit' => 2, + 'total' => 4, + 'records' => [ + [ + 'id' => 1, + 'field' => 'value' + ], + [ + 'id' => 2, + 'field' => 'value' + ] + ], + ], + [ + 'offset' => 2, + 'limit' => 2, + 'total' => 4, + 'records' => [ + [ + 'id' => 3, + 'field' => 'value' + ], + [ + 'id' => 4, + 'field' => 'value' + ] + ], + ] + ]; + + $tenant = $this->createTenant([ + 'product_type' => 'jira' + ]); + + $client = $this->createClient([ + new \GuzzleHttp\Psr7\Response(200, [], json_encode($responses[0])), + new \GuzzleHttp\Psr7\Response(200, [], json_encode($responses[1])), + ], $tenant); + + $actual = $client->paginate('/rest/api/2/configuration'); + + $items = array_collapse(array_pluck($responses, 'records')); + + static::assertInstanceOf(\AtlassianConnectCore\Pagination\JiraPaginator::class, $client->paginator()); + static::assertEquals($actual, $items); + } + + /** + * @covers JWTClient::loadPaginator + * @covers JWTClient::paginators + */ + public function testPaginateWithConfluenceTenant() + { + $responses = [ + [ + 'start' => 0, + 'limit' => 2, + 'size' => 4, + 'results' => [ + [ + 'id' => 1, + 'field' => 'value' + ], + [ + 'id' => 2, + 'field' => 'value' + ] + ], + ], + [ + 'start' => 2, + 'limit' => 2, + 'size' => 4, + 'results' => [ + [ + 'id' => 3, + 'field' => 'value' + ], + [ + 'id' => 4, + 'field' => 'value' + ] + ], + ] + ]; + + $tenant = $this->createTenant([ + 'product_type' => 'confluence' + ]); + + $client = $this->createClient([ + new \GuzzleHttp\Psr7\Response(200, [], json_encode($responses[0])), + new \GuzzleHttp\Psr7\Response(200, [], json_encode($responses[1])), + ], $tenant); + + $actual = $client->paginate('/rest/api/audit'); + + $items = array_collapse(array_pluck($responses, 'results')); + + static::assertInstanceOf(\AtlassianConnectCore\Pagination\ConfluencePaginator::class, $client->paginator()); + static::assertEquals($actual, $items); + } + + /** + * @expectedException \Exception + */ + public function testPaginateWithUnknown() + { + $tenant = $this->createTenant([ + 'product_type' => 'undefined' + ]); + + $client = $this->createClient([ + new \GuzzleHttp\Psr7\Response(200), + ], $tenant); + + $client->paginate('/rest/api/unknown'); + } + + + public function testPaginateWithInitializedManually() + { + $responses = [ + [ + 'offset' => 0, + 'limit' => 2, + 'total' => 4, + 'records' => [ + [ + 'id' => 1, + 'field' => 'value' + ], + [ + 'id' => 2, + 'field' => 'value' + ] + ], + ], + [ + 'offset' => 2, + 'limit' => 2, + 'total' => 4, + 'records' => [ + [ + 'id' => 3, + 'field' => 'value' + ], + [ + 'id' => 4, + 'field' => 'value' + ] + ], + ] + ]; + + $tenant = $this->createTenant([ + 'product_type' => 'confluence' + ]); + + $client = $this->createClient([ + new \GuzzleHttp\Psr7\Response(200, [], json_encode($responses[0])), + new \GuzzleHttp\Psr7\Response(200, [], json_encode($responses[1])) + ], $tenant, new \AtlassianConnectCore\Pagination\JiraPaginator()); + + // We've created Tenant for the Confluence product, but use Jira paginator + $actual = $client->paginate('/rest/api/2/configuration'); + + $items = array_collapse(array_pluck($responses, 'records')); + + static::assertInstanceOf(\AtlassianConnectCore\Pagination\JiraPaginator::class, $client->paginator()); + static::assertEquals($actual, $items); + } + + /** + * Creates the tenant, mocked HTTP client and JWTClient + * + * @param array $responses + * @param \AtlassianConnectCore\Models\Tenant|null $tenant + * @param \AtlassianConnectCore\Pagination\Paginator|null $paginator + * + * @return \AtlassianConnectCore\Http\Clients\JWTClient + */ + protected function createClient( + array $responses = [], + \AtlassianConnectCore\Models\Tenant $tenant = null, + \AtlassianConnectCore\Pagination\Paginator $paginator = null + ) + { + $tenant = $tenant ?? $this->createTenant(); + + $mock = new \GuzzleHttp\Handler\MockHandler($responses); + $handler = \GuzzleHttp\HandlerStack::create($mock); + $httpClient = new \GuzzleHttp\Client(['handler' => $handler]); + + $client = new \AtlassianConnectCore\Http\Clients\JWTClient($tenant, $paginator, $httpClient); + + return $client; + } +} \ No newline at end of file diff --git a/tests/Pagination/PaginatorTest.php b/tests/Pagination/PaginatorTest.php new file mode 100644 index 0000000..78059d3 --- /dev/null +++ b/tests/Pagination/PaginatorTest.php @@ -0,0 +1,179 @@ +createPaginator([ + 'type' => Paginator::TYPE_PAGE, + 'url' => '/', + 'client' => null + ]); + + iterator_to_array($paginator); + } + + /** + * @expectedException \AtlassianConnectCore\Exceptions\PaginationException + */ + public function testValidateConfigWithoutURL() + { + $paginator = $this->createPaginator([ + 'type' => Paginator::TYPE_PAGE, + 'url' => null + ]); + + iterator_to_array($paginator); + } + + /** + * @expectedException \AtlassianConnectCore\Exceptions\PaginationException + */ + public function testValidateConfigWithInvalidType() + { + $paginator = $this->createPaginator([ + 'type' => 192, + 'url' => null + ]); + + iterator_to_array($paginator); + } + + /** + * @covers Paginator::rewind + * @covers Paginator::current + * @covers Paginator::key + * @covers Paginator::next + * @covers Paginator::valid + */ + public function testPaginateWithPageType() + { + $responses = [ + ['page' => 1, 'pagelen' => 1, 'values' => [['id' => 1, 'name' => 'Joe']]], + ['page' => 2, 'pagelen' => 1, 'values' => [['id' => 2, 'name' => 'Cocker']]], + ['page' => 3, 'pagelen' => 2, 'values' => [ + ['id' => 3, 'name' => 'Taylor'], + ['id' => 4, 'name' => 'Otwell'], + ]], + ['page' => 4, 'pagelen' => 0] + ]; + + $paginator = $this->createPaginator([ + 'type' => Paginator::TYPE_PAGE, + 'url' => '/', + 'offsetKey' => 'page', + 'itemsKey' => 'values', + 'perPageKey' => 'pagelen' + ], $this->createResponses($responses)); + + $expected = array_collapse(array_pluck($responses, 'values')); + $actual = iterator_to_array($paginator); + + static::assertEquals($expected, $actual); + } + + /** + * @covers Paginator::isReachedTotal + */ + public function testPaginateWithOffsetType() + { + $responses = [ + ['offset' => 0, 'perPage' => 2, 'total' => 5, 'items' => [['id' => 1], ['id' => 2]]], + ['offset' => 2, 'perPage' => 2, 'total' => 5, 'items' => [['id' => 3], ['id' => 4]]], + ['offset' => 4, 'perPage' => 2, 'total' => 5, 'items' => [['id' => 5]]], + ]; + + $paginator = $this->createPaginator([ + 'type' => Paginator::TYPE_OFFSET, + 'url' => '/', + 'offsetKey' => 'offset', + 'itemsKey' => 'items', + 'perPageKey' => 'perPage', + 'totalKey' => 'total' + ], $this->createResponses($responses)); + + $expected = array_collapse(array_pluck($responses, 'items')); + $actual = iterator_to_array($paginator); + + static::assertEquals($expected, $actual); + } + + /** + * @covers Paginator::getItems + * @covers Paginator::getLastResponse + * @covers Paginator::isNextTypeReached + */ + public function testPaginateWithNextType() + { + $responses = [ + ['results' => [['id' => 1], ['id' => 2]], 'next' => '/page/2'], + ['results' => [['id' => 3], ['id' => 4]], 'next' => '/page/3'], + ['results' => [['id' => 5], ['id' => 6], ['id' => 7]]], + ]; + + $paginator = $this->createPaginator([ + 'type' => Paginator::TYPE_NEXT, + 'url' => '/', + 'itemsKey' => 'results', + 'nextKey' => 'next' + ], $this->createResponses($responses)); + + $expected = array_collapse(array_pluck($responses, 'results')); + $actual = iterator_to_array($paginator); + + static::assertEquals($expected, $actual); + + static::assertEquals($expected, $paginator->getItems()); + static::assertEquals(array_last($responses), $paginator->getLastResponse()); + } + + /** + * Creates the tenant, mocked HTTP client and JWTClient + * + * @covers Paginator::setConfig + * + * @param array $config + * @param array $responses + * + * @return Paginator + */ + protected function createPaginator(array $config = [], array $responses = []) + { + if(!array_has($config, 'client')) { + $mock = new \GuzzleHttp\Handler\MockHandler($responses); + $handler = \GuzzleHttp\HandlerStack::create($mock); + $httpClient = new \GuzzleHttp\Client(['handler' => $handler]); + + $config['client'] = $httpClient; + } + + $paginator = new Paginator($config); + + return $paginator; + } + + /** + * Create array of Client responses from an array + * + * @param array $responses + * + * @return array + */ + protected function createResponses(array $responses) + { + $result = []; + + foreach ($responses as $response) { + $result[] = new \GuzzleHttp\Psr7\Response(200, [], json_encode($response)); + } + + return $result; + } +} \ No newline at end of file