From 6498aca7ace486da15474ceaa8ecdbb5f65ea8a9 Mon Sep 17 00:00:00 2001 From: Ondrej Popelka Date: Thu, 19 Mar 2026 11:18:49 +0100 Subject: [PATCH] feat: add pagination to listProjectSandboxes https://linear.app/keboola/issue/AJDA-2441 --- src/ManageClient.php | 58 ++++++++++++++++++++++--- tests/ManageClientUnitTest.php | 78 ++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 7 deletions(-) diff --git a/src/ManageClient.php b/src/ManageClient.php index bb7d732..20fb5b8 100644 --- a/src/ManageClient.php +++ b/src/ManageClient.php @@ -4,7 +4,11 @@ namespace Keboola\Sandboxes\Api; +use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Psr7\Request; +use JsonException; +use Keboola\Sandboxes\Api\Exception\ClientException; +use Keboola\Sandboxes\Api\Exception\ServerException; use function GuzzleHttp\json_encode; class ManageClient extends AbstractClient @@ -23,17 +27,57 @@ public function __construct( /** * @return Sandbox[] - * - * @TODO pagination */ public function listProjectSandboxes(string $projectId): array { - $response = $this->sendRequest(new Request( - 'GET', - sprintf('manage/projects/%s/sandboxes', urlencode($projectId)), - )); + $baseUrl = sprintf('manage/projects/%s/sandboxes', urlencode($projectId)); + $nextPageToken = null; + $sandboxes = []; + + do { + $url = $nextPageToken !== null + ? $baseUrl . '?' . http_build_query(['nextPageToken' => $nextPageToken]) + : $baseUrl; + + try { + $response = $this->client->send(new Request('GET', $url)); + } catch (GuzzleException $e) { + if ($e->getCode() < 500) { + throw new ClientException($e->getMessage(), $e->getCode(), $e); + } + throw new ServerException($e->getMessage(), $e->getCode(), $e); + } + + $body = $response->getBody()->getContents(); + if (!strlen($body)) { + break; + } + + try { + $data = (array) json_decode($body, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new ServerException( + 'Unable to parse response body into JSON: ' . $e->getMessage(), + $e->getCode(), + $e, + ); + } + + foreach ($data as $sandboxData) { + $sandboxes[] = Sandbox::fromArray((array) $sandboxData); + } + + $nextPageToken = null; + // The Link header URL points to the wrong endpoint due to a bug in sandboxes-api, + // so we extract only the nextPageToken from it and reconstruct the correct URL ourselves. + $linkHeader = $response->getHeaderLine('Link'); + if ($linkHeader) { + parse_str((string) parse_url($linkHeader, PHP_URL_QUERY), $query); + $nextPageToken = $query['nextPageToken'] ?? null; + } + } while ($nextPageToken !== null); - return array_map(fn (array $s) => Sandbox::fromArray($s), $response); + return $sandboxes; } /** diff --git a/tests/ManageClientUnitTest.php b/tests/ManageClientUnitTest.php index 605c067..1126ebd 100644 --- a/tests/ManageClientUnitTest.php +++ b/tests/ManageClientUnitTest.php @@ -12,10 +12,88 @@ use GuzzleHttp\Psr7\Uri; use Keboola\Sandboxes\Api\ManageClient; use Keboola\Sandboxes\Api\Project; +use Keboola\Sandboxes\Api\Sandbox; use PHPUnit\Framework\TestCase; class ManageClientUnitTest extends TestCase { + private function makeSandboxJson(string $id): string + { + return (string) json_encode([ + 'id' => $id, + 'projectId' => 'proj1', + 'tokenId' => 'tok1', + 'type' => 'python', + 'active' => true, + 'createdTimestamp' => '2024-01-01 00:00:00', + ]); + } + + public function testListProjectSandboxesSinglePage(): void + { + $requestsLog = []; + + $body = '[' . $this->makeSandboxJson('sandbox-1') . ',' . $this->makeSandboxJson('sandbox-2') . ']'; + $mockedResponses = [ + new Response(200, [], $body), + ]; + + $handlerStack = HandlerStack::create(new MockHandler($mockedResponses)); + $handlerStack->push(Middleware::history($requestsLog)); + + $client = new ManageClient('http://sandboxes-api', 'token', [ + 'handler' => $handlerStack, + ]); + + $sandboxes = $client->listProjectSandboxes('my-project'); + + self::assertCount(2, $sandboxes); + self::assertContainsOnlyInstancesOf(Sandbox::class, $sandboxes); + self::assertSame('sandbox-1', $sandboxes[0]->getId()); + self::assertSame('sandbox-2', $sandboxes[1]->getId()); + + self::assertCount(1, $requestsLog); + $request = $requestsLog[0]['request']; + self::assertSame('GET', $request->getMethod()); + self::assertSame('/manage/projects/my-project/sandboxes', $request->getUri()->getPath()); + self::assertSame('', $request->getUri()->getQuery()); + } + + public function testListProjectSandboxesPagination(): void + { + $requestsLog = []; + + $mockedResponses = [ + new Response(200, [ + 'Link' => 'http://wrong-host/manage/list?nextPageToken=token-abc', + ], '[' . $this->makeSandboxJson('sandbox-1') . ']'), + new Response(200, [], '[' . $this->makeSandboxJson('sandbox-2') . ']'), + ]; + + $handlerStack = HandlerStack::create(new MockHandler($mockedResponses)); + $handlerStack->push(Middleware::history($requestsLog)); + + $client = new ManageClient('http://sandboxes-api', 'token', [ + 'handler' => $handlerStack, + ]); + + $sandboxes = $client->listProjectSandboxes('my-project'); + + self::assertCount(2, $sandboxes); + self::assertSame('sandbox-1', $sandboxes[0]->getId()); + self::assertSame('sandbox-2', $sandboxes[1]->getId()); + + self::assertCount(2, $requestsLog); + + $firstRequest = $requestsLog[0]['request']; + self::assertSame('/manage/projects/my-project/sandboxes', $firstRequest->getUri()->getPath()); + self::assertSame('', $firstRequest->getUri()->getQuery()); + + $secondRequest = $requestsLog[1]['request']; + self::assertSame('/manage/projects/my-project/sandboxes', $secondRequest->getUri()->getPath()); + self::assertSame('nextPageToken=token-abc', $secondRequest->getUri()->getQuery()); + } + public function testUpdateEmptyProject(): void { $requestsLog = [];