diff --git a/legacy/src/Command/Organization/OrganizationCommandBase.php b/legacy/src/Command/Organization/OrganizationCommandBase.php index 275eefaf6..3e8615079 100644 --- a/legacy/src/Command/Organization/OrganizationCommandBase.php +++ b/legacy/src/Command/Organization/OrganizationCommandBase.php @@ -10,6 +10,7 @@ use Symfony\Contracts\Service\Attribute\Required; use Platformsh\Cli\Command\CommandBase; use Platformsh\Cli\Console\ProgressMessage; +use Platformsh\Cli\Util\PaginationUtil; use Platformsh\Client\Model\Organization\Member; use Platformsh\Client\Model\Organization\Organization; use Symfony\Component\Console\Exception\InvalidArgumentException; @@ -90,11 +91,15 @@ protected function chooseMember(Organization $organization): Member $progress = new ProgressMessage($this->stdErr); $progress->showIfOutputDecorated('Loading users...'); try { - do { + while (true) { $result = Member::getCollectionWithParent($url, $httpClient, $options); $members = array_merge($members, $result['items']); - $url = $result['collection']->getNextPageUrl(); - } while (!empty($url)); + $nextPage = PaginationUtil::nextPage($result['collection']->getNextPageUrl(), $url, $options['query']); + if ($nextPage === null) { + break; + } + [$url, $options['query']] = $nextPage; + } } finally { $progress->done(); } diff --git a/legacy/src/Command/Organization/OrganizationSubscriptionListCommand.php b/legacy/src/Command/Organization/OrganizationSubscriptionListCommand.php index 9fb0995b3..e85dfd6fc 100644 --- a/legacy/src/Command/Organization/OrganizationSubscriptionListCommand.php +++ b/legacy/src/Command/Organization/OrganizationSubscriptionListCommand.php @@ -9,6 +9,7 @@ use Platformsh\Cli\Service\Config; use Platformsh\Cli\Console\ProgressMessage; use Platformsh\Cli\Service\Table; +use Platformsh\Cli\Util\PaginationUtil; use Platformsh\Client\Model\Subscription; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; @@ -18,6 +19,9 @@ #[AsCommand(name: 'organization:subscription:list', description: 'List subscriptions within an organization', aliases: ['org:subs'])] class OrganizationSubscriptionListCommand extends OrganizationCommandBase { + /** The maximum page size allowed by the API. */ + public const MAX_COUNT = 100; + /** @var array */ private array $tableHeader = [ 'id' => 'Subscription ID', @@ -39,8 +43,8 @@ public function __construct(private readonly Api $api, private readonly Config $ protected function configure(): void { $this->setHiddenAliases(['organization:subscriptions']) - ->addOption('page', null, InputOption::VALUE_REQUIRED, 'Page number. This enables pagination, despite configuration or --count.') - ->addOption('count', 'c', InputOption::VALUE_REQUIRED, 'The number of items to display per page. Use 0 to disable pagination. Ignored if --page is specified.'); + ->addOption('page', null, InputOption::VALUE_REQUIRED, 'Page number. This enables pagination, despite the configuration or --count 0.') + ->addOption('count', 'c', InputOption::VALUE_REQUIRED, 'The number of items to display per page (max: ' . self::MAX_COUNT . '). Use 0 to disable pagination.'); $this->selector->addOrganizationOptions($this->getDefinition(), true); $this->addCompleter($this->selector); Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); @@ -59,48 +63,58 @@ protected function execute(InputInterface $input, OutputInterface $output): int $count = $input->getOption('count'); $itemsPerPage = $this->config->getInt('pagination.count'); if ($count !== null && $count !== '0') { - if (!\is_numeric($count) || $count > 50) { - $this->stdErr->writeln('The --count must be a number between 1 and 50, or 0 to disable pagination.'); + if (!\is_numeric($count) || $count < 1 || $count > self::MAX_COUNT) { + $this->stdErr->writeln('The --count must be a number between 1 and ' . self::MAX_COUNT . ', or 0 to disable pagination.'); return 1; } - $itemsPerPage = $count; + $itemsPerPage = (int) $count; } - $options['query']['range'] = $itemsPerPage; $fetchAllPages = !$this->config->getBool('pagination.enabled'); if ($count === '0') { $fetchAllPages = true; + $itemsPerPage = self::MAX_COUNT; } + $options['query']['page[size]'] = $itemsPerPage; - $pageNumber = $input->getOption('page'); - if ($pageNumber === null) { - $pageNumber = 1; - } else { + $requestedPage = 1; + if (($pageOption = $input->getOption('page')) !== null) { + if (!\is_numeric($pageOption) || $pageOption < 1) { + $this->stdErr->writeln('The --page must be a number greater than 0.'); + return 1; + } + $requestedPage = (int) $pageOption; $fetchAllPages = false; } - $options['query']['page'] = $pageNumber; $organization = $this->selector->selectOrganization($input); $httpClient = $this->api->getHttpClient(); $subscriptions = []; $url = $organization->getUri() . '/subscriptions'; + $pageNumber = 1; + $hasNextPage = false; $progress = new ProgressMessage($output); while (true) { $progress->showIfOutputDecorated(\sprintf('Loading subscriptions (page %d)...', $pageNumber)); $collection = Subscription::getPagedCollection($url, $httpClient, $options); $progress->done(); - $subscriptions = \array_merge($subscriptions, $collection['items']); - if ($fetchAllPages && count($collection['items']) > 0 && isset($collection['next']) && $collection['next'] !== $url) { - $url = $collection['next']; - $pageNumber++; - continue; + // The API paginates with a cursor, so pages before the requested + // one have to be fetched, and discarded, to reach it. + if ($pageNumber >= $requestedPage) { + $subscriptions = \array_merge($subscriptions, $collection['items']); + } + $nextPage = PaginationUtil::nextPage($collection['next'], $url, $options['query']); + $hasNextPage = $nextPage !== null; + if (!$hasNextPage || (!$fetchAllPages && $pageNumber >= $requestedPage)) { + break; } - break; + [$url, $options['query']] = $nextPage; + $pageNumber++; } if (empty($subscriptions)) { - if ($pageNumber > 1) { + if ($requestedPage > 1) { $this->stdErr->writeln('No subscriptions were found on this page.'); return 0; } @@ -116,7 +130,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (!$this->table->formatIsMachineReadable()) { $title = \sprintf('Subscriptions belonging to the organization %s', $this->api->getOrganizationLabel($organization)); - if (($pageNumber > 1 || isset($collection['next'])) && !$fetchAllPages) { + if (($pageNumber > 1 || $hasNextPage) && !$fetchAllPages) { $title .= \sprintf(' (page %d)', $pageNumber); } $this->stdErr->writeln($title); @@ -124,7 +138,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->table->render($rows, $this->tableHeader, $this->defaultColumns); - if (!$this->table->formatIsMachineReadable() && isset($collection['next'])) { + if (!$this->table->formatIsMachineReadable() && $hasNextPage) { $this->stdErr->writeln(\sprintf('More subscriptions are available on the next page (--page %d)', $pageNumber + 1)); $this->stdErr->writeln('List all items with: --count 0 (-c0)'); } diff --git a/legacy/src/Command/Organization/User/OrganizationUserListCommand.php b/legacy/src/Command/Organization/User/OrganizationUserListCommand.php index a2534790d..74062bbc0 100644 --- a/legacy/src/Command/Organization/User/OrganizationUserListCommand.php +++ b/legacy/src/Command/Organization/User/OrganizationUserListCommand.php @@ -11,6 +11,7 @@ use Platformsh\Cli\Console\ProgressMessage; use Platformsh\Cli\Service\PropertyFormatter; use Platformsh\Cli\Service\Table; +use Platformsh\Cli\Util\PaginationUtil; use Platformsh\Client\Model\Organization\Member; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; @@ -97,11 +98,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int $progress = new ProgressMessage($output); $progress->showIfOutputDecorated('Loading users...'); try { - do { + while (true) { $result = Member::getCollectionWithParent($url, $httpClient, $options); $members = array_merge($members, $result['items']); - $url = $result['collection']->getNextPageUrl(); - } while (!empty($url) && $fetchAllPages); + $nextPage = PaginationUtil::nextPage($result['collection']->getNextPageUrl(), $url, $options['query']); + if ($nextPage === null || !$fetchAllPages) { + break; + } + [$url, $options['query']] = $nextPage; + } } finally { $progress->done(); } diff --git a/legacy/src/Command/Organization/User/OrganizationUserProjectsCommand.php b/legacy/src/Command/Organization/User/OrganizationUserProjectsCommand.php index 824a1fc5c..8a7d22ca4 100644 --- a/legacy/src/Command/Organization/User/OrganizationUserProjectsCommand.php +++ b/legacy/src/Command/Organization/User/OrganizationUserProjectsCommand.php @@ -14,6 +14,7 @@ use Platformsh\Cli\Service\PropertyFormatter; use Platformsh\Cli\Service\Table; use Platformsh\Cli\Util\OsUtil; +use Platformsh\Cli\Util\PaginationUtil; use Platformsh\Client\Model\CentralizedPermissions\UserExtendedAccess; use Platformsh\Client\Model\Ref\UserRef; use Symfony\Component\Console\Attribute\AsCommand; @@ -118,12 +119,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int $collection = UserExtendedAccess::getPagedCollection($url, $httpClient, $options); $progress->done(); $items = \array_merge($items, $collection['items']); - if (count($collection['items']) > 0 && isset($collection['next']) && $collection['next'] !== $url) { - $url = $collection['next']; - $pageNumber++; - continue; + $nextPage = PaginationUtil::nextPage($collection['next'], $url, $options['query']); + if ($nextPage === null) { + break; } - break; + [$url, $options['query']] = $nextPage; + $pageNumber++; } if (empty($items)) { diff --git a/legacy/src/Command/Team/Project/TeamProjectAddCommand.php b/legacy/src/Command/Team/Project/TeamProjectAddCommand.php index e5d92b911..9f0173b07 100644 --- a/legacy/src/Command/Team/Project/TeamProjectAddCommand.php +++ b/legacy/src/Command/Team/Project/TeamProjectAddCommand.php @@ -11,6 +11,7 @@ use Platformsh\Cli\Command\Team\TeamCommandBase; use Platformsh\Cli\Console\ArrayArgument; use Platformsh\Cli\Console\ProgressMessage; +use Platformsh\Cli\Util\PaginationUtil; use Platformsh\Client\Exception\ApiResponseException; use Platformsh\Client\Model\Organization\Project as OrgProject; use Platformsh\Client\Model\Team\Team; @@ -229,14 +230,18 @@ private function loadOrgProjects(Team $team): array $options = []; $options['query']['filter[status][in]'] = 'active,suspended'; $progress = new ProgressMessage($this->stdErr); - while ($url !== null) { + while (true) { if ($pageNumber > 1) { $progress->showIfOutputDecorated(sprintf('Loading projects (page %d)...', $pageNumber)); } $result = OrgProject::getCollectionWithParent($url, $httpClient, $options); $progress->done(); $projects = array_merge($projects, $result['items']); - $url = $result['collection']->getNextPageUrl(); + $nextPage = PaginationUtil::nextPage($result['collection']->getNextPageUrl(), $url, $options['query']); + if ($nextPage === null) { + break; + } + [$url, $options['query']] = $nextPage; $pageNumber++; } return $projects; diff --git a/legacy/src/Command/Team/Project/TeamProjectListCommand.php b/legacy/src/Command/Team/Project/TeamProjectListCommand.php index a343c2944..d846506d5 100644 --- a/legacy/src/Command/Team/Project/TeamProjectListCommand.php +++ b/legacy/src/Command/Team/Project/TeamProjectListCommand.php @@ -12,6 +12,7 @@ use Platformsh\Cli\Console\ProgressMessage; use Platformsh\Cli\Service\PropertyFormatter; use Platformsh\Cli\Service\Table; +use Platformsh\Cli\Util\PaginationUtil; use Platformsh\Client\Model\Team\TeamProjectAccess; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; @@ -58,18 +59,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int $count = $input->getOption('count'); $itemsPerPage = $this->config->getInt('pagination.count'); if ($count !== null && $count !== '0') { - if (!\is_numeric($count) || $count > self::MAX_COUNT) { + if (!\is_numeric($count) || $count < 1 || $count > self::MAX_COUNT) { $this->stdErr->writeln('The --count must be a number between 1 and ' . self::MAX_COUNT . ', or 0 to disable pagination.'); return 1; } - $itemsPerPage = $count; + $itemsPerPage = (int) $count; } - $options['query']['range'] = $itemsPerPage; $fetchAllPages = !$this->config->getBool('pagination.enabled'); if ($count === '0') { $fetchAllPages = true; + $itemsPerPage = self::MAX_COUNT; } + $options['query']['page[size]'] = $itemsPerPage; $team = $this->validateTeamInput($input); if (!$team) { @@ -82,16 +84,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int $url = $team->getUri() . '/project-access'; $progress = new ProgressMessage($output); $pageNumber = 1; - do { + while (true) { if ($pageNumber > 1) { $progress->showIfOutputDecorated(sprintf('Loading projects (page %d)...', $pageNumber)); } $result = TeamProjectAccess::getCollectionWithParent($url, $httpClient, $options); $progress->done(); $projects = \array_merge($projects, $result['items']); - $url = $result['collection']->getNextPageUrl(); + $nextPage = PaginationUtil::nextPage($result['collection']->getNextPageUrl(), $url, $options['query']); + if ($nextPage === null || !$fetchAllPages) { + break; + } + [$url, $options['query']] = $nextPage; $pageNumber++; - } while ($url && $fetchAllPages); + } if (empty($projects)) { $this->stdErr->writeln(\sprintf('No projects were found in the team %s.', $this->getTeamLabel($team))); diff --git a/legacy/src/Command/Team/TeamCommandBase.php b/legacy/src/Command/Team/TeamCommandBase.php index 50990cd59..34c79ff83 100644 --- a/legacy/src/Command/Team/TeamCommandBase.php +++ b/legacy/src/Command/Team/TeamCommandBase.php @@ -12,6 +12,7 @@ use Platformsh\Cli\Command\CommandBase; use Platformsh\Cli\Console\ProgressMessage; use Platformsh\Cli\Exception\NoOrganizationsException; +use Platformsh\Cli\Util\PaginationUtil; use Platformsh\Client\Model\Organization\Organization; use Platformsh\Client\Model\Team\Team; use Platformsh\Client\Model\Team\TeamProjectAccess; @@ -168,16 +169,20 @@ protected function loadTeams(Organization $organization, bool $fetchAllPages = t $teams = []; $progress = new ProgressMessage($this->stdErr); $pageNumber = 1; - do { + while (true) { if ($pageNumber > 1) { $progress->showIfOutputDecorated(sprintf('Loading teams (page %d)...', $pageNumber)); } $result = Team::getCollectionWithParent($url, $httpClient, $options); $progress->done(); $teams = array_merge($teams, $result['items']); - $url = $result['collection']->getNextPageUrl(); + $nextPage = PaginationUtil::nextPage($result['collection']->getNextPageUrl(), $url, $options['query']); + if ($nextPage === null || !$fetchAllPages) { + break; + } + [$url, $options['query']] = $nextPage; $pageNumber++; - } while ($url && $fetchAllPages); + } return $teams; } @@ -215,14 +220,18 @@ protected function loadTeamProjects(Team $team): array $url = $team->getUri() . '/project-access'; $progress = new ProgressMessage($this->stdErr); $pageNumber = 1; - while ($url !== null) { + while (true) { if ($pageNumber > 1) { $progress->showIfOutputDecorated(sprintf('Loading team projects (page %d)...', $pageNumber)); } $result = TeamProjectAccess::getCollectionWithParent($url, $httpClient, $options); $progress->done(); $projects = \array_merge($projects, $result['items']); - $url = $result['collection']->getNextPageUrl(); + $nextPage = PaginationUtil::nextPage($result['collection']->getNextPageUrl(), $url, $options['query']); + if ($nextPage === null) { + break; + } + [$url, $options['query']] = $nextPage; $pageNumber++; } return $projects; diff --git a/legacy/src/Command/Team/TeamCreateCommand.php b/legacy/src/Command/Team/TeamCreateCommand.php index 366f88ad9..84d2cd888 100644 --- a/legacy/src/Command/Team/TeamCreateCommand.php +++ b/legacy/src/Command/Team/TeamCreateCommand.php @@ -12,6 +12,7 @@ use GuzzleHttp\Exception\BadResponseException; use Platformsh\Cli\Console\ArrayArgument; use Platformsh\Cli\Console\ProgressMessage; +use Platformsh\Cli\Util\PaginationUtil; use Platformsh\Cli\Util\Wildcard; use Platformsh\Client\Exception\ApiResponseException; use Platformsh\Client\Model\Team\Team; @@ -81,7 +82,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $url = '/teams'; $pageNumber = 1; $progress = new ProgressMessage($this->stdErr); - while ($url) { + while (true) { if ($pageNumber > 1) { $progress->showIfOutputDecorated(sprintf('Loading teams (page %d)...', $pageNumber)); } @@ -94,7 +95,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 1; } } - $url = $result['collection']->getNextPageUrl(); + $nextPage = PaginationUtil::nextPage($result['collection']->getNextPageUrl(), $url, $options['query']); + if ($nextPage === null) { + break; + } + [$url, $options['query']] = $nextPage; $pageNumber++; } } diff --git a/legacy/src/Command/Team/User/TeamUserListCommand.php b/legacy/src/Command/Team/User/TeamUserListCommand.php index 834fc30fc..85b74cb76 100644 --- a/legacy/src/Command/Team/User/TeamUserListCommand.php +++ b/legacy/src/Command/Team/User/TeamUserListCommand.php @@ -12,6 +12,7 @@ use Platformsh\Cli\Console\ProgressMessage; use Platformsh\Cli\Service\PropertyFormatter; use Platformsh\Cli\Service\Table; +use Platformsh\Cli\Util\PaginationUtil; use Platformsh\Client\Model\Team\TeamMember; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; @@ -78,16 +79,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int $url = $team->getUri() . '/members'; $progress = new ProgressMessage($output); $pageNumber = 1; - do { + while (true) { if ($pageNumber > 1) { $progress->showIfOutputDecorated(sprintf('Loading users (page %d)...', $pageNumber)); } $result = TeamMember::getCollectionWithParent($url, $httpClient, $options); $progress->done(); $members = \array_merge($members, $result['items']); - $url = $result['collection']->getNextPageUrl(); + $nextPage = PaginationUtil::nextPage($result['collection']->getNextPageUrl(), $url, $options['query']); + if ($nextPage === null || !$fetchAllPages) { + break; + } + [$url, $options['query']] = $nextPage; $pageNumber++; - } while ($url && $fetchAllPages); + } if (empty($members)) { $this->stdErr->writeln(\sprintf('No users were found in the team %s.', $this->getTeamLabel($team))); diff --git a/legacy/src/Util/PaginationUtil.php b/legacy/src/Util/PaginationUtil.php new file mode 100644 index 000000000..c1b8cb27a --- /dev/null +++ b/legacy/src/Util/PaginationUtil.php @@ -0,0 +1,48 @@ + $query The query that was used for the current page. + * + * @return array{0: string, 1: array}|null + * The URL and query for the next page, or null if there is no next page. + */ + public static function nextPage(?string $nextPageUrl, string $url, array $query): ?array + { + if ($nextPageUrl === null || $nextPageUrl === '') { + return null; + } + + // Normalize the current query via a round trip, so that keys written in + // bracket notation (page[size]) match the parsed ones (page => [size]). + $currentQuery = []; + parse_str(http_build_query($query), $currentQuery); + + $nextQuery = []; + parse_str((string) parse_url($nextPageUrl, PHP_URL_QUERY), $nextQuery); + + $merged = array_replace_recursive($currentQuery, $nextQuery); + + // Avoid repeating the request that has just been made. + if ($nextPageUrl === $url && $merged === $currentQuery) { + return null; + } + + return [$nextPageUrl, $merged]; + } +} diff --git a/legacy/tests/Util/PaginationUtilTest.php b/legacy/tests/Util/PaginationUtilTest.php new file mode 100644 index 000000000..da2297180 --- /dev/null +++ b/legacy/tests/Util/PaginationUtilTest.php @@ -0,0 +1,66 @@ + ['status' => ['value' => ['active', 'suspended'], 'operator' => 'IN']]]; + + $cases = [ + // The cursor is added to the query, and the filter is kept, as the + // subscriptions endpoint omits it from the next page link. + [ + $url . '?page%5Bafter%5D=' . $cursor . '&page%5Bsize%5D=50', + $url, + $filter + ['page[size]' => 50], + [ + $url . '?page%5Bafter%5D=' . $cursor . '&page%5Bsize%5D=50', + $filter + ['page' => ['size' => '50', 'after' => $cursor]], + ], + ], + // The page size in the next page link takes precedence. + [ + $url . '?page%5Bafter%5D=' . $cursor . '&page%5Bsize%5D=100', + $url, + ['page[size]' => 20], + [ + $url . '?page%5Bafter%5D=' . $cursor . '&page%5Bsize%5D=100', + ['page' => ['size' => '100', 'after' => $cursor]], + ], + ], + // Any other parameter that the next page link omits is kept too. + [ + '/teams/foo/project-access?page%5Bafter%5D=' . $cursor, + '/teams/foo/project-access', + ['sort' => 'project_title'], + [ + '/teams/foo/project-access?page%5Bafter%5D=' . $cursor, + ['sort' => 'project_title', 'page' => ['after' => $cursor]], + ], + ], + // There is no next page. + [null, $url, $filter, null], + ['', $url, $filter, null], + // The next page link repeats the current request. + [ + $url . '?page%5Bafter%5D=' . $cursor, + $url . '?page%5Bafter%5D=' . $cursor, + ['page' => ['after' => $cursor]], + null, + ], + ]; + foreach ($cases as $i => $case) { + [$nextPageUrl, $url, $query, $expected] = $case; + $this->assertEquals($expected, PaginationUtil::nextPage($nextPageUrl, $url, $query), "Case $i"); + } + } +}