diff --git a/libs/sandboxes-service-api-client/README.md b/libs/sandboxes-service-api-client/README.md index efe85307b..cb6aab453 100644 --- a/libs/sandboxes-service-api-client/README.md +++ b/libs/sandboxes-service-api-client/README.md @@ -11,13 +11,12 @@ composer require keboola/sandboxes-service-api-client ```php use Keboola\SandboxesServiceApiClient\Sandboxes\SandboxesApiClient; -use Keboola\SandboxesServiceApiClient\ApiClientConfiguration; -$client = new SandboxesApiClient(new ApiClientConfiguration( +$client = new SandboxesApiClient( baseUrl: 'https://data-science.keboola.com', - storageToken: '{storage-api-token}', + token: '{storage-api-token}', userAgent: 'My App', -)); +); $result = $client->createSandbox([ 'componentId' => 'keboola.data-apps', @@ -31,13 +30,12 @@ $result = $client->createSandbox([ ```php use Keboola\SandboxesServiceApiClient\Apps\AppsApiClient; -use Keboola\SandboxesServiceApiClient\ApiClientConfiguration; -$client = new AppsApiClient(new ApiClientConfiguration( +$client = new AppsApiClient( baseUrl: 'https://data-apps.keboola.com', - storageToken: '{storage-api-token}', + token: '{storage-api-token}', userAgent: 'My App', -)); +); // List all apps $apps = $client->listApps(); diff --git a/libs/sandboxes-service-api-client/composer.json b/libs/sandboxes-service-api-client/composer.json index 2e87e699d..de218271a 100644 --- a/libs/sandboxes-service-api-client/composer.json +++ b/libs/sandboxes-service-api-client/composer.json @@ -9,6 +9,12 @@ "email": "devel@keboola.com" } ], + "repositories": { + "libs": { + "type": "path", + "url": "../../libs/*" + } + }, "autoload": { "psr-4": { "Keboola\\SandboxesServiceApiClient\\": "src/" @@ -22,6 +28,7 @@ "require": { "php": "^8.2", "guzzlehttp/guzzle": "^7.8", + "keboola/php-api-client-base": "*@dev", "monolog/monolog": "^2.0|^3.0", "webmozart/assert": "^1.11" }, diff --git a/libs/sandboxes-service-api-client/src/ApiClient.php b/libs/sandboxes-service-api-client/src/ApiClient.php deleted file mode 100644 index 03f23c109..000000000 --- a/libs/sandboxes-service-api-client/src/ApiClient.php +++ /dev/null @@ -1,118 +0,0 @@ -requestHandlerStack = HandlerStack::create($configuration->requestHandler); - - $this->requestHandlerStack->remove('auth'); - $this->requestHandlerStack->push( - Middleware::mapRequest(new StorageTokenAuthenticator($configuration->storageToken)), - 'auth', - ); - - $this->requestHandlerStack->remove('http_errors'); - $this->requestHandlerStack->unshift( - Middleware::httpErrors(new BodySummarizer(self::MAX_HTTP_ERROR_MESSAGE_LENGTH)), - 'http_errors', - ); - - if ($configuration->backoffMaxTries > 0) { - $this->requestHandlerStack->push(Middleware::retry(new RetryDecider( - $configuration->backoffMaxTries, - $configuration->logger, - ))); - } - - $this->requestHandlerStack->push(Middleware::log($configuration->logger, new MessageFormatter( - '{hostname} {req_header_User-Agent} - [{ts}] "{method} {resource} {protocol}/{version}"' . - ' {code} {res_header_Content-Length}', - ))); - - $this->httpClient = new GuzzleClient([ - 'base_uri' => $configuration->baseUrl, - 'handler' => $this->requestHandlerStack, - 'headers' => [ - 'User-Agent' => $configuration->userAgent, - ], - 'connect_timeout' => 10, - 'timeout' => 120, - ]); - } - - public function sendRequestAndDecodeResponse( - RequestInterface $request, - array $options = [], - ): array { - $response = $this->doSendRequest($request, $options); - - try { - return Json::decodeArray($response->getBody()->getContents()); - } catch (JsonException $e) { - throw new ClientException('Response is not a valid JSON: ' . $e->getMessage(), $e->getCode(), $e); - } - } - - public function sendRequest(RequestInterface $request): void - { - $this->doSendRequest($request); - } - - private function doSendRequest(RequestInterface $request, array $options = []): ResponseInterface - { - try { - return $this->httpClient->send($request, $options); - } catch (RequestException $e) { - throw $this->processRequestException($e) ?? new ClientException($e->getMessage(), $e->getCode(), $e); - } catch (GuzzleException $e) { - throw new ClientException($e->getMessage(), $e->getCode(), $e); - } - } - - private function processRequestException(RequestException $e): ?ClientException - { - $response = $e->getResponse(); - if ($response === null) { - return null; - } - - try { - $data = Json::decodeArray($response->getBody()->getContents()); - } catch (JsonException) { - // throw the original one, we don't care about e2 - return new ClientException(trim($e->getMessage()), $response->getStatusCode(), $e); - } - - if (empty($data['error']) || empty($data['message'])) { - return null; - } - - return new ClientException( - trim(sprintf('%s: %s', $data['error'], $data['message'])), - $response->getStatusCode(), - $e, - ); - } -} diff --git a/libs/sandboxes-service-api-client/src/ApiClientConfiguration.php b/libs/sandboxes-service-api-client/src/ApiClientConfiguration.php deleted file mode 100644 index 5418ea802..000000000 --- a/libs/sandboxes-service-api-client/src/ApiClientConfiguration.php +++ /dev/null @@ -1,35 +0,0 @@ - $backoffMaxTries - */ - public function __construct( - public readonly string $baseUrl, - public readonly string $storageToken, - public readonly string $userAgent, - public readonly int $backoffMaxTries = self::DEFAULT_BACKOFF_RETRIES, - public readonly null|Closure $requestHandler = null, - public readonly LoggerInterface $logger = new NullLogger(), - ) { - Assert::stringNotEmpty($this->baseUrl); - Assert::stringNotEmpty($this->storageToken); - Assert::stringNotEmpty($this->userAgent); - Assert::greaterThanEq($this->backoffMaxTries, 0); - } -} diff --git a/libs/sandboxes-service-api-client/src/Apps/App.php b/libs/sandboxes-service-api-client/src/Apps/App.php index 3b3b7675c..c0d3f3c74 100644 --- a/libs/sandboxes-service-api-client/src/Apps/App.php +++ b/libs/sandboxes-service-api-client/src/Apps/App.php @@ -4,9 +4,10 @@ namespace Keboola\SandboxesServiceApiClient\Apps; -use Keboola\SandboxesServiceApiClient\Exception\ClientException; +use Keboola\ApiClientBase\Exception\ClientException; +use Keboola\ApiClientBase\ResponseModelInterface; -class App +final class App implements ResponseModelInterface { protected const REQUIRED_PROPERTIES = [ 'id', @@ -72,28 +73,28 @@ class App private int $autoSuspendAfterSeconds; private string $provisioningStrategy; - public static function fromArray(array $in): self + public static function fromResponseData(array $data): static { foreach (self::REQUIRED_PROPERTIES as $property) { - if (!isset($in[$property])) { + if (!isset($data[$property])) { throw new ClientException("Property $property is missing from API response"); } } $app = new self(); - $app->setId((string) $in['id']); - $app->setProjectId((string) $in['projectId']); - $app->setComponentId((string) $in['componentId']); - $app->setType(isset($in['type']) ? (string) $in['type'] : null); - $app->setBranchId(isset($in['branchId']) ? $in['branchId'] : null); - $app->setConfigId((string) $in['configId']); - $app->setConfigVersion((string) $in['configVersion']); - $app->setState((string) $in['state']); - $app->setDesiredState((string) $in['desiredState']); - $app->setLastRequestTimestamp($in['lastRequestTimestamp'] ?? null); - $app->setUrl($in['url'] ?? null); - $app->setAutoSuspendAfterSeconds((int) ($in['autoSuspendAfterSeconds'] ?? 0)); - $app->setProvisioningStrategy((string) $in['provisioningStrategy']); + $app->setId((string) $data['id']); + $app->setProjectId((string) $data['projectId']); + $app->setComponentId((string) $data['componentId']); + $app->setType(isset($data['type']) ? (string) $data['type'] : null); + $app->setBranchId(isset($data['branchId']) ? $data['branchId'] : null); + $app->setConfigId((string) $data['configId']); + $app->setConfigVersion((string) $data['configVersion']); + $app->setState((string) $data['state']); + $app->setDesiredState((string) $data['desiredState']); + $app->setLastRequestTimestamp($data['lastRequestTimestamp'] ?? null); + $app->setUrl($data['url'] ?? null); + $app->setAutoSuspendAfterSeconds((int) ($data['autoSuspendAfterSeconds'] ?? 0)); + $app->setProvisioningStrategy((string) $data['provisioningStrategy']); return $app; } diff --git a/libs/sandboxes-service-api-client/src/Apps/AppsApiClient.php b/libs/sandboxes-service-api-client/src/Apps/AppsApiClient.php index e022d3ecf..a4b1c674f 100644 --- a/libs/sandboxes-service-api-client/src/Apps/AppsApiClient.php +++ b/libs/sandboxes-service-api-client/src/Apps/AppsApiClient.php @@ -4,18 +4,52 @@ namespace Keboola\SandboxesServiceApiClient\Apps; +use Closure; +use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Request; -use Keboola\SandboxesServiceApiClient\ApiClient; -use Keboola\SandboxesServiceApiClient\ApiClientConfiguration; -use Keboola\SandboxesServiceApiClient\Json; +use Keboola\ApiClientBase\ApiClient; +use Keboola\ApiClientBase\ApiClientOptions; +use Keboola\ApiClientBase\Auth\StorageApiTokenAuthenticator; +use Keboola\ApiClientBase\Json; +use Keboola\SandboxesServiceApiClient\Exception\SandboxesServiceClientException; +use Keboola\SandboxesServiceApiClient\SandboxesErrorMessageResolver; +use Psr\Log\LoggerInterface; class AppsApiClient { + private const FALLBACK_USER_AGENT = 'Keboola Sandboxes Service API PHP Client'; + private ApiClient $apiClient; - public function __construct(ApiClientConfiguration $configuration) - { - $this->apiClient = new ApiClient($configuration); + /** + * @param non-empty-string $baseUrl + * @param non-empty-string $storageToken + * @param int<0, max> $backoffMaxTries + */ + public function __construct( + string $baseUrl, + string $storageToken, + ?LoggerInterface $logger = null, + int $backoffMaxTries = ApiClientOptions::DEFAULT_BACKOFF_MAX_TRIES, + int $connectTimeout = ApiClientOptions::DEFAULT_CONNECT_TIMEOUT, + int $requestTimeout = ApiClientOptions::DEFAULT_REQUEST_TIMEOUT, + string $userAgent = self::FALLBACK_USER_AGENT, + null|Closure|HandlerStack $requestHandler = null, + ) { + $this->apiClient = new ApiClient( + $baseUrl, + new StorageApiTokenAuthenticator($storageToken), + new ApiClientOptions( + userAgent: $userAgent, + backoffMaxTries: $backoffMaxTries, + connectTimeout: $connectTimeout, + requestTimeout: $requestTimeout, + requestHandler: $requestHandler, + logger: $logger, + ), + errorMessageResolver: new SandboxesErrorMessageResolver(), + exceptionClass: SandboxesServiceClientException::class, + ); } /** @@ -45,23 +79,22 @@ public function listApps( $uri .= '?' . http_build_query($queryParams); } - $responseData = $this->apiClient->sendRequestAndDecodeResponse( + return $this->apiClient->sendRequestAndMapResponse( new Request('GET', $uri), + App::class, + isList: true, ); - - return array_map(fn(array $appData) => App::fromArray($appData), $responseData); } public function getApp(string $appId): App { - $responseData = $this->apiClient->sendRequestAndDecodeResponse( + return $this->apiClient->sendRequestAndMapResponse( new Request( 'GET', sprintf('/apps/%s', $appId), ), + App::class, ); - - return App::fromArray($responseData); } /** @@ -87,7 +120,7 @@ public function patchApp(string $appId, array $payload): void */ public function createApp(array $payload): App { - $responseData = $this->apiClient->sendRequestAndDecodeResponse( + return $this->apiClient->sendRequestAndMapResponse( new Request( 'POST', '/apps', @@ -96,9 +129,8 @@ public function createApp(array $payload): App ], Json::encodeArray($payload), ), + App::class, ); - - return App::fromArray($responseData); } public function deleteApp(string $appId): void diff --git a/libs/sandboxes-service-api-client/src/Authentication/StorageTokenAuthenticator.php b/libs/sandboxes-service-api-client/src/Authentication/StorageTokenAuthenticator.php deleted file mode 100644 index 65e7df354..000000000 --- a/libs/sandboxes-service-api-client/src/Authentication/StorageTokenAuthenticator.php +++ /dev/null @@ -1,21 +0,0 @@ -withHeader(self::STORAGE_TOKEN_HEADER, $this->value); - } -} diff --git a/libs/sandboxes-service-api-client/src/Exception/ClientException.php b/libs/sandboxes-service-api-client/src/Exception/ClientException.php deleted file mode 100644 index 29d70f9dd..000000000 --- a/libs/sandboxes-service-api-client/src/Exception/ClientException.php +++ /dev/null @@ -1,11 +0,0 @@ -= $this->maxRetries) { - return false; - } - - $code = null; - if ($response) { - $code = $response->getStatusCode(); - } elseif ($error instanceof Throwable) { - $code = $error->getCode(); - } - - if ($code >= 400 && $code < 500) { - return false; - } - - if ($error || $code >= 500) { - $this->logger->warning( - sprintf( - 'Request failed (%s), retrying (%s of %s)', - match (true) { - $error instanceof Throwable => $error->getMessage(), - is_scalar($error) => $error, - $response !== null => $response->getBody()->getContents(), - default => 'No error', - }, - $retries, - $this->maxRetries, - ), - ); - return true; - } - - return false; - } -} diff --git a/libs/sandboxes-service-api-client/src/Sandboxes/Legacy/PersistentStorage.php b/libs/sandboxes-service-api-client/src/Sandboxes/Legacy/PersistentStorage.php index 7aa6531fa..d0e4393d0 100644 --- a/libs/sandboxes-service-api-client/src/Sandboxes/Legacy/PersistentStorage.php +++ b/libs/sandboxes-service-api-client/src/Sandboxes/Legacy/PersistentStorage.php @@ -4,7 +4,9 @@ namespace Keboola\SandboxesServiceApiClient\Sandboxes\Legacy; -class PersistentStorage +use Keboola\ApiClientBase\ResponseModelInterface; + +final class PersistentStorage implements ResponseModelInterface { private ?bool $ready = null; private ?string $k8sStorageClassName = ''; @@ -14,12 +16,12 @@ public static function create(): self return new self(); } - public static function fromArray(array $values): self + public static function fromResponseData(array $data): static { return self::create() - ->setReady($values['ready'] ?? null) + ->setReady($data['ready'] ?? null) ->setK8sStorageClassName( - array_key_exists('k8sStorageClassName', $values) ? $values['k8sStorageClassName'] : '', + array_key_exists('k8sStorageClassName', $data) ? $data['k8sStorageClassName'] : '', ) ; } diff --git a/libs/sandboxes-service-api-client/src/Sandboxes/Legacy/Project.php b/libs/sandboxes-service-api-client/src/Sandboxes/Legacy/Project.php index 421c82863..09d31362e 100644 --- a/libs/sandboxes-service-api-client/src/Sandboxes/Legacy/Project.php +++ b/libs/sandboxes-service-api-client/src/Sandboxes/Legacy/Project.php @@ -4,7 +4,9 @@ namespace Keboola\SandboxesServiceApiClient\Sandboxes\Legacy; -class Project +use Keboola\ApiClientBase\ResponseModelInterface; + +final class Project implements ResponseModelInterface { private string $id; @@ -12,14 +14,14 @@ class Project private string $createdTimestamp; private string $updatedTimestamp; - public static function fromArray(array $in): self + public static function fromResponseData(array $data): static { $project = new self(); - $project->id = (string) $in['id']; - $project->createdTimestamp = $in['createdTimestamp']; - $project->updatedTimestamp = $in['updatedTimestamp'] ?? ''; - $project->persistentStorage = isset($in['persistentStorage']) - ? PersistentStorage::fromArray($in['persistentStorage']) + $project->id = (string) $data['id']; + $project->createdTimestamp = $data['createdTimestamp']; + $project->updatedTimestamp = $data['updatedTimestamp'] ?? ''; + $project->persistentStorage = isset($data['persistentStorage']) + ? PersistentStorage::fromResponseData($data['persistentStorage']) : null; return $project; diff --git a/libs/sandboxes-service-api-client/src/Sandboxes/Legacy/Sandbox.php b/libs/sandboxes-service-api-client/src/Sandboxes/Legacy/Sandbox.php index d71ea4feb..1c38c88e4 100644 --- a/libs/sandboxes-service-api-client/src/Sandboxes/Legacy/Sandbox.php +++ b/libs/sandboxes-service-api-client/src/Sandboxes/Legacy/Sandbox.php @@ -4,9 +4,10 @@ namespace Keboola\SandboxesServiceApiClient\Sandboxes\Legacy; -use Keboola\SandboxesServiceApiClient\Exception\ClientException; +use Keboola\ApiClientBase\Exception\ClientException; +use Keboola\ApiClientBase\ResponseModelInterface; -class Sandbox +final class Sandbox implements ResponseModelInterface { public const DEFAULT_EXPIRATION_DAYS = 7; protected const REQUIRED_PROPERTIES = ['id', 'projectId', 'tokenId', 'type', 'active', 'createdTimestamp']; @@ -155,63 +156,63 @@ class Sandbox private ?SandboxCredentials $credentials = null; - public static function fromArray(array $in): self + public static function fromResponseData(array $data): static { foreach (self::REQUIRED_PROPERTIES as $property) { - if (!isset($in[$property])) { + if (!isset($data[$property])) { throw new ClientException("Property $property is missing from API response"); } } - $sandbox = new Sandbox(); - $sandbox->setId((string) $in['id']); - $sandbox->setComponentId((string) $in['componentId']); - $sandbox->setProjectId((string) $in['projectId']); - $sandbox->setTokenId((string) $in['tokenId']); - $sandbox->setType($in['type']); - $sandbox->setActive($in['active'] ?? false); - $sandbox->setShared($in['shared'] ?? false); - $sandbox->setCreatedTimestamp($in['createdTimestamp']); - - $sandbox->setBranchId(isset($in['branchId']) ? (string) $in['branchId'] : null); - $sandbox->setConfigurationId(isset($in['configurationId']) ? (string) $in['configurationId'] : ''); - $sandbox->setConfigurationVersion((string) $in['configurationVersion']); - $sandbox->setPhysicalId($in['physicalId'] ?? ''); - $sandbox->setSize($in['size'] ?? ''); + $sandbox = new self(); + $sandbox->setId((string) $data['id']); + $sandbox->setComponentId((string) $data['componentId']); + $sandbox->setProjectId((string) $data['projectId']); + $sandbox->setTokenId((string) $data['tokenId']); + $sandbox->setType($data['type']); + $sandbox->setActive($data['active'] ?? false); + $sandbox->setShared($data['shared'] ?? false); + $sandbox->setCreatedTimestamp($data['createdTimestamp']); + + $sandbox->setBranchId(isset($data['branchId']) ? (string) $data['branchId'] : null); + $sandbox->setConfigurationId(isset($data['configurationId']) ? (string) $data['configurationId'] : ''); + $sandbox->setConfigurationVersion((string) $data['configurationVersion']); + $sandbox->setPhysicalId($data['physicalId'] ?? ''); + $sandbox->setSize($data['size'] ?? ''); $sandbox->setSizeParameters( - isset($in['sizeParameters']) ? - SandboxSizeParameters::fromArray($in['sizeParameters']) : + isset($data['sizeParameters']) ? + SandboxSizeParameters::fromResponseData($data['sizeParameters']) : null, ); - $sandbox->setUser($in['user'] ?? ''); - $sandbox->setHost($in['host'] ?? ''); - $sandbox->setUrl($in['url'] ?? ''); - $sandbox->setImageVersion($in['imageVersion'] ?? ''); - $sandbox->setStagingWorkspaceId(isset($in['stagingWorkspaceId']) ? (string) $in['stagingWorkspaceId'] : ''); - $sandbox->setStagingWorkspaceType($in['stagingWorkspaceType'] ?? ''); - $sandbox->setWorkspaceDetails($in['workspaceDetails'] ?? []); - $sandbox->setAutosaveTokenId(isset($in['autosaveTokenId']) ? (string) $in['autosaveTokenId'] : ''); - $sandbox->setPackages($in['packages'] ?? []); - $sandbox->setUpdatedTimestamp($in['updatedTimestamp'] ?? ''); - $sandbox->setExpirationTimestamp($in['expirationTimestamp'] ?? ''); - $sandbox->setLastAutosaveTimestamp($in['lastAutosaveTimestamp'] ?? ''); - $sandbox->setExpirationAfterHours($in['expirationAfterHours'] ?? 0); - $sandbox->setAutoSuspendAfterSeconds($in['autoSuspendAfterSeconds'] ?? 0); - $sandbox->setDeletedTimestamp($in['deletedTimestamp'] ?? ''); - - $sandbox->setDatabricksSparkVersion($in['databricks']['sparkVersion'] ?? ''); - $sandbox->setDatabricksNodeType($in['databricks']['nodeType'] ?? ''); - $sandbox->setDatabricksNumberOfNodes($in['databricks']['numberOfNodes'] ?? 0); - $sandbox->setDatabricksClusterId($in['databricks']['clusterId'] ?? ''); - - $sandbox->persistentStoragePvcName = $in['persistentStorage']['pvcName'] ?? null; - $sandbox->persistentStorageK8sManifest = $in['persistentStorage']['k8sManifest'] ?? null; - $sandbox->persistentStorageReady = isset($in['persistentStorage']['ready']) - ? (bool) $in['persistentStorage']['ready'] + $sandbox->setUser($data['user'] ?? ''); + $sandbox->setHost($data['host'] ?? ''); + $sandbox->setUrl($data['url'] ?? ''); + $sandbox->setImageVersion($data['imageVersion'] ?? ''); + $sandbox->setStagingWorkspaceId(isset($data['stagingWorkspaceId']) ? (string) $data['stagingWorkspaceId'] : ''); + $sandbox->setStagingWorkspaceType($data['stagingWorkspaceType'] ?? ''); + $sandbox->setWorkspaceDetails($data['workspaceDetails'] ?? []); + $sandbox->setAutosaveTokenId(isset($data['autosaveTokenId']) ? (string) $data['autosaveTokenId'] : ''); + $sandbox->setPackages($data['packages'] ?? []); + $sandbox->setUpdatedTimestamp($data['updatedTimestamp'] ?? ''); + $sandbox->setExpirationTimestamp($data['expirationTimestamp'] ?? ''); + $sandbox->setLastAutosaveTimestamp($data['lastAutosaveTimestamp'] ?? ''); + $sandbox->setExpirationAfterHours($data['expirationAfterHours'] ?? 0); + $sandbox->setAutoSuspendAfterSeconds($data['autoSuspendAfterSeconds'] ?? 0); + $sandbox->setDeletedTimestamp($data['deletedTimestamp'] ?? ''); + + $sandbox->setDatabricksSparkVersion($data['databricks']['sparkVersion'] ?? ''); + $sandbox->setDatabricksNodeType($data['databricks']['nodeType'] ?? ''); + $sandbox->setDatabricksNumberOfNodes($data['databricks']['numberOfNodes'] ?? 0); + $sandbox->setDatabricksClusterId($data['databricks']['clusterId'] ?? ''); + + $sandbox->persistentStoragePvcName = $data['persistentStorage']['pvcName'] ?? null; + $sandbox->persistentStorageK8sManifest = $data['persistentStorage']['k8sManifest'] ?? null; + $sandbox->persistentStorageReady = isset($data['persistentStorage']['ready']) + ? (bool) $data['persistentStorage']['ready'] : null; - $sandbox->persistentStorageK8sStorageClassName = $in['persistentStorage']['k8sStorageClassName'] ?? null; + $sandbox->persistentStorageK8sStorageClassName = $data['persistentStorage']['k8sStorageClassName'] ?? null; - self::setPasswordOrCredentials($in, $sandbox); + self::setPasswordOrCredentials($data, $sandbox); return $sandbox; } @@ -225,7 +226,7 @@ private static function setPasswordOrCredentials(array $in, Sandbox $sandbox): v if (isset($in['password'])) { $sandbox->setPassword($in['password']); } elseif (isset($in['credentials'])) { - $sandbox->setCredentials(SandboxCredentials::fromArray($in['credentials'])); + $sandbox->setCredentials(SandboxCredentials::fromResponseData($in['credentials'])); } else { $sandbox->setPassword(''); } diff --git a/libs/sandboxes-service-api-client/src/Sandboxes/Legacy/SandboxCredentials.php b/libs/sandboxes-service-api-client/src/Sandboxes/Legacy/SandboxCredentials.php index 40038c5c6..e834e67c2 100644 --- a/libs/sandboxes-service-api-client/src/Sandboxes/Legacy/SandboxCredentials.php +++ b/libs/sandboxes-service-api-client/src/Sandboxes/Legacy/SandboxCredentials.php @@ -5,8 +5,9 @@ namespace Keboola\SandboxesServiceApiClient\Sandboxes\Legacy; use InvalidArgumentException; +use Keboola\ApiClientBase\ResponseModelInterface; -class SandboxCredentials +final class SandboxCredentials implements ResponseModelInterface { private string $type; private string $projectId; @@ -32,7 +33,7 @@ class SandboxCredentials 'private_key', ]; - public static function fromArray(array $data): self + public static function fromResponseData(array $data): static { self::checkRequiredKeys($data); diff --git a/libs/sandboxes-service-api-client/src/Sandboxes/Legacy/SandboxSizeParameters.php b/libs/sandboxes-service-api-client/src/Sandboxes/Legacy/SandboxSizeParameters.php index f549a787a..e753aaca2 100644 --- a/libs/sandboxes-service-api-client/src/Sandboxes/Legacy/SandboxSizeParameters.php +++ b/libs/sandboxes-service-api-client/src/Sandboxes/Legacy/SandboxSizeParameters.php @@ -4,7 +4,9 @@ namespace Keboola\SandboxesServiceApiClient\Sandboxes\Legacy; -class SandboxSizeParameters +use Keboola\ApiClientBase\ResponseModelInterface; + +final class SandboxSizeParameters implements ResponseModelInterface { private ?int $storageSize_GB = null; @@ -13,7 +15,7 @@ public static function create(): self return new self(); } - public static function fromArray(array $data): self + public static function fromResponseData(array $data): static { $instance = new self(); $instance->setStorageSizeGB($data['storageSize_GB'] ?? null); diff --git a/libs/sandboxes-service-api-client/src/Sandboxes/SandboxesApiClient.php b/libs/sandboxes-service-api-client/src/Sandboxes/SandboxesApiClient.php index 96e919e21..435c2e78f 100644 --- a/libs/sandboxes-service-api-client/src/Sandboxes/SandboxesApiClient.php +++ b/libs/sandboxes-service-api-client/src/Sandboxes/SandboxesApiClient.php @@ -4,37 +4,70 @@ namespace Keboola\SandboxesServiceApiClient\Sandboxes; +use Closure; +use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Request; -use Keboola\SandboxesServiceApiClient\ApiClient; -use Keboola\SandboxesServiceApiClient\ApiClientConfiguration; -use Keboola\SandboxesServiceApiClient\Json; +use Keboola\ApiClientBase\ApiClient; +use Keboola\ApiClientBase\ApiClientOptions; +use Keboola\ApiClientBase\Auth\StorageApiTokenAuthenticator; +use Keboola\ApiClientBase\Json; +use Keboola\SandboxesServiceApiClient\Exception\SandboxesServiceClientException; use Keboola\SandboxesServiceApiClient\Sandboxes\Legacy\Project; use Keboola\SandboxesServiceApiClient\Sandboxes\Legacy\Sandbox; +use Keboola\SandboxesServiceApiClient\SandboxesErrorMessageResolver; +use Psr\Log\LoggerInterface; class SandboxesApiClient { + private const FALLBACK_USER_AGENT = 'Keboola Sandboxes Service API PHP Client'; + private ApiClient $apiClient; - public function __construct(ApiClientConfiguration $configuration) - { - $this->apiClient = new ApiClient($configuration); + /** + * @param non-empty-string $baseUrl + * @param non-empty-string $storageToken + * @param int<0, max> $backoffMaxTries + */ + public function __construct( + string $baseUrl, + string $storageToken, + ?LoggerInterface $logger = null, + int $backoffMaxTries = ApiClientOptions::DEFAULT_BACKOFF_MAX_TRIES, + int $connectTimeout = ApiClientOptions::DEFAULT_CONNECT_TIMEOUT, + int $requestTimeout = ApiClientOptions::DEFAULT_REQUEST_TIMEOUT, + string $userAgent = self::FALLBACK_USER_AGENT, + null|Closure|HandlerStack $requestHandler = null, + ) { + $this->apiClient = new ApiClient( + $baseUrl, + new StorageApiTokenAuthenticator($storageToken), + new ApiClientOptions( + userAgent: $userAgent, + backoffMaxTries: $backoffMaxTries, + connectTimeout: $connectTimeout, + requestTimeout: $requestTimeout, + requestHandler: $requestHandler, + logger: $logger, + ), + errorMessageResolver: new SandboxesErrorMessageResolver(), + exceptionClass: SandboxesServiceClientException::class, + ); } public function getSandbox(string $sandboxId): Sandbox { - $responseData = $this->apiClient->sendRequestAndDecodeResponse( + return $this->apiClient->sendRequestAndMapResponse( new Request( 'GET', sprintf('/sandboxes/%s', $sandboxId), ), + Sandbox::class, ); - - return Sandbox::fromArray($responseData); } public function createSandbox(array $payload): Sandbox { - $responseData = $this->apiClient->sendRequestAndDecodeResponse( + return $this->apiClient->sendRequestAndMapResponse( new Request( 'POST', '/sandboxes', @@ -43,9 +76,8 @@ public function createSandbox(array $payload): Sandbox ], Json::encodeArray($payload), ), + Sandbox::class, ); - - return Sandbox::fromArray($responseData); } public function deleteSandbox(string $sandboxId): void @@ -60,7 +92,7 @@ public function deleteSandbox(string $sandboxId): void public function updateSandbox(string $sandboxId, array $array): Sandbox { - $responseData = $this->apiClient->sendRequestAndDecodeResponse( + return $this->apiClient->sendRequestAndMapResponse( new Request( 'PATCH', sprintf('/sandboxes/%s', $sandboxId), @@ -69,20 +101,18 @@ public function updateSandbox(string $sandboxId, array $array): Sandbox ], Json::encodeArray($array), ), + Sandbox::class, ); - - return Sandbox::fromArray($responseData); } public function getCurrentProject(): Project { - $responseData = $this->apiClient->sendRequestAndDecodeResponse( + return $this->apiClient->sendRequestAndMapResponse( new Request( 'GET', '/sandboxes/project', ), + Project::class, ); - - return Project::fromArray($responseData); } } diff --git a/libs/sandboxes-service-api-client/src/SandboxesErrorMessageResolver.php b/libs/sandboxes-service-api-client/src/SandboxesErrorMessageResolver.php new file mode 100644 index 000000000..c051c7aaf --- /dev/null +++ b/libs/sandboxes-service-api-client/src/SandboxesErrorMessageResolver.php @@ -0,0 +1,30 @@ +reset(); - $mockserver->expect([ - 'httpRequest' => [ - 'method' => 'GET', - 'path' => '/foo/bar', - ], - 'httpResponse' => [ - 'statusCode' => 200, - ], - ]); - - $apiClient = $this->getApiClient($mockserver->getServerUrl()); - $apiClient->sendRequest(new Request('GET', 'foo/bar')); - - $recordedRequests = $mockserver->fetchRecordedRequests([ - 'method' => 'GET', - 'path' => '/foo/bar', - ]); - self::assertCount(1, $recordedRequests); - $request = $recordedRequests[0]; - - self::assertSame('GET', $request['method']); - self::assertSame('/foo/bar', $request['path']); - self::assertTrue($request['keepAlive']); - self::assertSame('Keboola Sandboxes Service API PHP Client', $request['headers']['user-agent'] ?? null); - self::assertArrayNotHasKey('content-type', $request['headers']); - } - - public function testSendRequestWithDecodedResponse(): void - { - $mockserver = new Mockserver(); - $mockserver->reset(); - $mockserver->expect([ - 'httpRequest' => [ - 'method' => 'POST', - 'path' => '/foo/bar', - 'headers' => [ - 'Content-Type' => 'application/json', - ], - 'body' => '{"foo":"baz"}', - ], - 'httpResponse' => [ - 'statusCode' => 200, - 'body' => Json::encodeArray(['foo' => 'bar']), - ], - ]); - - $apiClient = $this->getApiClient($mockserver->getServerUrl()); - - $response = $apiClient->sendRequestAndDecodeResponse( - new Request( - 'POST', - 'foo/bar', - ['Content-Type' => 'application/json'], - '{"foo":"baz"}', - ), - ); - - self::assertEquals( - ['foo' => 'bar'], - $response, - ); - - $recordedRequests = $mockserver->fetchRecordedRequests([ - 'method' => 'POST', - 'path' => '/foo/bar', - ]); - self::assertCount(1, $recordedRequests); - $request = $recordedRequests[0]; - - self::assertSame('POST', $request['method']); - self::assertSame('/foo/bar', $request['path']); - self::assertTrue($request['keepAlive']); - self::assertSame('Keboola Sandboxes Service API PHP Client', $request['headers']['user-agent'] ?? null); - self::assertSame('application/json', $request['headers']['content-type'] ?? null); - self::assertSame('{"foo":"baz"}', base64_decode($request['body']['rawBytes'] ?? '')); - } - - public function testSendRequestWithArrayResponse(): void - { - $mockserver = new Mockserver(); - $mockserver->reset(); - $mockserver->expect([ - 'httpRequest' => [ - 'method' => 'POST', - 'path' => '/foo/bar', - 'headers' => [ - 'Content-Type' => 'application/json', - ], - 'body' => '{"foo":"baz"}', - ], - 'httpResponse' => [ - 'statusCode' => 200, - 'body' => Json::encodeArray([ - ['foo' => 'bar'], - ['foo' => 'me'], - ]), - ], - ]); - - $apiClient = $this->getApiClient($mockserver->getServerUrl()); - - $response = $apiClient->sendRequestAndDecodeResponse( - new Request( - 'POST', - 'foo/bar', - ['Content-Type' => 'application/json'], - '{"foo":"baz"}', - ), - ); - - self::assertEquals( - [ - ['foo' => 'bar'], - ['foo' => 'me'], - ], - $response, - ); - } - - public function testSendRequestFailingWithRegularError(): void - { - $mockserver = new Mockserver(); - $mockserver->reset(); - $mockserver->expect([ - 'httpRequest' => [ - 'method' => 'GET', - 'path' => '/foo/bar', - ], - 'httpResponse' => [ - 'statusCode' => 400, - 'body' => Json::encodeArray([ - 'error' => 'BadRequest', - 'message' => 'This is not good', - ]), - ], - ]); - - $apiClient = $this->getApiClient($mockserver->getServerUrl()); - - $this->expectException(ClientException::class); - $this->expectExceptionMessage('BadRequest: This is not good'); - - $apiClient->sendRequest(new Request('GET', 'foo/bar')); - } - - public function testSendRequestFailingWithUnexpectedError(): void - { - $mockserver = new Mockserver(); - $mockserver->reset(); - $mockserver->expect([ - 'httpRequest' => [ - 'method' => 'GET', - 'path' => '/foo/bar', - ], - 'httpResponse' => [ - 'statusCode' => 400, - 'body' => 'Gateway timeout', - ], - ]); - - $apiClient = $this->getApiClient($mockserver->getServerUrl()); - - $this->expectException(ClientException::class); - $this->expectExceptionMessage( - "GET http://mockserver:1080/foo/bar` resulted in a `400 Bad Request` response:\nGateway timeout", - ); - - $apiClient->sendRequest(new Request('GET', 'foo/bar')); - } - - public function testRetrySuccess(): void - { - $mockserver = new Mockserver(); - $mockserver->reset(); - $mockserver->expect([ - 'httpRequest' => [ - 'method' => 'GET', - 'path' => '/foo/bar', - ], - 'httpResponse' => [ - 'statusCode' => 500, - ], - 'times' => [ - 'remainingTimes' => 2, - ], - ]); - $mockserver->expect([ - 'httpRequest' => [ - 'method' => 'GET', - 'path' => '/foo/bar', - ], - 'httpResponse' => [ - 'statusCode' => 200, - ], - ]); - - $apiClient = $this->getApiClient($mockserver->getServerUrl()); - $apiClient->sendRequest(new Request('GET', 'foo/bar')); - - $recordedRequests = $mockserver->fetchRecordedRequests([ - 'method' => 'GET', - 'path' => '/foo/bar', - ]); - self::assertCount(3, $recordedRequests); - } - - public function testRetryFailure(): void - { - $mockserver = new Mockserver(); - $mockserver->reset(); - $mockserver->expect([ - 'httpRequest' => [ - 'method' => 'GET', - 'path' => '/foo/bar', - ], - 'httpResponse' => [ - 'statusCode' => 500, - 'body' => 'error occurred', - ], - ]); - - $apiClient = new ApiClient(new ApiClientConfiguration( - $mockserver->getServerUrl(), - storageToken: 'token', - userAgent: 'Keboola Sandboxes Service API PHP Client', - backoffMaxTries: 2, - )); - - $this->expectException(ClientException::class); - $this->expectExceptionMessage( - 'Server error: `GET http://mockserver:1080/foo/bar` resulted in a `500 Internal Server Error` response: -error occurred', - ); - - $apiClient->sendRequest(new Request('GET', 'foo/bar')); - } -} diff --git a/libs/sandboxes-service-api-client/tests/ApiClientTest.php b/libs/sandboxes-service-api-client/tests/ApiClientTest.php deleted file mode 100644 index d0e1ecd24..000000000 --- a/libs/sandboxes-service-api-client/tests/ApiClientTest.php +++ /dev/null @@ -1,131 +0,0 @@ -logsHandler = new TestHandler(); - $this->logger = new Logger('tests', [$this->logsHandler]); - } - - /** - * @param non-empty-string $url - */ - private function getApiClient(string $url): ApiClient - { - return new ApiClient(new ApiClientConfiguration( - $url, - 'token', - 'Keboola Sandboxes Service API PHP Client', - )); - } - - public function testCreateClientWithDefaults(): void - { - $client = $this->getApiClient('http://example.com'); - - $httpClient = self::getPrivatePropertyValue($client, 'httpClient'); - self::assertInstanceOf(GuzzleClient::class, $httpClient); - $httpClientConfig = self::getPrivatePropertyValue($httpClient, 'config'); - self::assertIsArray($httpClientConfig); - - self::assertSame('http://example.com', (string) $httpClientConfig['base_uri']); - self::assertSame(['User-Agent' => 'Keboola Sandboxes Service API PHP Client'], $httpClientConfig['headers']); - self::assertSame(120, $httpClientConfig['timeout']); - self::assertSame(10, $httpClientConfig['connect_timeout']); - } - - /** @dataProvider provideInvalidOptions */ - public function testInvalidOptions( - string $baseUrl, - string $storageToken, - string $userAgent, - ?int $backoffMaxTries, - string $expectedError, - ): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage($expectedError); - - new ApiClient(new ApiClientConfiguration( - $baseUrl, // @phpstan-ignore-line intentionally passing invalid values - $storageToken, // @phpstan-ignore-line - $userAgent, // @phpstan-ignore-line - $backoffMaxTries, // @phpstan-ignore-line - )); - } - - public function provideInvalidOptions(): iterable - { - yield 'empty baseUrl' => [ - 'baseUrl' => '', - 'storageToken' => 'token', - 'userAgent' => 'Test App', - 'backoffMaxTries' => 0, - 'error' => 'Expected a different value than "".', - ]; - - yield 'empty storageToken' => [ - 'baseUrl' => 'https://example.com', - 'storageToken' => '', - 'userAgent' => 'Test App', - 'backoffMaxTries' => 0, - 'error' => 'Expected a different value than "".', - ]; - - yield 'empty userAgent' => [ - 'baseUrl' => 'https://example.com', - 'storageToken' => 'token', - 'userAgent' => '', - 'backoffMaxTries' => 0, - 'error' => 'Expected a different value than "".', - ]; - - yield 'negative backoffMaxTries' => [ - 'baseUrl' => 'http://example.com', - 'storageToken' => 'token', - 'userAgent' => 'Test App', - 'backoffMaxTries' => -1, - 'error' => 'Expected a value greater than or equal to 0. Got: -1', - ]; - } - - public function testLogger(): void - { - $client = new ApiClient(new ApiClientConfiguration( - 'http://example.com', - 'token', - 'Keboola Sandboxes Service API PHP Client', - requestHandler: fn($request) => Create::promiseFor(new Response(201, [], 'boo')), - logger: $this->logger, - )); - - $client->sendRequest(new Request('GET', '/')); - self::assertTrue($this->logsHandler->hasInfoThatMatches( - '#^[\w\d]+ Keboola Sandboxes Service API PHP Client ' . - '- \[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00\] "GET /1.1" 201 $#', - )); - } -} diff --git a/libs/sandboxes-service-api-client/tests/Apps/AppTest.php b/libs/sandboxes-service-api-client/tests/Apps/AppTest.php index c4249f494..92ca73f0a 100644 --- a/libs/sandboxes-service-api-client/tests/Apps/AppTest.php +++ b/libs/sandboxes-service-api-client/tests/Apps/AppTest.php @@ -5,15 +5,15 @@ namespace Keboola\SandboxesServiceApiClient\Tests\Apps; use Generator; +use Keboola\ApiClientBase\Exception\ClientException; use Keboola\SandboxesServiceApiClient\Apps\App; -use Keboola\SandboxesServiceApiClient\Exception\ClientException; use PHPUnit\Framework\TestCase; class AppTest extends TestCase { public function testGetters(): void { - $app = App::fromArray([ + $app = App::fromResponseData([ 'id' => 'app-id', 'projectId' => 'project-id', 'componentId' => 'keboola.data-apps', @@ -44,7 +44,7 @@ public function testGetters(): void public function testGettersWithNullableValues(): void { - $app = App::fromArray([ + $app = App::fromResponseData([ 'id' => 'app-id', 'projectId' => 'project-id', 'componentId' => 'keboola.data-apps', @@ -83,7 +83,7 @@ public function testToArray(): void 'provisioningStrategy' => 'operator', ]; - $app = App::fromArray($expectedData); + $app = App::fromResponseData($expectedData); self::assertSame($expectedData, $app->toArray()); } @@ -130,7 +130,7 @@ public function testMissingRequiredProperties(string $missingProperty): void $this->expectException(ClientException::class); $this->expectExceptionMessage("Property $missingProperty is missing from API response"); - App::fromArray($data); + App::fromResponseData($data); } /** @@ -280,10 +280,10 @@ public function testSettersReturnSelfForChaining(): void self::assertSame($app, $app->setAutoSuspendAfterSeconds(123)); } - public function testFromArrayWithEmptyAutoSuspendAfterSeconds(): void + public function testFromResponseDataWithEmptyAutoSuspendAfterSeconds(): void { // Test missing autoSuspendAfterSeconds defaults to 0 - $app = App::fromArray([ + $app = App::fromResponseData([ 'id' => 'app-id', 'projectId' => 'project-id', 'componentId' => 'keboola.data-apps', @@ -331,4 +331,65 @@ public function testValidationErrorContainsAllValidValues(): void } } } + + public function testFromResponseDataCastsNumericValuesToStrings(): void + { + // Mutation testing: verify that numeric values from API response are cast to strings + $app = App::fromResponseData([ + 'id' => 12345, + 'projectId' => 67890, + 'componentId' => 99999, + 'configId' => 111, + 'configVersion' => 5, + 'state' => 'running', + 'desiredState' => 'running', + 'provisioningStrategy' => 'operator', + 'type' => 42, + 'autoSuspendAfterSeconds' => '3600', + ]); + + self::assertSame('12345', $app->getId()); + self::assertSame('67890', $app->getProjectId()); + self::assertSame('99999', $app->getComponentId()); + self::assertSame('111', $app->getConfigId()); + self::assertSame('5', $app->getConfigVersion()); + self::assertSame('42', $app->getType()); + self::assertSame(3600, $app->getAutoSuspendAfterSeconds()); + } + + public function testSetTypeIsCallablePublicly(): void + { + // PublicVisibility mutation: setType must be accessible from outside the class + $app = new App(); + $result = $app->setType('streamlit'); + + self::assertSame('streamlit', $app->getType()); + self::assertSame($app, $result); + } + + public function testSetTypeAcceptsNull(): void + { + $app = new App(); + $app->setType('streamlit'); + $result = $app->setType(null); + + self::assertNull($app->getType()); + self::assertSame($app, $result); + } + + public function testFromResponseDataSetsProvisioningStrategyFromCast(): void + { + $app = App::fromResponseData([ + 'id' => 'app-id', + 'projectId' => 'project-id', + 'componentId' => 'keboola.data-apps', + 'configId' => 'config-id', + 'configVersion' => '1', + 'state' => 'running', + 'desiredState' => 'running', + 'provisioningStrategy' => 'jobQueue', + ]); + + self::assertSame('jobQueue', $app->getProvisioningStrategy()); + } } diff --git a/libs/sandboxes-service-api-client/tests/Apps/AppsApiClientTest.php b/libs/sandboxes-service-api-client/tests/Apps/AppsApiClientTest.php index 507c59e9e..422c9a976 100644 --- a/libs/sandboxes-service-api-client/tests/Apps/AppsApiClientTest.php +++ b/libs/sandboxes-service-api-client/tests/Apps/AppsApiClientTest.php @@ -4,19 +4,26 @@ namespace Keboola\SandboxesServiceApiClient\Tests\Apps; +use GuzzleHttp\Client as GuzzleClient; use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Middleware; -use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; -use Keboola\SandboxesServiceApiClient\ApiClientConfiguration; +use InvalidArgumentException; +use Keboola\ApiClientBase\ApiClient; +use Keboola\ApiClientBase\Json; use Keboola\SandboxesServiceApiClient\Apps\App; use Keboola\SandboxesServiceApiClient\Apps\AppsApiClient; -use Keboola\SandboxesServiceApiClient\Json; +use Keboola\SandboxesServiceApiClient\Exception\SandboxesServiceClientException; +use Keboola\SandboxesServiceApiClient\Tests\ReflectionPropertyAccessTestCase; +use Monolog\Handler\TestHandler; +use Monolog\Logger; use PHPUnit\Framework\TestCase; +use Psr\Http\Message\RequestInterface; +use stdClass; class AppsApiClientTest extends TestCase { + use ReflectionPropertyAccessTestCase; + public function testListApps(): void { $responseBody = [ @@ -50,31 +57,26 @@ public function testListApps(): void ], ]; - $requestHandler = self::createRequestHandler($requestsHistory, [ - new Response( - 200, - ['Content-Type' => 'application/json'], - Json::encodeArray($responseBody), - ), + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], Json::encodeArray($responseBody)), ]); + [$requestHandler, $store] = self::createCapturingHandler($mock); $client = new AppsApiClient( - new ApiClientConfiguration( - baseUrl: 'https://data-apps.keboola.com', - storageToken: 'my-token', - userAgent: 'Keboola Sandboxes Service API PHP Client', - requestHandler: $requestHandler(...), - ), + 'https://data-apps.keboola.com', + 'my-token', + backoffMaxTries: 0, + requestHandler: $requestHandler, ); $result = $client->listApps(); $expectedApps = [ - App::fromArray($responseBody[0]), - App::fromArray($responseBody[1]), + App::fromResponseData($responseBody[0]), + App::fromResponseData($responseBody[1]), ]; self::assertEquals($expectedApps, $result); - self::assertCount(1, $requestsHistory); + self::assertCount(1, $store->requests); self::assertRequestEquals( 'GET', 'https://data-apps.keboola.com/apps', @@ -82,7 +84,7 @@ public function testListApps(): void 'X-StorageApi-Token' => 'my-token', ], '', - $requestsHistory[0]['request'], + $store->requests[0], ); } @@ -105,28 +107,23 @@ public function testListAppsWithOffsetAndLimit(): void ], ]; - $requestHandler = self::createRequestHandler($requestsHistory, [ - new Response( - 200, - ['Content-Type' => 'application/json'], - Json::encodeArray($responseBody), - ), + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], Json::encodeArray($responseBody)), ]); + [$requestHandler, $store] = self::createCapturingHandler($mock); $client = new AppsApiClient( - new ApiClientConfiguration( - baseUrl: 'https://data-apps.keboola.com', - storageToken: 'my-token', - userAgent: 'Keboola Sandboxes Service API PHP Client', - requestHandler: $requestHandler(...), - ), + 'https://data-apps.keboola.com', + 'my-token', + backoffMaxTries: 0, + requestHandler: $requestHandler, ); $result = $client->listApps(10, 50); - $expectedApps = [App::fromArray($responseBody[0])]; + $expectedApps = [App::fromResponseData($responseBody[0])]; self::assertEquals($expectedApps, $result); - self::assertCount(1, $requestsHistory); + self::assertCount(1, $store->requests); self::assertRequestEquals( 'GET', 'https://data-apps.keboola.com/apps?offset=10&limit=50', @@ -134,7 +131,7 @@ public function testListAppsWithOffsetAndLimit(): void 'X-StorageApi-Token' => 'my-token', ], '', - $requestsHistory[0]['request'], + $store->requests[0], ); } @@ -155,27 +152,22 @@ public function testGetApp(): void 'provisioningStrategy' => 'operator', ]; - $requestHandler = self::createRequestHandler($requestsHistory, [ - new Response( - 200, - ['Content-Type' => 'application/json'], - Json::encodeArray($responseBody), - ), + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], Json::encodeArray($responseBody)), ]); + [$requestHandler, $store] = self::createCapturingHandler($mock); $client = new AppsApiClient( - new ApiClientConfiguration( - baseUrl: 'https://data-apps.keboola.com', - storageToken: 'my-token', - userAgent: 'Keboola Sandboxes Service API PHP Client', - requestHandler: $requestHandler(...), - ), + 'https://data-apps.keboola.com', + 'my-token', + backoffMaxTries: 0, + requestHandler: $requestHandler, ); $result = $client->getApp('app-id'); - self::assertEquals(App::fromArray($responseBody), $result); + self::assertEquals(App::fromResponseData($responseBody), $result); - self::assertCount(1, $requestsHistory); + self::assertCount(1, $store->requests); self::assertRequestEquals( 'GET', 'https://data-apps.keboola.com/apps/app-id', @@ -183,30 +175,27 @@ public function testGetApp(): void 'X-StorageApi-Token' => 'my-token', ], '', - $requestsHistory[0]['request'], + $store->requests[0], ); } public function testPatchApp(): void { - $requestHandler = self::createRequestHandler($requestsHistory, [ - new Response(200), - ]); + $mock = new MockHandler([new Response(200)]); + [$requestHandler, $store] = self::createCapturingHandler($mock); $client = new AppsApiClient( - new ApiClientConfiguration( - baseUrl: 'https://data-apps.keboola.com', - storageToken: 'my-token', - userAgent: 'Keboola Sandboxes Service API PHP Client', - requestHandler: $requestHandler(...), - ), + 'https://data-apps.keboola.com', + 'my-token', + backoffMaxTries: 0, + requestHandler: $requestHandler, ); $client->patchApp('app-id', [ 'desiredState' => 'stopped', 'restartIfRunning' => false, ]); - self::assertCount(1, $requestsHistory); + self::assertCount(1, $store->requests); self::assertRequestEquals( 'PATCH', 'https://data-apps.keboola.com/apps/app-id', @@ -218,67 +207,28 @@ public function testPatchApp(): void 'desiredState' => 'stopped', 'restartIfRunning' => false, ]), - $requestsHistory[0]['request'], + $store->requests[0], ); } - /** - * @param list $requestsHistory - * @param list $responses - * @return HandlerStack - */ - private static function createRequestHandler(?array &$requestsHistory, array $responses): HandlerStack - { - $requestsHistory = []; - - $stack = HandlerStack::create(new MockHandler($responses)); - $stack->push(Middleware::history($requestsHistory)); - - return $stack; - } - - private static function assertRequestEquals( - string $method, - string $uri, - array $headers, - ?string $body, - Request $request, - ): void { - self::assertSame($method, $request->getMethod()); - self::assertSame($uri, $request->getUri()->__toString()); - - foreach ($headers as $headerName => $headerValue) { - self::assertSame($headerValue, $request->getHeaderLine($headerName)); - } - - self::assertSame($body ?? '', $request->getBody()->getContents()); - } - public function testListAppsWithOnlyOffset(): void { - $responseBody = []; - - $requestHandler = self::createRequestHandler($requestsHistory, [ - new Response( - 200, - ['Content-Type' => 'application/json'], - Json::encodeArray($responseBody), - ), + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], Json::encodeArray([])), ]); + [$requestHandler, $store] = self::createCapturingHandler($mock); $client = new AppsApiClient( - new ApiClientConfiguration( - baseUrl: 'https://data-apps.keboola.com', - storageToken: 'my-token', - userAgent: 'Keboola Sandboxes Service API PHP Client', - requestHandler: $requestHandler(...), - ), + 'https://data-apps.keboola.com', + 'my-token', + backoffMaxTries: 0, + requestHandler: $requestHandler, ); $result = $client->listApps(10); self::assertEquals([], $result); - self::assertCount(1, $requestsHistory); + self::assertCount(1, $store->requests); self::assertRequestEquals( 'GET', 'https://data-apps.keboola.com/apps?offset=10', @@ -286,35 +236,28 @@ public function testListAppsWithOnlyOffset(): void 'X-StorageApi-Token' => 'my-token', ], '', - $requestsHistory[0]['request'], + $store->requests[0], ); } public function testListAppsWithOnlyLimit(): void { - $responseBody = []; - - $requestHandler = self::createRequestHandler($requestsHistory, [ - new Response( - 200, - ['Content-Type' => 'application/json'], - Json::encodeArray($responseBody), - ), + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], Json::encodeArray([])), ]); + [$requestHandler, $store] = self::createCapturingHandler($mock); $client = new AppsApiClient( - new ApiClientConfiguration( - baseUrl: 'https://data-apps.keboola.com', - storageToken: 'my-token', - userAgent: 'Keboola Sandboxes Service API PHP Client', - requestHandler: $requestHandler(...), - ), + 'https://data-apps.keboola.com', + 'my-token', + backoffMaxTries: 0, + requestHandler: $requestHandler, ); $result = $client->listApps(null, 50); self::assertEquals([], $result); - self::assertCount(1, $requestsHistory); + self::assertCount(1, $store->requests); self::assertRequestEquals( 'GET', 'https://data-apps.keboola.com/apps?limit=50', @@ -322,7 +265,7 @@ public function testListAppsWithOnlyLimit(): void 'X-StorageApi-Token' => 'my-token', ], '', - $requestsHistory[0]['request'], + $store->requests[0], ); } @@ -346,27 +289,22 @@ public function testListAppsWithTypes(): void ], ]; - $requestHandler = self::createRequestHandler($requestsHistory, [ - new Response( - 200, - ['Content-Type' => 'application/json'], - Json::encodeArray($responseBody), - ), + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], Json::encodeArray($responseBody)), ]); + [$requestHandler, $store] = self::createCapturingHandler($mock); $client = new AppsApiClient( - new ApiClientConfiguration( - baseUrl: 'https://data-apps.keboola.com', - storageToken: 'my-token', - userAgent: 'Keboola Sandboxes Service API PHP Client', - requestHandler: $requestHandler(...), - ), + 'https://data-apps.keboola.com', + 'my-token', + backoffMaxTries: 0, + requestHandler: $requestHandler, ); $result = $client->listApps(types: ['python', 'r']); - self::assertEquals([App::fromArray($responseBody[0])], $result); + self::assertEquals([App::fromResponseData($responseBody[0])], $result); - self::assertCount(1, $requestsHistory); + self::assertCount(1, $store->requests); self::assertRequestEquals( 'GET', 'https://data-apps.keboola.com/apps?type%5B0%5D=python&type%5B1%5D=r', @@ -374,7 +312,7 @@ public function testListAppsWithTypes(): void 'X-StorageApi-Token' => 'my-token', ], '', - $requestsHistory[0]['request'], + $store->requests[0], ); } @@ -395,21 +333,16 @@ public function testCreateApp(): void 'provisioningStrategy' => 'operator', ]; - $requestHandler = self::createRequestHandler($requestsHistory, [ - new Response( - 200, - ['Content-Type' => 'application/json'], - Json::encodeArray($responseBody), - ), + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], Json::encodeArray($responseBody)), ]); + [$requestHandler, $store] = self::createCapturingHandler($mock); $client = new AppsApiClient( - new ApiClientConfiguration( - baseUrl: 'https://data-apps.keboola.com', - storageToken: 'my-token', - userAgent: 'Keboola Sandboxes Service API PHP Client', - requestHandler: $requestHandler(...), - ), + 'https://data-apps.keboola.com', + 'my-token', + backoffMaxTries: 0, + requestHandler: $requestHandler, ); $payload = [ @@ -421,9 +354,9 @@ public function testCreateApp(): void ]; $result = $client->createApp($payload); - self::assertEquals(App::fromArray($responseBody), $result); + self::assertEquals(App::fromResponseData($responseBody), $result); - self::assertCount(1, $requestsHistory); + self::assertCount(1, $store->requests); self::assertRequestEquals( 'POST', 'https://data-apps.keboola.com/apps', @@ -432,27 +365,24 @@ public function testCreateApp(): void 'Content-Type' => 'application/json', ], Json::encodeArray($payload), - $requestsHistory[0]['request'], + $store->requests[0], ); } public function testDeleteApp(): void { - $requestHandler = self::createRequestHandler($requestsHistory, [ - new Response(202), - ]); + $mock = new MockHandler([new Response(202)]); + [$requestHandler, $store] = self::createCapturingHandler($mock); $client = new AppsApiClient( - new ApiClientConfiguration( - baseUrl: 'https://data-apps.keboola.com', - storageToken: 'my-token', - userAgent: 'Keboola Sandboxes Service API PHP Client', - requestHandler: $requestHandler(...), - ), + 'https://data-apps.keboola.com', + 'my-token', + backoffMaxTries: 0, + requestHandler: $requestHandler, ); $client->deleteApp('app-id'); - self::assertCount(1, $requestsHistory); + self::assertCount(1, $store->requests); self::assertRequestEquals( 'DELETE', 'https://data-apps.keboola.com/apps/app-id', @@ -460,7 +390,355 @@ public function testDeleteApp(): void 'X-StorageApi-Token' => 'my-token', ], '', - $requestsHistory[0]['request'], + $store->requests[0], + ); + } + + /** + * @param MockHandler $mock + * @return array{0: \Closure, 1: stdClass} + */ + private static function createCapturingHandler(MockHandler $mock): array + { + $store = new stdClass(); + $store->requests = []; + $handler = static function (RequestInterface $request, array $options) use ($mock, $store) { + $store->requests[] = $request; + return $mock($request, $options); + }; + + return [$handler, $store]; + } + + private static function assertRequestEquals( + string $method, + string $uri, + array $headers, + ?string $body, + RequestInterface $request, + ): void { + self::assertSame($method, $request->getMethod()); + self::assertSame($uri, $request->getUri()->__toString()); + + foreach ($headers as $headerName => $headerValue) { + self::assertSame($headerValue, $request->getHeaderLine($headerName)); + } + + self::assertSame($body ?? '', $request->getBody()->getContents()); + } + + public function testCustomUserAgentIsPassedInRequest(): void + { + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], Json::encodeArray([])), + ]); + [$requestHandler, $store] = self::createCapturingHandler($mock); + + $client = new AppsApiClient( + 'https://data-apps.keboola.com', + 'my-token', + backoffMaxTries: 0, + userAgent: 'Custom Agent/1.0', + requestHandler: $requestHandler, + ); + $client->listApps(); + + self::assertCount(1, $store->requests); + self::assertSame( + 'Custom Agent/1.0', + $store->requests[0]->getHeaderLine('User-Agent'), + ); + } + + public function testDefaultOptionsWithRetry(): void + { + // Test that default backoffMaxTries=5 allows retries on 500 errors + $mock = new MockHandler([ + new Response(500), + new Response(200, ['Content-Type' => 'application/json'], Json::encodeArray([])), + ]); + [$requestHandler, $store] = self::createCapturingHandler($mock); + + $client = new AppsApiClient( + 'https://data-apps.keboola.com', + 'my-token', + requestHandler: $requestHandler, ); + // Should succeed after retry + $result = $client->listApps(); + self::assertSame([], $result); + self::assertCount(2, $store->requests); + } + + public function testConstructWithNullOptionsDoesNotThrow(): void + { + // With null options the facade should construct without error + $client = new AppsApiClient( + 'https://data-apps.keboola.com', + 'my-token', + ); + self::assertInstanceOf(AppsApiClient::class, $client); + } + + public function testEmptyTokenThrows(): void + { + $this->expectException(InvalidArgumentException::class); + // @phpstan-ignore argument.type + new AppsApiClient('https://data-apps.keboola.com', ''); + } + + public function testErrorMessageResolverCombinesErrorAndMessage(): void + { + // Test custom error resolver: error+message format "BadRequest: This is not good" + $mock = new MockHandler([ + new Response( + 400, + ['Content-Type' => 'application/json'], + Json::encodeArray(['error' => 'BadRequest', 'message' => 'This is not good']), + ), + ]); + [$requestHandler, $store] = self::createCapturingHandler($mock); + + $client = new AppsApiClient( + 'https://data-apps.keboola.com', + 'my-token', + backoffMaxTries: 0, + requestHandler: $requestHandler, + ); + + try { + $client->getApp('app-id'); + self::fail('Expected SandboxesServiceClientException'); + } catch (SandboxesServiceClientException $e) { + self::assertSame('BadRequest: This is not good', $e->getMessage()); + self::assertCount(1, $store->requests); + } + } + + public function testErrorMessageResolverTrimsResult(): void + { + // UnwrapTrim: verify that trim() removes trailing whitespace from the combined message + $mock = new MockHandler([ + new Response( + 400, + ['Content-Type' => 'application/json'], + Json::encodeArray(['error' => 'BadRequest', 'message' => 'This is not good ']), + ), + ]); + [$requestHandler] = self::createCapturingHandler($mock); + + $client = new AppsApiClient( + 'https://data-apps.keboola.com', + 'my-token', + backoffMaxTries: 0, + requestHandler: $requestHandler, + ); + + try { + $client->getApp('app-id'); + self::fail('Expected SandboxesServiceClientException'); + } catch (SandboxesServiceClientException $e) { + // trim() should remove trailing space → 'BadRequest: This is not good' not 'BadRequest: This is not good ' + self::assertSame('BadRequest: This is not good', $e->getMessage()); + } + } + + public function testErrorMessageResolverDoesNotCombineWhenErrorMissing(): void + { + // LogicalAnd: verify that missing 'error' key prevents custom format + $mock = new MockHandler([ + new Response( + 400, + ['Content-Type' => 'application/json'], + Json::encodeArray(['message' => 'This is not good']), + ), + ]); + [$requestHandler] = self::createCapturingHandler($mock); + + $client = new AppsApiClient( + 'https://data-apps.keboola.com', + 'my-token', + backoffMaxTries: 0, + requestHandler: $requestHandler, + ); + + try { + $client->getApp('app-id'); + self::fail('Expected SandboxesServiceClientException'); + } catch (SandboxesServiceClientException $e) { + // Should NOT be in "error: message" format since 'error' key is missing + self::assertStringNotContainsString(': This is not good', $e->getMessage()); + } + } + + public function testErrorMessageResolverDoesNotCombineWhenMessageMissing(): void + { + // LogicalAnd: verify that missing 'message' key prevents custom format + $mock = new MockHandler([ + new Response( + 400, + ['Content-Type' => 'application/json'], + Json::encodeArray(['error' => 'BadRequest']), + ), + ]); + [$requestHandler] = self::createCapturingHandler($mock); + + $client = new AppsApiClient( + 'https://data-apps.keboola.com', + 'my-token', + backoffMaxTries: 0, + requestHandler: $requestHandler, + ); + + try { + $client->getApp('app-id'); + self::fail('Expected SandboxesServiceClientException'); + } catch (SandboxesServiceClientException $e) { + // Should NOT produce "BadRequest: " format since 'message' key is missing + self::assertStringNotContainsString('BadRequest: ', $e->getMessage()); + } + } + + public function testErrorMessageResolverDoesNotCombineWhenErrorIsEmpty(): void + { + // LogicalAnd: verify that empty 'error' string prevents custom format + $mock = new MockHandler([ + new Response( + 400, + ['Content-Type' => 'application/json'], + Json::encodeArray(['error' => '', 'message' => 'This is not good']), + ), + ]); + [$requestHandler] = self::createCapturingHandler($mock); + + $client = new AppsApiClient( + 'https://data-apps.keboola.com', + 'my-token', + backoffMaxTries: 0, + requestHandler: $requestHandler, + ); + + try { + $client->getApp('app-id'); + self::fail('Expected SandboxesServiceClientException'); + } catch (SandboxesServiceClientException $e) { + // Empty error field → should NOT produce ": This is not good" format + self::assertStringNotContainsString(': This is not good', $e->getMessage()); + } + } + + public function testErrorMessageResolverDoesNotCombineWhenMessageIsEmpty(): void + { + // LogicalAnd: verify that empty 'message' string prevents custom format + $mock = new MockHandler([ + new Response( + 400, + ['Content-Type' => 'application/json'], + Json::encodeArray(['error' => 'BadRequest', 'message' => '']), + ), + ]); + [$requestHandler] = self::createCapturingHandler($mock); + + $client = new AppsApiClient( + 'https://data-apps.keboola.com', + 'my-token', + backoffMaxTries: 0, + requestHandler: $requestHandler, + ); + + try { + $client->getApp('app-id'); + self::fail('Expected SandboxesServiceClientException'); + } catch (SandboxesServiceClientException $e) { + // Empty message field → should NOT produce "BadRequest: " format + self::assertStringNotContainsString('BadRequest: ', $e->getMessage()); + } + } + + private static function getHttpClient(AppsApiClient $client): GuzzleClient + { + $apiClient = self::getPrivatePropertyValue($client, 'apiClient'); + self::assertInstanceOf(ApiClient::class, $apiClient); + $httpClient = self::getPrivatePropertyValue($apiClient, 'httpClient'); + self::assertInstanceOf(GuzzleClient::class, $httpClient); + return $httpClient; + } + + public function testDefaultConnectTimeoutIs10(): void + { + $client = new AppsApiClient('https://data-apps.keboola.com', 'my-token'); + self::assertSame(10, self::getHttpClient($client)->getConfig('connect_timeout')); + } + + public function testDefaultRequestTimeoutIs120(): void + { + $client = new AppsApiClient('https://data-apps.keboola.com', 'my-token'); + self::assertSame(120, self::getHttpClient($client)->getConfig('timeout')); + } + + public function testCustomConnectTimeoutIsUsed(): void + { + $client = new AppsApiClient('https://data-apps.keboola.com', 'my-token', connectTimeout: 30); + self::assertSame(30, self::getHttpClient($client)->getConfig('connect_timeout')); + } + + public function testCustomRequestTimeoutIsUsed(): void + { + $client = new AppsApiClient('https://data-apps.keboola.com', 'my-token', requestTimeout: 300); + self::assertSame(300, self::getHttpClient($client)->getConfig('timeout')); + } + + public function testDefaultBackoffMaxTriesIsFive(): void + { + // The RetryDecider logs "retrying (N of MAX)" on failure. Trigger one 500 to + // capture the log message and verify MAX == 5 (kills IncrementInteger 5→6 and + // DecrementInteger 5→4 mutations on the default parameter value). + $handler = new TestHandler(); + $logger = new Logger('test', [$handler]); + + $mock = new MockHandler([ + new Response(500), + new Response(200, ['Content-Type' => 'application/json'], Json::encodeArray([])), + ]); + [$requestHandler] = self::createCapturingHandler($mock); + + $client = new AppsApiClient( + 'https://data-apps.keboola.com', + 'my-token', + logger: $logger, + requestHandler: $requestHandler, + ); + $client->listApps(); + + // RetryDecider logs "retrying (0 of 5)" — verify the configured max is exactly 5 + $records = $handler->getRecords(); + self::assertNotEmpty($records); + $messages = implode(' ', array_map( + static fn(object $r): string => (string) $r->message, + $records, + )); + self::assertStringContainsString('of 5', $messages); + } + + public function testPassedLoggerReceivesLogEntries(): void + { + $handler = new TestHandler(); + $logger = new Logger('test', [$handler]); + + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], Json::encodeArray([])), + ]); + [$requestHandler] = self::createCapturingHandler($mock); + + $client = new AppsApiClient( + 'https://data-apps.keboola.com', + 'my-token', + logger: $logger, + backoffMaxTries: 0, + requestHandler: $requestHandler, + ); + $client->listApps(); + + self::assertTrue($handler->hasRecords('INFO'), 'Passed logger should receive log entries'); } } diff --git a/libs/sandboxes-service-api-client/tests/Authentication/StorageTokenAuthenticatorTest.php b/libs/sandboxes-service-api-client/tests/Authentication/StorageTokenAuthenticatorTest.php deleted file mode 100644 index a30f47d19..000000000 --- a/libs/sandboxes-service-api-client/tests/Authentication/StorageTokenAuthenticatorTest.php +++ /dev/null @@ -1,25 +0,0 @@ -__invoke($request); - self::assertSame( - 'token-string-value', - $modifiedRequest->getHeaderLine(StorageTokenAuthenticator::STORAGE_TOKEN_HEADER), - ); - } -} diff --git a/libs/sandboxes-service-api-client/tests/JsonTest.php b/libs/sandboxes-service-api-client/tests/JsonTest.php deleted file mode 100644 index 977684c79..000000000 --- a/libs/sandboxes-service-api-client/tests/JsonTest.php +++ /dev/null @@ -1,50 +0,0 @@ - 'bar']), - ); - } - - public function testDecodeArray(): void - { - self::assertSame( - ['foo' => 'bar'], - Json::decodeArray('{"foo":"bar"}'), - ); - } - - /** @dataProvider provideDecodeArrayTestData */ - public function testDecodeArrayError(string $data, string $expectedError): void - { - $this->expectException(JsonException::class); - $this->expectExceptionMessage($expectedError); - - Json::decodeArray($data); - } - - public function provideDecodeArrayTestData(): iterable - { - yield 'invalid JSON' => [ - 'data' => '{"foo"', - 'error' => 'Syntax error', - ]; - - yield 'not an array' => [ - 'data' => '"foo"', - 'error' => 'Decoded data is string, array expected', - ]; - } -} diff --git a/libs/sandboxes-service-api-client/tests/RetryDeciderTest.php b/libs/sandboxes-service-api-client/tests/RetryDeciderTest.php deleted file mode 100644 index d1e5c9111..000000000 --- a/libs/sandboxes-service-api-client/tests/RetryDeciderTest.php +++ /dev/null @@ -1,117 +0,0 @@ -logsHandler = new TestHandler(); - $this->logger = new Logger('tests', [$this->logsHandler]); - } - - /** @dataProvider provideTestData */ - public function testDecide( - int $retries, - ?ResponseInterface $response, - mixed $error, - bool $expectedResult, - ?string $log, - ): void { - $decider = new RetryDecider(10, $this->logger); - $result = $decider($retries, new Request('GET', ''), $response, $error); - - self::assertSame($expectedResult, $result); - - if ($log === null) { - self::assertCount(0, $this->logsHandler->getRecords()); - } else { - self::assertTrue($this->logsHandler->hasWarning($log)); - } - } - - public function provideTestData(): iterable - { - yield 'too many retries' => [ - 'retries' => 11, - 'response' => null, - 'error' => null, - 'result' => false, - 'log' => null, - ]; - - yield 'no error, no response' => [ - 'retries' => 0, - 'response' => null, - 'error' => null, - 'result' => false, - 'log' => null, - ]; - - yield '4xx response without error' => [ - 'retries' => 0, - 'response' => new Response(400), - 'error' => null, - 'result' => false, - 'log' => null, - ]; - - yield '4xx response with error' => [ - 'retries' => 0, - 'response' => new Response(400), - 'error' => new ClientException('Request failed', new Request('GET', ''), new Response(400)), - 'result' => false, - 'log' => null, - ]; - - yield '5xx response' => [ - 'retries' => 0, - 'response' => new Response(500, [], 'Error body'), - 'error' => null, - 'result' => true, - 'log' => 'Request failed (Error body), retrying (0 of 10)', - ]; - - yield 'text error with response' => [ - 'retries' => 0, - 'response' => new Response(200), - 'error' => 'Text error', - 'result' => true, - 'log' => 'Request failed (Text error), retrying (0 of 10)', - ]; - - yield 'text error without response' => [ - 'retries' => 0, - 'response' => null, - 'error' => 'Text error', - 'result' => true, - 'log' => 'Request failed (Text error), retrying (0 of 10)', - ]; - - yield 'exception error' => [ - 'retries' => 0, - 'response' => new Response(200), - 'error' => new RuntimeException('Exception error'), - 'result' => true, - 'log' => 'Request failed (Exception error), retrying (0 of 10)', - ]; - } -} diff --git a/libs/sandboxes-service-api-client/tests/Sandboxes/Legacy/PersistentStorageTest.php b/libs/sandboxes-service-api-client/tests/Sandboxes/Legacy/PersistentStorageTest.php index 7fe5411f9..2ab393f29 100644 --- a/libs/sandboxes-service-api-client/tests/Sandboxes/Legacy/PersistentStorageTest.php +++ b/libs/sandboxes-service-api-client/tests/Sandboxes/Legacy/PersistentStorageTest.php @@ -10,14 +10,14 @@ class PersistentStorageTest extends TestCase { - /** @dataProvider fromArrayProvider */ - public function testFromArray(array $data, PersistentStorage $expectedValue): void + /** @dataProvider fromResponseDataProvider */ + public function testFromResponseData(array $data, PersistentStorage $expectedValue): void { - $persistentStorage = PersistentStorage::fromArray($data); + $persistentStorage = PersistentStorage::fromResponseData($data); self::assertEquals($expectedValue, $persistentStorage); } - public function fromArrayProvider(): Generator + public function fromResponseDataProvider(): Generator { yield 'empty' => [ [], diff --git a/libs/sandboxes-service-api-client/tests/Sandboxes/Legacy/SandboxCredentialsTest.php b/libs/sandboxes-service-api-client/tests/Sandboxes/Legacy/SandboxCredentialsTest.php index 335805ab9..4642c8287 100644 --- a/libs/sandboxes-service-api-client/tests/Sandboxes/Legacy/SandboxCredentialsTest.php +++ b/libs/sandboxes-service-api-client/tests/Sandboxes/Legacy/SandboxCredentialsTest.php @@ -10,7 +10,7 @@ class SandboxCredentialsTest extends TestCase { - public function testFromArrayToArray(): void + public function testFromResponseDataToArray(): void { $input = [ 'type' => 'service_account', @@ -24,19 +24,19 @@ public function testFromArrayToArray(): void 'client_x509_cert_url' => 'https://www.googleapis.com/robot/v1/metadata/x509.com', 'private_key' => '-----BEGIN PRIVATE KEY-----key-----END PRIVATE KEY-----', ]; - $credentials = SandboxCredentials::fromArray($input); + $credentials = SandboxCredentials::fromResponseData($input); self::assertSame($input, $credentials->toArray()); } - public function testFromArrayInvalidValues(): void + public function testFromResponseDataInvalidValues(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage( 'Missing credential field(s) "type,project_id,private_key_id,client_email,token_uri,' . 'auth_provider_x509_cert_url,client_x509_cert_url,private_key"', ); - SandboxCredentials::fromArray([ + SandboxCredentials::fromResponseData([ 'something' => 'weird', 'client_id' => '1234', 'auth_uri' => 'https://accounts.google.com/o/oauth2/auth', diff --git a/libs/sandboxes-service-api-client/tests/Sandboxes/Legacy/SandboxSizeParametersTest.php b/libs/sandboxes-service-api-client/tests/Sandboxes/Legacy/SandboxSizeParametersTest.php index 2fbb84cc1..2ca3e1b79 100644 --- a/libs/sandboxes-service-api-client/tests/Sandboxes/Legacy/SandboxSizeParametersTest.php +++ b/libs/sandboxes-service-api-client/tests/Sandboxes/Legacy/SandboxSizeParametersTest.php @@ -29,16 +29,16 @@ public function testSetStorageSize(): void } /** - * @dataProvider provideCreateFromArrayTestData + * @dataProvider provideCreateFromResponseDataTestData */ - public function testCreateFromArray(array $data, SandboxSizeParameters $expectedParameters): void + public function testCreateFromResponseData(array $data, SandboxSizeParameters $expectedParameters): void { - $parameters = SandboxSizeParameters::fromArray($data); + $parameters = SandboxSizeParameters::fromResponseData($data); self::assertEquals($expectedParameters, $parameters); } - public function provideCreateFromArrayTestData(): iterable + public function provideCreateFromResponseDataTestData(): iterable { yield 'no parameters' => [ 'data' => [], diff --git a/libs/sandboxes-service-api-client/tests/Sandboxes/Legacy/SandboxTest.php b/libs/sandboxes-service-api-client/tests/Sandboxes/Legacy/SandboxTest.php index 9d0878a95..ce21704a5 100644 --- a/libs/sandboxes-service-api-client/tests/Sandboxes/Legacy/SandboxTest.php +++ b/libs/sandboxes-service-api-client/tests/Sandboxes/Legacy/SandboxTest.php @@ -14,7 +14,7 @@ class SandboxTest extends TestCase { public function testGetters(): void { - $sandbox = Sandbox::fromArray([ + $sandbox = Sandbox::fromResponseData([ 'id' => 'id', 'componentId' => 'keboola.data-apps', 'projectId' => 'project-id', @@ -143,7 +143,7 @@ public function testPasswordNullable(): void $nullPassword = $sandbox->getPassword(); self::assertNull($nullPassword); - $sandbox = Sandbox::fromArray([ + $sandbox = Sandbox::fromResponseData([ 'id' => 1, 'componentId' => 'component-id', 'projectId' => '123', diff --git a/libs/sandboxes-service-api-client/tests/Sandboxes/SandboxesApiClientTest.php b/libs/sandboxes-service-api-client/tests/Sandboxes/SandboxesApiClientTest.php index 3e4dcbb10..d016f7f8e 100644 --- a/libs/sandboxes-service-api-client/tests/Sandboxes/SandboxesApiClientTest.php +++ b/libs/sandboxes-service-api-client/tests/Sandboxes/SandboxesApiClientTest.php @@ -4,20 +4,27 @@ namespace Keboola\SandboxesServiceApiClient\Tests\Sandboxes; +use GuzzleHttp\Client as GuzzleClient; use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Middleware; -use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; -use Keboola\SandboxesServiceApiClient\ApiClientConfiguration; -use Keboola\SandboxesServiceApiClient\Json; +use InvalidArgumentException; +use Keboola\ApiClientBase\ApiClient; +use Keboola\ApiClientBase\Json; +use Keboola\SandboxesServiceApiClient\Exception\SandboxesServiceClientException; use Keboola\SandboxesServiceApiClient\Sandboxes\Legacy\Project; use Keboola\SandboxesServiceApiClient\Sandboxes\Legacy\Sandbox; use Keboola\SandboxesServiceApiClient\Sandboxes\SandboxesApiClient; +use Keboola\SandboxesServiceApiClient\Tests\ReflectionPropertyAccessTestCase; +use Monolog\Handler\TestHandler; +use Monolog\Logger; use PHPUnit\Framework\TestCase; +use Psr\Http\Message\RequestInterface; +use stdClass; class SandboxesApiClientTest extends TestCase { + use ReflectionPropertyAccessTestCase; + public function testGetSandbox(): void { $responseBody = [ @@ -38,27 +45,22 @@ public function testGetSandbox(): void 'createdTimestamp' => '2024-02-01T08:00:00+01:00', ]; - $requestHandler = self::createRequestHandler($requestsHistory, [ - new Response( - 200, - ['Content-Type' => 'application/json'], - Json::encodeArray($responseBody), - ), + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], Json::encodeArray($responseBody)), ]); + [$requestHandler, $store] = self::createCapturingHandler($mock); $client = new SandboxesApiClient( - new ApiClientConfiguration( - baseUrl: 'https://data-science.keboola.com', - storageToken: 'my-token', - userAgent: 'Keboola Sandboxes Service API PHP Client', - requestHandler: $requestHandler(...), - ), + 'https://data-science.keboola.com', + 'my-token', + backoffMaxTries: 0, + requestHandler: $requestHandler, ); $result = $client->getSandbox('sandbox-id'); - self::assertEquals(Sandbox::fromArray($responseBody), $result); + self::assertEquals(Sandbox::fromResponseData($responseBody), $result); - self::assertCount(1, $requestsHistory); + self::assertCount(1, $store->requests); self::assertRequestEquals( 'GET', 'https://data-science.keboola.com/sandboxes/sandbox-id', @@ -66,7 +68,7 @@ public function testGetSandbox(): void 'X-StorageApi-Token' => 'my-token', ], '', - $requestsHistory[0]['request'], + $store->requests[0], ); } @@ -90,21 +92,16 @@ public function testCreateSandbox(): void 'createdTimestamp' => '2024-02-01T08:00:00+01:00', ]; - $requestHandler = self::createRequestHandler($requestsHistory, [ - new Response( - 201, - ['Content-Type' => 'application/json'], - Json::encodeArray($responseBody), - ), + $mock = new MockHandler([ + new Response(201, ['Content-Type' => 'application/json'], Json::encodeArray($responseBody)), ]); + [$requestHandler, $store] = self::createCapturingHandler($mock); $client = new SandboxesApiClient( - new ApiClientConfiguration( - baseUrl: 'https://data-science.keboola.com', - storageToken: 'my-token', - userAgent: 'Keboola Sandboxes Service API PHP Client', - requestHandler: $requestHandler(...), - ), + 'https://data-science.keboola.com', + 'my-token', + backoffMaxTries: 0, + requestHandler: $requestHandler, ); $result = $client->createSandbox([ 'componentId' => 'component-id', @@ -113,14 +110,15 @@ public function testCreateSandbox(): void 'type' => 'sandbox-type', ]); - self::assertEquals(Sandbox::fromArray($responseBody), $result); + self::assertEquals(Sandbox::fromResponseData($responseBody), $result); - self::assertCount(1, $requestsHistory); + self::assertCount(1, $store->requests); self::assertRequestEquals( 'POST', 'https://data-science.keboola.com/sandboxes', [ 'X-StorageApi-Token' => 'my-token', + 'Content-Type' => 'application/json', ], Json::encodeArray([ 'componentId' => 'component-id', @@ -128,7 +126,7 @@ public function testCreateSandbox(): void 'configurationVersion' => '4', 'type' => 'sandbox-type', ]), - $requestsHistory[0]['request'], + $store->requests[0], ); } @@ -152,61 +150,54 @@ public function testUpdateSandbox(): void 'createdTimestamp' => '2024-02-01T08:00:00+01:00', ]; - $requestHandler = self::createRequestHandler($requestsHistory, [ - new Response( - 200, - ['Content-Type' => 'application/json'], - Json::encodeArray($responseBody), - ), + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], Json::encodeArray($responseBody)), ]); + [$requestHandler, $store] = self::createCapturingHandler($mock); $client = new SandboxesApiClient( - new ApiClientConfiguration( - baseUrl: 'https://data-science.keboola.com', - storageToken: 'my-token', - userAgent: 'Keboola Sandboxes Service API PHP Client', - requestHandler: $requestHandler(...), - ), + 'https://data-science.keboola.com', + 'my-token', + backoffMaxTries: 0, + requestHandler: $requestHandler, ); $result = $client->updateSandbox('sandbox-id', [ 'configurationVersion' => '5', 'active' => false, ]); - self::assertEquals(Sandbox::fromArray($responseBody), $result); + self::assertEquals(Sandbox::fromResponseData($responseBody), $result); - self::assertCount(1, $requestsHistory); + self::assertCount(1, $store->requests); self::assertRequestEquals( 'PATCH', 'https://data-science.keboola.com/sandboxes/sandbox-id', [ 'X-StorageApi-Token' => 'my-token', + 'Content-Type' => 'application/json', ], Json::encodeArray([ 'configurationVersion' => '5', 'active' => false, ]), - $requestsHistory[0]['request'], + $store->requests[0], ); } public function testDeleteSandbox(): void { - $requestHandler = self::createRequestHandler($requestsHistory, [ - new Response(204), - ]); + $mock = new MockHandler([new Response(204)]); + [$requestHandler, $store] = self::createCapturingHandler($mock); $client = new SandboxesApiClient( - new ApiClientConfiguration( - baseUrl: 'https://data-science.keboola.com', - storageToken: 'my-token', - userAgent: 'Keboola Sandboxes Service API PHP Client', - requestHandler: $requestHandler(...), - ), + 'https://data-science.keboola.com', + 'my-token', + backoffMaxTries: 0, + requestHandler: $requestHandler, ); $client->deleteSandbox('sandbox-id'); - self::assertCount(1, $requestsHistory); + self::assertCount(1, $store->requests); self::assertRequestEquals( 'DELETE', 'https://data-science.keboola.com/sandboxes/sandbox-id', @@ -214,7 +205,7 @@ public function testDeleteSandbox(): void 'X-StorageApi-Token' => 'my-token', ], null, - $requestsHistory[0]['request'], + $store->requests[0], ); } @@ -225,27 +216,22 @@ public function testGetCurrentProject(): void 'createdTimestamp' => '2024-02-01T08:00:00+01:00', ]; - $requestHandler = self::createRequestHandler($requestsHistory, [ - new Response( - 200, - ['Content-Type' => 'application/json'], - Json::encodeArray($responseBody), - ), + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], Json::encodeArray($responseBody)), ]); + [$requestHandler, $store] = self::createCapturingHandler($mock); $client = new SandboxesApiClient( - new ApiClientConfiguration( - baseUrl: 'https://data-science.keboola.com', - storageToken: 'my-token', - userAgent: 'Keboola Sandboxes Service API PHP Client', - requestHandler: $requestHandler(...), - ), + 'https://data-science.keboola.com', + 'my-token', + backoffMaxTries: 0, + requestHandler: $requestHandler, ); $result = $client->getCurrentProject(); - self::assertEquals(Project::fromArray($responseBody), $result); + self::assertEquals(Project::fromResponseData($responseBody), $result); - self::assertCount(1, $requestsHistory); + self::assertCount(1, $store->requests); self::assertRequestEquals( 'GET', 'https://data-science.keboola.com/sandboxes/project', @@ -253,23 +239,24 @@ public function testGetCurrentProject(): void 'X-StorageApi-Token' => 'my-token', ], '', - $requestsHistory[0]['request'], + $store->requests[0], ); } /** - * @param list $requestsHistory - * @param list $responses - * @return HandlerStack + * @param MockHandler $mock + * @return array{0: \Closure, 1: stdClass} */ - private static function createRequestHandler(?array &$requestsHistory, array $responses): HandlerStack + private static function createCapturingHandler(MockHandler $mock): array { - $requestsHistory = []; - - $stack = HandlerStack::create(new MockHandler($responses)); - $stack->push(Middleware::history($requestsHistory)); - - return $stack; + $store = new stdClass(); + $store->requests = []; + $handler = static function (RequestInterface $request, array $options) use ($mock, $store) { + $store->requests[] = $request; + return $mock($request, $options); + }; + + return [$handler, $store]; } private static function assertRequestEquals( @@ -277,7 +264,7 @@ private static function assertRequestEquals( string $uri, array $headers, ?string $body, - Request $request, + RequestInterface $request, ): void { self::assertSame($method, $request->getMethod()); self::assertSame($uri, $request->getUri()->__toString()); @@ -288,4 +275,347 @@ private static function assertRequestEquals( self::assertSame($body ?? '', $request->getBody()->getContents()); } + + public function testCustomUserAgentIsPassedInRequest(): void + { + $responseBody = [ + 'id' => '123', + 'createdTimestamp' => '2024-02-01T08:00:00+01:00', + ]; + + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], Json::encodeArray($responseBody)), + ]); + [$requestHandler, $store] = self::createCapturingHandler($mock); + + $client = new SandboxesApiClient( + 'https://data-science.keboola.com', + 'my-token', + backoffMaxTries: 0, + userAgent: 'Custom Agent/1.0', + requestHandler: $requestHandler, + ); + $client->getCurrentProject(); + + self::assertCount(1, $store->requests); + self::assertSame( + 'Custom Agent/1.0', + $store->requests[0]->getHeaderLine('User-Agent'), + ); + } + + public function testDefaultOptionsWithRetry(): void + { + $responseBody = [ + 'id' => 'sandbox-id', + 'projectId' => 'project-id', + 'tokenId' => 'token-id', + 'componentId' => 'component-id-2', + 'configurationId' => '124', + 'configurationVersion' => '5', + 'type' => 'sandbox-type', + 'branchId' => null, + 'active' => false, + 'shared' => false, + 'persistentStorage' => [ + 'pvcName' => null, + 'k8sManifest' => null, + ], + 'createdTimestamp' => '2024-02-01T08:00:00+01:00', + ]; + + $mock = new MockHandler([ + new Response(500), + new Response(200, ['Content-Type' => 'application/json'], Json::encodeArray($responseBody)), + ]); + [$requestHandler, $store] = self::createCapturingHandler($mock); + + $client = new SandboxesApiClient( + 'https://data-science.keboola.com', + 'my-token', + requestHandler: $requestHandler, + ); + // Should succeed after retry + $result = $client->getSandbox('sandbox-id'); + self::assertInstanceOf(Sandbox::class, $result); + self::assertCount(2, $store->requests); + } + + public function testConstructWithNullOptionsDoesNotThrow(): void + { + // With null options, the facade should use defaults without throwing TypeError + $client = new SandboxesApiClient( + 'https://data-science.keboola.com', + 'my-token', + ); + self::assertInstanceOf(SandboxesApiClient::class, $client); + } + + public function testEmptyTokenThrows(): void + { + $this->expectException(InvalidArgumentException::class); + // @phpstan-ignore argument.type + new SandboxesApiClient('https://data-science.keboola.com', ''); + } + + public function testErrorMessageResolverCombinesErrorAndMessage(): void + { + // Test custom error resolver: error+message format "BadRequest: This is not good" + $mock = new MockHandler([ + new Response( + 400, + ['Content-Type' => 'application/json'], + Json::encodeArray(['error' => 'BadRequest', 'message' => 'This is not good']), + ), + ]); + [$requestHandler, $store] = self::createCapturingHandler($mock); + + $client = new SandboxesApiClient( + 'https://data-science.keboola.com', + 'my-token', + backoffMaxTries: 0, + requestHandler: $requestHandler, + ); + + try { + $client->getSandbox('sandbox-id'); + self::fail('Expected SandboxesServiceClientException'); + } catch (SandboxesServiceClientException $e) { + self::assertSame('BadRequest: This is not good', $e->getMessage()); + self::assertCount(1, $store->requests); + } + } + + public function testErrorMessageResolverTrimsResult(): void + { + // UnwrapTrim: verify that trim() removes trailing whitespace from the combined message + $mock = new MockHandler([ + new Response( + 400, + ['Content-Type' => 'application/json'], + Json::encodeArray(['error' => 'BadRequest', 'message' => 'This is not good ']), + ), + ]); + [$requestHandler] = self::createCapturingHandler($mock); + + $client = new SandboxesApiClient( + 'https://data-science.keboola.com', + 'my-token', + backoffMaxTries: 0, + requestHandler: $requestHandler, + ); + + try { + $client->getSandbox('sandbox-id'); + self::fail('Expected SandboxesServiceClientException'); + } catch (SandboxesServiceClientException $e) { + // trim() should remove trailing space + self::assertSame('BadRequest: This is not good', $e->getMessage()); + } + } + + public function testErrorMessageResolverDoesNotCombineWhenErrorMissing(): void + { + // LogicalAnd: verify that missing 'error' key prevents custom format + $mock = new MockHandler([ + new Response( + 400, + ['Content-Type' => 'application/json'], + Json::encodeArray(['message' => 'This is not good']), + ), + ]); + [$requestHandler] = self::createCapturingHandler($mock); + + $client = new SandboxesApiClient( + 'https://data-science.keboola.com', + 'my-token', + backoffMaxTries: 0, + requestHandler: $requestHandler, + ); + + try { + $client->getSandbox('sandbox-id'); + self::fail('Expected SandboxesServiceClientException'); + } catch (SandboxesServiceClientException $e) { + // Should NOT be in "error: message" format since 'error' key is missing + self::assertStringNotContainsString(': This is not good', $e->getMessage()); + } + } + + public function testErrorMessageResolverDoesNotCombineWhenMessageMissing(): void + { + // LogicalAnd: verify that missing 'message' key prevents custom format + $mock = new MockHandler([ + new Response( + 400, + ['Content-Type' => 'application/json'], + Json::encodeArray(['error' => 'BadRequest']), + ), + ]); + [$requestHandler] = self::createCapturingHandler($mock); + + $client = new SandboxesApiClient( + 'https://data-science.keboola.com', + 'my-token', + backoffMaxTries: 0, + requestHandler: $requestHandler, + ); + + try { + $client->getSandbox('sandbox-id'); + self::fail('Expected SandboxesServiceClientException'); + } catch (SandboxesServiceClientException $e) { + // Should NOT produce "BadRequest: " format since 'message' key is missing + self::assertStringNotContainsString('BadRequest: ', $e->getMessage()); + } + } + + public function testErrorMessageResolverDoesNotCombineWhenErrorIsEmpty(): void + { + // LogicalAnd: verify that empty 'error' string prevents custom format + $mock = new MockHandler([ + new Response( + 400, + ['Content-Type' => 'application/json'], + Json::encodeArray(['error' => '', 'message' => 'This is not good']), + ), + ]); + [$requestHandler] = self::createCapturingHandler($mock); + + $client = new SandboxesApiClient( + 'https://data-science.keboola.com', + 'my-token', + backoffMaxTries: 0, + requestHandler: $requestHandler, + ); + + try { + $client->getSandbox('sandbox-id'); + self::fail('Expected SandboxesServiceClientException'); + } catch (SandboxesServiceClientException $e) { + // Empty error field → should NOT produce ": This is not good" format + self::assertStringNotContainsString(': This is not good', $e->getMessage()); + } + } + + public function testErrorMessageResolverDoesNotCombineWhenMessageIsEmpty(): void + { + // LogicalAnd: verify that empty 'message' string prevents custom format + $mock = new MockHandler([ + new Response( + 400, + ['Content-Type' => 'application/json'], + Json::encodeArray(['error' => 'BadRequest', 'message' => '']), + ), + ]); + [$requestHandler] = self::createCapturingHandler($mock); + + $client = new SandboxesApiClient( + 'https://data-science.keboola.com', + 'my-token', + backoffMaxTries: 0, + requestHandler: $requestHandler, + ); + + try { + $client->getSandbox('sandbox-id'); + self::fail('Expected SandboxesServiceClientException'); + } catch (SandboxesServiceClientException $e) { + // Empty message field → should NOT produce "BadRequest: " format + self::assertStringNotContainsString('BadRequest: ', $e->getMessage()); + } + } + + private static function getHttpClient(SandboxesApiClient $client): GuzzleClient + { + $apiClient = self::getPrivatePropertyValue($client, 'apiClient'); + self::assertInstanceOf(ApiClient::class, $apiClient); + $httpClient = self::getPrivatePropertyValue($apiClient, 'httpClient'); + self::assertInstanceOf(GuzzleClient::class, $httpClient); + return $httpClient; + } + + public function testDefaultConnectTimeoutIs10(): void + { + $client = new SandboxesApiClient('https://data-science.keboola.com', 'my-token'); + self::assertSame(10, self::getHttpClient($client)->getConfig('connect_timeout')); + } + + public function testDefaultRequestTimeoutIs120(): void + { + $client = new SandboxesApiClient('https://data-science.keboola.com', 'my-token'); + self::assertSame(120, self::getHttpClient($client)->getConfig('timeout')); + } + + public function testCustomConnectTimeoutIsUsed(): void + { + $client = new SandboxesApiClient('https://data-science.keboola.com', 'my-token', connectTimeout: 30); + self::assertSame(30, self::getHttpClient($client)->getConfig('connect_timeout')); + } + + public function testCustomRequestTimeoutIsUsed(): void + { + $client = new SandboxesApiClient('https://data-science.keboola.com', 'my-token', requestTimeout: 300); + self::assertSame(300, self::getHttpClient($client)->getConfig('timeout')); + } + + public function testDefaultBackoffMaxTriesIsFive(): void + { + // The RetryDecider logs "retrying (N of MAX)" on failure. Trigger one 500 to + // capture the log message and verify MAX == 5 (kills IncrementInteger 5→6 and + // DecrementInteger 5→4 mutations on the default parameter value). + $handler = new TestHandler(); + $logger = new Logger('test', [$handler]); + + $mock = new MockHandler([ + new Response(500), + new Response(200, ['Content-Type' => 'application/json'], Json::encodeArray([ + 'id' => '123', + 'createdTimestamp' => '2024-02-01T08:00:00+01:00', + ])), + ]); + [$requestHandler] = self::createCapturingHandler($mock); + + $client = new SandboxesApiClient( + 'https://data-science.keboola.com', + 'my-token', + logger: $logger, + requestHandler: $requestHandler, + ); + $client->getCurrentProject(); + + // RetryDecider logs "retrying (0 of 5)" — verify the configured max is exactly 5 + $records = $handler->getRecords(); + self::assertNotEmpty($records); + $messages = implode(' ', array_map( + static fn(object $r): string => (string) $r->message, + $records, + )); + self::assertStringContainsString('of 5', $messages); + } + + public function testPassedLoggerReceivesLogEntries(): void + { + $handler = new TestHandler(); + $logger = new Logger('test', [$handler]); + + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], Json::encodeArray([ + 'id' => '123', + 'createdTimestamp' => '2024-02-01T08:00:00+01:00', + ])), + ]); + [$requestHandler] = self::createCapturingHandler($mock); + + $client = new SandboxesApiClient( + 'https://data-science.keboola.com', + 'my-token', + logger: $logger, + backoffMaxTries: 0, + requestHandler: $requestHandler, + ); + $client->getCurrentProject(); + + self::assertTrue($handler->hasRecords('INFO'), 'Passed logger should receive log entries'); + } } diff --git a/libs/sandboxes-service-api-client/tests/SandboxesErrorMessageResolverTest.php b/libs/sandboxes-service-api-client/tests/SandboxesErrorMessageResolverTest.php new file mode 100644 index 000000000..3c86955c4 --- /dev/null +++ b/libs/sandboxes-service-api-client/tests/SandboxesErrorMessageResolverTest.php @@ -0,0 +1,107 @@ +resolver = new SandboxesErrorMessageResolver(); + } + + public function testCombinedErrorAndMessageFormat(): void + { + $body = json_encode(['error' => 'NotFound', 'message' => 'Sandbox does not exist'], JSON_THROW_ON_ERROR); + $result = ($this->resolver)($body, 404); + + self::assertSame('NotFound: Sandbox does not exist', $result); + } + + public function testTrimsWhitespace(): void + { + $body = json_encode(['error' => ' Conflict ', 'message' => ' already exists '], JSON_THROW_ON_ERROR); + $result = ($this->resolver)($body, 409); + + self::assertSame('Conflict : already exists', $result); + } + + /** + * @dataProvider nullCasesProvider + */ + public function testReturnsNullForNullCases(string $body, int $statusCode): void + { + $result = ($this->resolver)($body, $statusCode); + + self::assertNull($result); + } + + public function nullCasesProvider(): Generator + { + yield 'invalid JSON' => [ + 'body' => 'not json at all', + 'statusCode' => 500, + ]; + + yield 'empty body' => [ + 'body' => '', + 'statusCode' => 400, + ]; + + yield 'JSON array instead of object' => [ + 'body' => '["error","message"]', + 'statusCode' => 400, + ]; + + yield 'missing error field' => [ + 'body' => json_encode(['message' => 'Something went wrong'], JSON_THROW_ON_ERROR), + 'statusCode' => 400, + ]; + + yield 'missing message field' => [ + 'body' => json_encode(['error' => 'SomeError'], JSON_THROW_ON_ERROR), + 'statusCode' => 400, + ]; + + yield 'empty error string' => [ + 'body' => json_encode(['error' => '', 'message' => 'Something went wrong'], JSON_THROW_ON_ERROR), + 'statusCode' => 400, + ]; + + yield 'empty message string' => [ + 'body' => json_encode(['error' => 'SomeError', 'message' => ''], JSON_THROW_ON_ERROR), + 'statusCode' => 400, + ]; + + yield 'error is not a string' => [ + 'body' => json_encode(['error' => 42, 'message' => 'Something went wrong'], JSON_THROW_ON_ERROR), + 'statusCode' => 400, + ]; + + yield 'message is not a string' => [ + 'body' => json_encode(['error' => 'SomeError', 'message' => ['nested' => 'array']], JSON_THROW_ON_ERROR), + 'statusCode' => 400, + ]; + + yield 'both fields null' => [ + 'body' => json_encode(['error' => null, 'message' => null], JSON_THROW_ON_ERROR), + 'statusCode' => 500, + ]; + } + + public function testStatusCodeIsNotUsedInLogic(): void + { + $body = json_encode(['error' => 'Forbidden', 'message' => 'Access denied'], JSON_THROW_ON_ERROR); + + self::assertSame('Forbidden: Access denied', ($this->resolver)($body, 403)); + self::assertSame('Forbidden: Access denied', ($this->resolver)($body, 200)); + self::assertSame('Forbidden: Access denied', ($this->resolver)($body, 500)); + } +}