diff --git a/README.md b/README.md index c205b890..5e1441ef 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,12 @@ We are planning to add Monetization API support to this library in the near futu The [Apigee Monetization APIs](https://apidocs.apigee.com/api-reference/content/monetization-apis) have been added to this library but are considered to be an alpha. If you run into any problems, add an issue to our [GitHub issue queue](https://github.com/apigee/apigee-client-php/issues). +## Edge for Private Cloud +[Core Persistent Services (CPS)](https://docs.apigee.com/api-platform/reference/cps) is not available on Private Cloud installations. +The PHP API client supports pagination on listing API endpoints (ex.: [List Developers](https://apidocs.apigee.com/management/apis/get/organizations/%7Borg_name%7D/developers)). If CPS is not available the PHP API client simulates the pagination feature and it triggers an E_USER_NOTICE level error to let developers know that the paginated result is generated by PHP and not the Management API server. +This notice can be suppressed in multiple ways. You can suppress it by changing PHP's `error_reporting` configuration to +suppress _all_ E_NOTICE level errors with changing its value to `E_ALL | ~E_NOTICE` for example. You can also suppress only the notice generated by the PHP API client by setting the `APIGEE_EDGE_PHP_CLIENT_SUPPRESS_CPS_SIMULATION_NOTICE` environment variable value to a falsy value, for example: `APIGEE_EDGE_PHP_CLIENT_SUPPRESS_CPS_SIMULATION_NOTICE=1`. + ## Installing the client library You must install an HTTP client or adapter before you install the Apigee API Client Library for PHP. For a complete list @@ -145,6 +151,8 @@ APIGEE_EDGE_PHP_CLIENT_BASIC_AUTH_USER=[YOUR-EMAIL-ADDRESS@HOST.COM] APIGEE_EDGE_PHP_CLIENT_BASIC_AUTH_PASSWORD=[PASSWORD] APIGEE_EDGE_PHP_CLIENT_ORGANIZATION=[ORGANIZATION] APIGEE_EDGE_PHP_CLIENT_ENVIRONMENT=[ENVIRONMENT] +# If test organization does not support CPS. +APIGEE_EDGE_PHP_CLIENT_SUPPRESS_CPS_SIMULATION_NOTICE=1 ``` There are multiple ways to set these environment variables, but probably the easiest is creating a copy from the diff --git a/src/Controller/PaginationHelperTrait.php b/src/Controller/PaginationHelperTrait.php index 6096c180..02e28f72 100644 --- a/src/Controller/PaginationHelperTrait.php +++ b/src/Controller/PaginationHelperTrait.php @@ -18,7 +18,8 @@ namespace Apigee\Edge\Controller; -use Apigee\Edge\Exception\CpsNotEnabledException; +use Apigee\Edge\Exception\ClientErrorException; +use Apigee\Edge\Exception\RuntimeException; use Apigee\Edge\Structure\PagerInterface; use Psr\Http\Message\ResponseInterface; @@ -39,12 +40,6 @@ trait PaginationHelperTrait */ public function createPager(int $limit = 0, ?string $startKey = null): PagerInterface { - /** @var \Apigee\Edge\Api\Management\Entity\OrganizationInterface $organization */ - $organization = $this->getOrganizationController()->load($this->getOrganisationName()); - if (!$organization->getPropertyValue('features.isCpsEnabled')) { - throw new CpsNotEnabledException($this->getOrganisationName()); - } - // Create an anonymous class here because this class should not exist and be in use // in those controllers that do not work with entities that belongs to an organization. $pager = new class() implements PagerInterface { @@ -107,14 +102,78 @@ public function setLimit(int $limit): int * * @return \Apigee\Edge\Entity\EntityInterface[] * Array of entity objects. + */ + protected function listEntities(PagerInterface $pager = null, array $query_params = [], string $key_provider = 'id'): array + { + /** @var \Apigee\Edge\Api\Management\Entity\OrganizationInterface $organization */ + $organization = $this->getOrganizationController()->load($this->getOrganisationName()); + $isCpsEnabled = $organization->getPropertyValue('features.isCpsEnabled'); + + if ($isCpsEnabled) { + return $this->listEntitiesWithCps($pager, $query_params, $key_provider); + } else { + $this->triggerCpsSimulationNotice($pager); + + return $this->listEntitiesWithoutCps($pager, $query_params, $key_provider); + } + } + + /** + * Loads entity ids from Apigee Edge. + * + * @param \Apigee\Edge\Structure\PagerInterface|null $pager + * Pager. + * @param array $query_params + * Additional query parameters. + * + * @return string[] + * Array of entity ids. + */ + protected function listEntityIds(PagerInterface $pager = null, array $query_params = []): array + { + /** @var \Apigee\Edge\Api\Management\Entity\OrganizationInterface $organization */ + $organization = $this->getOrganizationController()->load($this->getOrganisationName()); + $isCpsEnabled = $organization->getPropertyValue('features.isCpsEnabled'); + + if ($isCpsEnabled) { + return $this->listEntityIdsWithCps($pager, $query_params); + } else { + $this->triggerCpsSimulationNotice($pager); + + return $this->listEntityIdsWithoutCps($pager, $query_params); + } + } + + /** + * @inheritdoc + */ + abstract protected function responseToArray(ResponseInterface $response): array; + + /** + * @inheritdoc + */ + abstract protected function responseArrayToArrayOfEntities(array $responseArray, string $keyGetter = 'id'): array; + + /** + * Real paginated entity listing on organization with CPS support. + * + * @param \Apigee\Edge\Structure\PagerInterface|null $pager + * Pager. + * @param array $query_params + * Additional query parameters. + * @param string $key_provider + * Getter method on the entity that should provide a unique array key. + * + * @return \Apigee\Edge\Entity\EntityInterface[] + * Array of entity objects. * * @psalm-suppress PossiblyNullArrayOffset $tmp->id() is always not null here. */ - protected function listEntities(PagerInterface $pager = null, array $query_params = [], string $key_provider = 'id'): array + private function listEntitiesWithCps(PagerInterface $pager = null, array $query_params = [], string $key_provider = 'id'): array { $query_params = [ - 'expand' => 'true', - ] + $query_params; + 'expand' => 'true', + ] + $query_params; if ($pager) { $responseArray = $this->getResultsInRange($pager, $query_params); @@ -163,7 +222,84 @@ protected function listEntities(PagerInterface $pager = null, array $query_param } /** - * Loads entity ids from Apigee Edge. + * Simulates paginated entity listing on organization without CPS support. + * + * For example, on on-prem installations. + * + * @param \Apigee\Edge\Structure\PagerInterface|null $pager + * Pager. + * @param array $query_params + * Additional query parameters. + * @param string $key_provider + * Getter method on the entity that should provide a unique array key. + * + * @return \Apigee\Edge\Entity\EntityInterface[] + * Array of entity objects. + */ + private function listEntitiesWithoutCps(PagerInterface $pager = null, array $query_params = [], string $key_provider = 'id'): array + { + $query_params = [ + 'expand' => 'true', + ] + $query_params; + + $uri = $this->getBaseEndpointUri()->withQuery(http_build_query($query_params)); + $response = $this->getClient()->get($uri); + $responseArray = $this->responseToArray($response); + // Ignore entity type key from response, ex.: apiProduct. + $responseArray = reset($responseArray); + + $entities = $this->responseArrayToArrayOfEntities($responseArray, $key_provider); + + return $pager ? $this->simulateCpsPagination($pager, $entities, array_keys($entities)) : $entities; + } + + /** + * Gets entities and entity ids in a provided range from Apigee Edge. + * + * This method for organizations with CPS enabled. + * + * @param \Apigee\Edge\Structure\PagerInterface $pager + * CPS limit object with configured startKey and limit. + * @param array $query_params + * Query parameters for the API call. + * + * @return array + * API response parsed as an array. + */ + private function getResultsInRange(PagerInterface $pager, array $query_params): array + { + $query_params['startKey'] = $pager->getStartKey(); + // Do not add 0 unnecessarily to the query parameters. + if ($pager->getLimit() > 0) { + $query_params['count'] = $pager->getLimit(); + } + $uri = $this->getBaseEndpointUri()->withQuery(http_build_query($query_params)); + $response = $this->getClient()->get($uri); + + return $this->responseToArray($response); + } + + /** + * Triggers an E_USER_NOTICE if pagination is used in a non-CPS org. + * + * @param \Apigee\Edge\Structure\PagerInterface|null $pager + * Pager. + */ + private function triggerCpsSimulationNotice(PagerInterface $pager = null): void + { + // Trigger an E_USER_NOTICE error if pagination feature needs to + // be simulated on an organization without CPS to let developers + // know that the Apigee PHP API client executed a workaround. + // If suppressing all E_NOTICE level errors in an environment is + // undesired then setting the following environment variable to + // falsy value can also suppress this notice. + if ($pager && !getenv('APIGEE_EDGE_PHP_CLIENT_SUPPRESS_CPS_SIMULATION_NOTICE')) { + trigger_error('Apigee Edge PHP Client: Simulating CPS pagination on an organization that does not have CPS support. https://docs.apigee.com/api-platform/reference/cps', E_USER_NOTICE); + } + } + + /** + * Real paginated entity id listing on organization with CPS support. * * @param \Apigee\Edge\Structure\PagerInterface|null $pager * Pager. @@ -173,11 +309,11 @@ protected function listEntities(PagerInterface $pager = null, array $query_param * @return string[] * Array of entity ids. */ - protected function listEntityIds(PagerInterface $pager = null, array $query_params = []): array + private function listEntityIdsWithCps(PagerInterface $pager = null, array $query_params = []): array { $query_params = [ - 'expand' => 'false', - ] + $query_params; + 'expand' => 'false', + ] + $query_params; if ($pager) { return $this->getResultsInRange($pager, $query_params); } else { @@ -207,36 +343,71 @@ protected function listEntityIds(PagerInterface $pager = null, array $query_para } /** - * @inheritdoc + * Simulates paginated entity id listing on organization without CPS. + * + * @param \Apigee\Edge\Structure\PagerInterface|null $pager + * Pager. + * @param array $query_params + * Additional query parameters. + * + * @return string[] + * Array of entity ids. */ - abstract protected function responseToArray(ResponseInterface $response): array; + private function listEntityIdsWithoutCps(PagerInterface $pager = null, array $query_params = []): array + { + $query_params = [ + 'expand' => 'false', + ] + $query_params; - /** - * @inheritdoc - */ - abstract protected function responseArrayToArrayOfEntities(array $responseArray, string $keyGetter = 'id'): array; + $uri = $this->getBaseEndpointUri()->withQuery(http_build_query($query_params)); + $response = $this->getClient()->get($uri); + + $ids = $this->responseToArray($response); + + // Re-key the array from 0 if CPS had to be simulated. + return $pager ? array_values($this->simulateCpsPagination($pager, $ids)) : $ids; + } /** - * Gets entities and entity ids in a provided range from Apigee Edge. + * Simulates paginated response on an organization without CPS. * * @param \Apigee\Edge\Structure\PagerInterface $pager - * CPS limit object with configured startKey and limit. - * @param array $query_params - * Query parameters for the API call. + * Pager. + * @param array $result + * The non-paginated result returned by the API. + * @param array|null $array_search_haystack + * Haystack for array_search, the needle is the start key from the pager. + * If it is null, then the haystack is the $result. * * @return array - * API response parsed as an array. + * The paginated result. */ - private function getResultsInRange(PagerInterface $pager, array $query_params): array + private function simulateCpsPagination(PagerInterface $pager, array $result, array $array_search_haystack = null): array { - $query_params['startKey'] = $pager->getStartKey(); - // Do not add 0 unnecessarily to the query parameters. - if ($pager->getLimit() > 0) { - $query_params['count'] = $pager->getLimit(); + $array_search_haystack = $array_search_haystack ?? $result; + // If start key is null let's set it to the first key in the + // result just like the API would do. + $start_key = $pager->getStartKey() ?? reset($array_search_haystack); + $offset = array_search($start_key, $array_search_haystack); + // Start key has not been found in the response. Apigee Edge with + // CPS enabled would return an HTTP 404, with error code + // "keymanagement.service.[ENTITY_TYPE]_doesnot_exist" which would + // trigger a ClientErrorException. We throw a RuntimeException + // instead of that because it does not require to construct an + // API response object. + if (false === $offset) { + throw new RuntimeException(sprintf('CPS simulation error: "%s" does not exist.', $start_key)); } - $uri = $this->getBaseEndpointUri()->withQuery(http_build_query($query_params)); - $response = $this->getClient()->get($uri); - - return $this->responseToArray($response); + // The default pagination limit (aka. "count") on CPS supported + // listing endpoints varies. When this script was written it was + // 1000 on two endpoints and 100 on two app related endpoints, + // namely List Developer Apps and List Company Apps. A + // developer/company should not have 100 apps, this is + // the reason why this limit is smaller. Therefore we choose to + // use 1000 as pagination limit if it has not been set. + // https://apidocs.apigee.com/management/apis/get/organizations/%7Borg_name%7D/apiproducts-0 + // https://apidocs.apigee.com/management/apis/get/organizations/%7Borg_name%7D/developers + // https://apidocs.apigee.com/management/apis/get/organizations/%7Borg_name%7D/developers/%7Bdeveloper_email_or_id%7D/apps + return array_slice($result, $offset, $pager->getLimit() ?: 1000, true); } } diff --git a/src/Exception/CpsNotEnabledException.php b/src/Exception/CpsNotEnabledException.php index 07a5d10c..2a8bb040 100644 --- a/src/Exception/CpsNotEnabledException.php +++ b/src/Exception/CpsNotEnabledException.php @@ -27,6 +27,7 @@ * feature is not enabled on the organization on Apigee Edge. * * @see https://docs.apigee.com/api-services/content/api-reference-getting-started#cps + * @deprecated Since 2.0.1, https://github.com/apigee/apigee-client-php/pull/43/files */ class CpsNotEnabledException extends \RuntimeException { diff --git a/tests/Controller/PaginationHelperTraitTest.php b/tests/Controller/PaginationHelperTraitTest.php index 6294d92c..e19d0d8d 100644 --- a/tests/Controller/PaginationHelperTraitTest.php +++ b/tests/Controller/PaginationHelperTraitTest.php @@ -55,47 +55,91 @@ public static function setUpBeforeClass(): void static::$testController = new PaginationHelperTraitTestClass(); } - /** - * @expectedException \Apigee\Edge\Exception\CpsNotEnabledException - */ - public function testNonCpsEnabledOrg(): void + public function testListEntitiesWithoutPagerWithEmptyResponse(): void { /** @var \Apigee\Edge\Tests\Test\HttpClient\MockHttpClient $httpClient */ $httpClient = static::mockApiClient()->getMockHttpClient(); - $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], $this->getOrgLoadResponsePayload(false))); - static::$testController->createPager(); + foreach ([true, false] as $cpsEnabled) { + $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], $this->getOrgLoadResponsePayload($cpsEnabled))); + $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], json_encode([[]]))); + $this->assertEmpty(static::$testController->getEntities(), $cpsEnabled ? 'CPS supported' : 'CPS not supported'); + } } - public function testListEntitiesWithoutPagerWithEmptyResponse(): void + public function testListEntityIdsWithoutPagerWithEmptyResponse(): void { /** @var \Apigee\Edge\Tests\Test\HttpClient\MockHttpClient $httpClient */ $httpClient = static::mockApiClient()->getMockHttpClient(); - $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], $this->getOrgLoadResponsePayload())); - $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], json_encode([[]]))); - $this->assertEmpty(static::$testController->getEntities()); + foreach ([true, false] as $cpsEnabled) { + $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], $this->getOrgLoadResponsePayload($cpsEnabled))); + $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], json_encode([]))); + $this->assertEmpty(static::$testController->getEntityIds(), $cpsEnabled ? 'CPS supported' : 'CPS not supported'); + } } - public function testListEntityIdsWithoutPagerWithEmptyResponse(): void + public function testListEntityIdsWithoutPagerWithMoreResults(): void { /** @var \Apigee\Edge\Tests\Test\HttpClient\MockHttpClient $httpClient */ $httpClient = static::mockApiClient()->getMockHttpClient(); $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], $this->getOrgLoadResponsePayload())); + $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], json_encode(['first', 'second']))); + $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], json_encode(['second', 'third']))); $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], json_encode([]))); - $this->assertEmpty(static::$testController->getEntityIds()); + $this->assertCount(3, static::$testController->getEntityIds(), 'CPS enabled'); } - public function testListEntityIdsWithoutPagerWithMoreResults(): void + /** + * @expectedException \PHPUnit\Framework\Error\Notice + * @expectedExceptionMessage Apigee Edge PHP Client: Simulating CPS pagination on an organization that does not have CPS support. https://docs.apigee.com/api-platform/reference/cps + */ + public function testWithoutCpsNotice(): void { + // Make sure CPS notice suppressing is disabled. + putenv('APIGEE_EDGE_PHP_CLIENT_SUPPRESS_CPS_SIMULATION_NOTICE=0'); /** @var \Apigee\Edge\Tests\Test\HttpClient\MockHttpClient $httpClient */ $httpClient = static::mockApiClient()->getMockHttpClient(); - $orgLoadResponsePayload = $this->getOrgLoadResponsePayload(); - $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], $orgLoadResponsePayload)); + $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], $this->getOrgLoadResponsePayload(false))); + static::$testController->getEntityIds(static::$testController->createPager()); + } + + public function testWithoutCpsNoticeSuppress(): void + { + // Make sure CPS notice suppressing is enabled. + putenv('APIGEE_EDGE_PHP_CLIENT_SUPPRESS_CPS_SIMULATION_NOTICE=1'); + $httpClient = static::mockApiClient()->getMockHttpClient(); + $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], $this->getOrgLoadResponsePayload(false))); $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], json_encode(['first', 'second']))); - $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], $orgLoadResponsePayload)); - $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], json_encode(['second', 'third']))); - $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], $orgLoadResponsePayload)); - $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], json_encode([]))); - $this->assertCount(3, static::$testController->getEntityIds()); + $this->assertCount(2, static::$testController->getEntityIds(static::$testController->createPager())); + } + + /** + * @expectedException \Apigee\Edge\Exception\RuntimeException + * @expectedExceptionMessage CPS simulation error: "foo" does not exist. + */ + public function testListEntityIdsWithoutCpsWithInvalidStartKey(): void + { + $this->iniSet('error_reporting', 'E_ALL & ~E_NOTICE'); + /** @var \Apigee\Edge\Tests\Test\HttpClient\MockHttpClient $httpClient */ + $httpClient = static::mockApiClient()->getMockHttpClient(); + $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], $this->getOrgLoadResponsePayload(false))); + $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], json_encode(['first', 'second']))); + static::$testController->getEntityIds(static::$testController->createPager(0, 'foo')); + } + + public function testListEntityIdsWithoutCps(): void + { + putenv('APIGEE_EDGE_PHP_CLIENT_SUPPRESS_CPS_SIMULATION_NOTICE=1'); + /** @var \Apigee\Edge\Tests\Test\HttpClient\MockHttpClient $httpClient */ + $httpClient = static::mockApiClient()->getMockHttpClient(); + $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], $this->getOrgLoadResponsePayload(false))); + $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], json_encode(['first', 'second']))); + $this->assertCount(2, static::$testController->getEntityIds()); + $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], $this->getOrgLoadResponsePayload(false))); + $httpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], json_encode(['first', 'second', 'third', 'fourth']))); + $result = static::$testController->getEntityIds(static::$testController->createPager(2, 'third')); + $this->assertCount(2, $result); + $this->assertContains('third', $result); + $this->assertContains('fourth', $result); } protected function getOrgLoadResponsePayload(bool $cpsEnabled = true): string