diff --git a/libs/configuration-variables-resolver/composer.json b/libs/configuration-variables-resolver/composer.json index ce9b25625..38138b219 100644 --- a/libs/configuration-variables-resolver/composer.json +++ b/libs/configuration-variables-resolver/composer.json @@ -38,6 +38,7 @@ "php": "^8.2", "ext-json": "*", "keboola/common-exceptions": "^1.1", + "keboola/php-api-client-base": "*@dev", "keboola/service-client": "*@dev", "keboola/storage-api-client": "^17.0|^18.0", "keboola/storage-api-php-client-branch-wrapper": "^6.0", diff --git a/libs/configuration-variables-resolver/src/UnifiedConfigurationResolverFactory.php b/libs/configuration-variables-resolver/src/UnifiedConfigurationResolverFactory.php index 19490086f..77c445285 100644 --- a/libs/configuration-variables-resolver/src/UnifiedConfigurationResolverFactory.php +++ b/libs/configuration-variables-resolver/src/UnifiedConfigurationResolverFactory.php @@ -6,7 +6,6 @@ use Keboola\ServiceClient\ServiceClient; use Keboola\StorageApiBranch\ClientWrapper; -use Keboola\VaultApiClient\ApiClientConfiguration as VaultVariablesApiClientConfiguration; use Keboola\VaultApiClient\Variables\VariablesApiClient; use Psr\Log\LoggerInterface; @@ -30,10 +29,13 @@ public function createResolver( $token = $clientWrapper->getToken()->getTokenValue(); assert($token !== ''); + $config = $this->vaultVariablesApiClientConfiguration; $vaultVariablesApiClient = new VariablesApiClient( baseUrl: $this->serviceClient->getVaultUrl(), token: $token, - configuration: $this->vaultVariablesApiClientConfiguration, + logger: $this->logger, + backoffMaxTries: $config->backoffMaxTries, + userAgent: 'Keboola Vault PHP Client' . ($config->userAgent !== null ? ' - ' . $config->userAgent : ''), ); $sharedCodeResolver = new SharedCodeResolver($clientWrapper, $this->logger); diff --git a/libs/configuration-variables-resolver/src/VaultVariablesApiClientConfiguration.php b/libs/configuration-variables-resolver/src/VaultVariablesApiClientConfiguration.php new file mode 100644 index 000000000..27bce08ba --- /dev/null +++ b/libs/configuration-variables-resolver/src/VaultVariablesApiClientConfiguration.php @@ -0,0 +1,26 @@ + $backoffMaxTries + */ + public function __construct( + public readonly ?string $userAgent = null, + public readonly int $backoffMaxTries = self::DEFAULT_BACKOFF_RETRIES, + ) { + } +} diff --git a/libs/configuration-variables-resolver/tests/UnifiedConfigurationResolverFactoryTest.php b/libs/configuration-variables-resolver/tests/UnifiedConfigurationResolverFactoryTest.php index de4c2dd5b..a1e68f255 100644 --- a/libs/configuration-variables-resolver/tests/UnifiedConfigurationResolverFactoryTest.php +++ b/libs/configuration-variables-resolver/tests/UnifiedConfigurationResolverFactoryTest.php @@ -5,11 +5,11 @@ namespace Keboola\ConfigurationVariablesResolver\Tests; use Keboola\ConfigurationVariablesResolver\UnifiedConfigurationResolverFactory; +use Keboola\ConfigurationVariablesResolver\VaultVariablesApiClientConfiguration; use Keboola\ServiceClient\ServiceClient; use Keboola\StorageApi\BranchAwareClient; use Keboola\StorageApiBranch\ClientWrapper; use Keboola\StorageApiBranch\StorageApiToken; -use Keboola\VaultApiClient\ApiClientConfiguration as VaultVariablesApiClientConfiguration; use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; @@ -235,11 +235,9 @@ private function getFactory(): UnifiedConfigurationResolverFactory $serviceClient = $this->createMock(ServiceClient::class); $serviceClient->method('getVaultUrl')->willReturn($this->variablesApiMock->getServerUrl()); - $vaultVariablesApiClientConfiguration = new VaultVariablesApiClientConfiguration(); - return new UnifiedConfigurationResolverFactory( $serviceClient, - $vaultVariablesApiClientConfiguration, + new VaultVariablesApiClientConfiguration(), new NullLogger, ); } diff --git a/libs/vault-api-client/README.md b/libs/vault-api-client/README.md index f1a4b4e3d..19e88ff72 100644 --- a/libs/vault-api-client/README.md +++ b/libs/vault-api-client/README.md @@ -1,10 +1,58 @@ # Vault API Client +PHP client for the Keboola Vault API, built on top of `keboola/php-api-client-base`. + ## Installation + ```bash composer require keboola/vault-api-client ``` +## Usage + +```php +use Keboola\VaultApiClient\Variables\Model\ListOptions; +use Keboola\VaultApiClient\Variables\Model\Variable; +use Keboola\VaultApiClient\Variables\VariablesApiClient; + +$client = new VariablesApiClient( + baseUrl: 'https://vault.keboola.com', + token: 'your-storage-api-token', +); + +// Create a variable +$variable = $client->createVariable( + key: 'MY_SECRET', + value: 'secret-value', + flags: [Variable::FLAG_ENCRYPTED], + attributes: ['branchId' => '123'], +); + +// List variables +$variables = $client->listVariables(new ListOptions(offset: 0, limit: 50)); + +// List scoped variables for a branch +$branchVariables = $client->listScopedVariablesForBranch(branchId: '123'); + +// Delete a variable +$client->deleteVariable(hash: $variable->hash); +``` + +### Custom configuration + +Pass retry, timeout, and logging options directly as constructor parameters: + +```php +use Monolog\Logger; + +$client = new VariablesApiClient( + baseUrl: 'https://vault.keboola.com', + token: 'your-storage-api-token', + backoffMaxTries: 3, + logger: new Logger('vault'), +); +``` + ## License MIT licensed, see [LICENSE](./LICENSE) file. diff --git a/libs/vault-api-client/composer.json b/libs/vault-api-client/composer.json index 2f728b8d1..c6f375fd8 100644 --- a/libs/vault-api-client/composer.json +++ b/libs/vault-api-client/composer.json @@ -9,6 +9,12 @@ "email": "devel@keboola.com" } ], + "repositories": { + "libs": { + "type": "path", + "url": "../../libs/*" + } + }, "autoload": { "psr-4": { "Keboola\\VaultApiClient\\": "src/" @@ -22,6 +28,7 @@ "require": { "php": "^8.2", "guzzlehttp/guzzle": "^7.5", + "keboola/php-api-client-base": "*@dev", "monolog/monolog": "^2.0|^3.0", "webmozart/assert": "^1.11" }, diff --git a/libs/vault-api-client/src/ApiClient.php b/libs/vault-api-client/src/ApiClient.php deleted file mode 100644 index 7309a0398..000000000 --- a/libs/vault-api-client/src/ApiClient.php +++ /dev/null @@ -1,142 +0,0 @@ -requestHandlerStack = HandlerStack::create($configuration->requestHandler); - $this->requestHandlerStack->push(Middleware::mapRequest(new StorageApiTokenAuthenticator($token))); - - 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}', - ))); - - $userAgent = self::USER_AGENT; - if ($configuration->userAgent) { - $userAgent .= ' - ' . $configuration->userAgent; - } - - $this->httpClient = new GuzzleClient([ - 'base_uri' => $baseUrl, - 'handler' => $this->requestHandlerStack, - 'headers' => [ - 'User-Agent' => $userAgent, - ], - 'connect_timeout' => 10, - 'timeout' => 120, - ]); - } - - /** - * @template TResponseClass of ResponseModelInterface - * @param class-string $responseClass - * @return ($isList is true ? list : TResponseClass) - */ - public function sendRequestAndMapResponse( - RequestInterface $request, - string $responseClass, - array $options = [], - bool $isList = false, - ) { - $response = $this->doSendRequest($request, $options); - - try { - $responseData = Json::decodeArray($response->getBody()->getContents()); - } catch (JsonException $e) { - throw new ClientException('Response is not a valid JSON: ' . $e->getMessage(), $e->getCode(), $e); - } - - try { - if ($isList) { - return array_map($responseClass::fromResponseData(...), $responseData); - } else { - return $responseClass::fromResponseData($responseData); - } - } catch (Throwable $e) { - throw new ClientException('Failed to map response data: ' . $e->getMessage(), 0, $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['code'])) { - return null; - } - - return new ClientException( - trim($data['code'] . ': ' . $data['error']), - $response->getStatusCode(), - $e, - ); - } -} diff --git a/libs/vault-api-client/src/ApiClientConfiguration.php b/libs/vault-api-client/src/ApiClientConfiguration.php deleted file mode 100644 index ec3698814..000000000 --- a/libs/vault-api-client/src/ApiClientConfiguration.php +++ /dev/null @@ -1,27 +0,0 @@ - $backoffMaxTries - */ - public function __construct( - public readonly ?string $userAgent = null, - public readonly int $backoffMaxTries = self::DEFAULT_BACKOFF_RETRIES, - public readonly null|Closure $requestHandler = null, - public readonly LoggerInterface $logger = new NullLogger(), - ) { - Assert::greaterThanEq($this->backoffMaxTries, 0, 'Backoff max tries must be greater than or equal to 0'); - } -} diff --git a/libs/vault-api-client/src/Authentication/RequestAuthenticatorInterface.php b/libs/vault-api-client/src/Authentication/RequestAuthenticatorInterface.php deleted file mode 100644 index 317e52b4a..000000000 --- a/libs/vault-api-client/src/Authentication/RequestAuthenticatorInterface.php +++ /dev/null @@ -1,12 +0,0 @@ -withHeader('X-StorageApi-Token', $this->value); - } -} diff --git a/libs/vault-api-client/src/Exception/ClientException.php b/libs/vault-api-client/src/Exception/ClientException.php deleted file mode 100644 index 1077f5a0f..000000000 --- a/libs/vault-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 && $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/vault-api-client/src/Variables/Model/Variable.php b/libs/vault-api-client/src/Variables/Model/Variable.php index e131b8fd8..10f3bc772 100644 --- a/libs/vault-api-client/src/Variables/Model/Variable.php +++ b/libs/vault-api-client/src/Variables/Model/Variable.php @@ -4,7 +4,8 @@ namespace Keboola\VaultApiClient\Variables\Model; -use Keboola\VaultApiClient\ResponseModelInterface; +use Keboola\ApiClientBase\ResponseModelInterface; +use Webmozart\Assert\Assert; final readonly class Variable implements ResponseModelInterface { @@ -27,14 +28,30 @@ public function __construct( ) { } + /** + * @param array $data + */ public static function fromResponseData(array $data): static { + Assert::stringNotEmpty($data['hash']); + Assert::stringNotEmpty($data['key']); + Assert::string($data['value']); + + $flags = (array) ($data['flags'] ?? []); + Assert::allString($flags); + + $attributes = (array) ($data['attributes'] ?? []); + Assert::isMap($attributes); + Assert::allString($attributes); + + /** @var array $flags */ + /** @var array $attributes */ return new self( $data['hash'], $data['key'], $data['value'], - $data['flags'] ?? [], - $data['attributes'] ?? [], + $flags, + $attributes, ); } } diff --git a/libs/vault-api-client/src/Variables/VariablesApiClient.php b/libs/vault-api-client/src/Variables/VariablesApiClient.php index 1c1743317..f0ee84ab8 100644 --- a/libs/vault-api-client/src/Variables/VariablesApiClient.php +++ b/libs/vault-api-client/src/Variables/VariablesApiClient.php @@ -4,27 +4,58 @@ namespace Keboola\VaultApiClient\Variables; +use Closure; +use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Request; -use Keboola\VaultApiClient\ApiClient; -use Keboola\VaultApiClient\ApiClientConfiguration; -use Keboola\VaultApiClient\Json; +use Keboola\ApiClientBase\ApiClient; +use Keboola\ApiClientBase\ApiClientOptions; +use Keboola\ApiClientBase\Auth\StorageApiTokenAuthenticator; +use Keboola\ApiClientBase\Json; +use Keboola\VaultApiClient\Exception\VaultClientException; use Keboola\VaultApiClient\Variables\Model\ListOptions; use Keboola\VaultApiClient\Variables\Model\Variable; +use Keboola\VaultApiClient\VaultErrorMessageResolver; +use Psr\Log\LoggerInterface; +use Webmozart\Assert\Assert; class VariablesApiClient { + private const FALLBACK_USER_AGENT = 'Keboola Vault PHP Client'; + private const DEFAULT_BACKOFF_MAX_TRIES = 10; + private ApiClient $apiClient; /** * @param non-empty-string $baseUrl * @param non-empty-string $token + * @param int<0, max> $backoffMaxTries */ public function __construct( string $baseUrl, string $token, - ?ApiClientConfiguration $configuration = null, + ?LoggerInterface $logger = null, + int $backoffMaxTries = self::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, $token, $configuration); + Assert::stringNotEmpty($baseUrl, 'Base URL must be a non-empty string'); + + $this->apiClient = new ApiClient( + $baseUrl, + new StorageApiTokenAuthenticator($token), + new ApiClientOptions( + userAgent: $userAgent, + backoffMaxTries: $backoffMaxTries, + connectTimeout: $connectTimeout, + requestTimeout: $requestTimeout, + requestHandler: $requestHandler, + logger: $logger, + ), + errorMessageResolver: new VaultErrorMessageResolver(), + exceptionClass: VaultClientException::class, + ); } /** diff --git a/libs/vault-api-client/src/VaultErrorMessageResolver.php b/libs/vault-api-client/src/VaultErrorMessageResolver.php new file mode 100644 index 000000000..aebeb7838 --- /dev/null +++ b/libs/vault-api-client/src/VaultErrorMessageResolver.php @@ -0,0 +1,27 @@ +expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Backoff max tries must be greater than or equal to 0'); - new ApiClientConfiguration(backoffMaxTries: -1); // @phpstan-ignore-line - } -} diff --git a/libs/vault-api-client/tests/ApiClientTest.php b/libs/vault-api-client/tests/ApiClientTest.php deleted file mode 100644 index 2aa88cb52..000000000 --- a/libs/vault-api-client/tests/ApiClientTest.php +++ /dev/null @@ -1,374 +0,0 @@ -logsHandler = new TestHandler(); - $this->logger = new Logger('tests', [$this->logsHandler]); - } - - public function testCreateClientWithDefaults(): void - { - $client = new ApiClient(self::BASE_URL, self::API_TOKEN); - - $httpClient = self::getPrivatePropertyValue($client, 'httpClient'); - self::assertInstanceOf(GuzzleClient::class, $httpClient); - $httpClientConfig = self::getPrivatePropertyValue($httpClient, 'config'); - self::assertIsArray($httpClientConfig); - - self::assertEquals(new Uri(self::BASE_URL), $httpClientConfig['base_uri']); - self::assertSame(['User-Agent' => 'Keboola Vault PHP Client'], $httpClientConfig['headers']); - self::assertSame(120, $httpClientConfig['timeout']); - self::assertSame(10, $httpClientConfig['connect_timeout']); - } - - /** - * @param non-empty-string $baseUrl - * @param non-empty-string $token - * @dataProvider provideInvalidOptions - */ - public function testInvalidOptions( - string $baseUrl, - string $token, - ?int $backoffMaxTries, - string $expectedError, - ): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage($expectedError); - - new ApiClient( - $baseUrl, - $token, - new ApiClientConfiguration( - backoffMaxTries: $backoffMaxTries, // @phpstan-ignore-line - ), - ); - } - - public function provideInvalidOptions(): iterable - { - yield 'empty baseUrl' => [ - 'baseUrl' => '', - 'token' => self::API_TOKEN, - 'backoffMaxTries' => 0, - 'error' => 'Base URL must be a non-empty string', - ]; - - yield 'empty token' => [ - 'baseUrl' => self::BASE_URL, - 'token' => '', - 'backoffMaxTries' => 0, - 'error' => 'Token must be a non-empty string', - ]; - - yield 'negative backoffMaxTries' => [ - 'baseUrl' => self::BASE_URL, - 'token' => self::API_TOKEN, - 'backoffMaxTries' => -1, - 'error' => 'Backoff max tries must be greater than or equal to 0', - ]; - } - - public function testLogger(): void - { - - $client = new ApiClient( - self::BASE_URL, - self::API_TOKEN, - configuration: new ApiClientConfiguration( - 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 Vault PHP Client - \[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00\] "GET /1.1" 201 $#', - )); - } - - public function testSendRequest(): void - { - $requestHandler = self::createRequestHandler($requestsHistory, [ - new Response(200), - ]); - - $apiClient = new ApiClient( - self::BASE_URL, - self::API_TOKEN, - new ApiClientConfiguration( - requestHandler: $requestHandler(...), - ), - ); - $apiClient->sendRequest(new Request('DELETE', 'foo/bar')); - - self::assertCount(1, $requestsHistory); - $request = $requestsHistory[0]['request']; - self::assertRequestEquals( - 'DELETE', - self::BASE_URL . '/foo/bar', - ['X-StorageApi-Token' => self::API_TOKEN], - null, - $request, - ); - } - - public function testSendRequestAndMapResponse(): void - { - $requestHandler = self::createRequestHandler($requestsHistory, [ - new Response( - 200, - ['Content-Type' => 'application/json'], - Json::encodeArray([ - 'foo' => 'bar', - ]), - ), - ]); - - $apiClient = new ApiClient( - self::BASE_URL, - self::API_TOKEN, - new ApiClientConfiguration( - requestHandler: $requestHandler(...), - ), - ); - $result = $apiClient->sendRequestAndMapResponse( - new Request('GET', 'foo/bar'), - DummyTestResponse::class, - ); - - self::assertCount(1, $requestsHistory); - $request = $requestsHistory[0]['request']; - self::assertRequestEquals( - 'GET', - self::BASE_URL . '/foo/bar', - ['X-StorageApi-Token' => self::API_TOKEN], - null, - $request, - ); - - self::assertEquals(DummyTestResponse::fromResponseData(['foo' => 'bar']), $result); - } - - public function testSendRequestFailingWithClientError(): void - { - $requestHandler = self::createRequestHandler($requestsHistory, [ - new Response( - 400, - ['Content-Type' => 'application/json'], - Json::encodeArray([ - 'error' => 'Missing data', - 'code' => 400, - ]), - ), - ]); - - $apiClient = new ApiClient( - self::BASE_URL, - self::API_TOKEN, - new ApiClientConfiguration( - requestHandler: $requestHandler(...), - ), - ); - - $this->expectException(ClientException::class); - $this->expectExceptionMessage('400: Missing data'); - - $apiClient->sendRequest(new Request('DELETE', 'foo/bar')); - } - - public function testSendRequestFailingWithNonStandardClientError(): void - { - $requestHandler = self::createRequestHandler($requestsHistory, [ - new Response( - 400, - ['Content-Type' => 'application/json'], - Json::encodeArray([ - 'error' => 'Missing data', - ]), - ), - ]); - - $apiClient = new ApiClient( - self::BASE_URL, - self::API_TOKEN, - new ApiClientConfiguration( - requestHandler: $requestHandler(...), - ), - ); - - $this->expectException(ClientException::class); - $this->expectExceptionMessage( - 'Client error: `DELETE https://vault.keboola.com/foo/bar` resulted in a `400 Bad Request` response', - ); - - $apiClient->sendRequest(new Request('DELETE', 'foo/bar')); - } - - public function testSendRequestFailingWithTransientServerError(): void - { - $requestHandler = self::createRequestHandler($requestsHistory, [ - new Response( - 500, - [], - 'Internal Server Error', - ), - new Response( - 200, - ), - ]); - - $apiClient = new ApiClient( - self::BASE_URL, - self::API_TOKEN, - new ApiClientConfiguration( - requestHandler: $requestHandler(...), - ), - ); - - $apiClient->sendRequest(new Request('DELETE', 'foo/bar')); - - self::assertCount(2, $requestsHistory); - self::assertRequestEquals( - 'DELETE', - self::BASE_URL . '/foo/bar', - ['X-StorageApi-Token' => self::API_TOKEN], - null, - $requestsHistory[0]['request'], - ); - self::assertRequestEquals( - 'DELETE', - self::BASE_URL . '/foo/bar', - ['X-StorageApi-Token' => self::API_TOKEN], - null, - $requestsHistory[1]['request'], - ); - } - - public function testSendRequestFailingWithPermanentServerError(): void - { - $requestHandler = self::createRequestHandler($requestsHistory, [ - new Response( - 500, - [], - 'Internal Server Error', - ), - new Response( - 500, - [], - 'Internal Server Error', - ), - new Response( - 500, - [], - 'Internal Server Error', - ), - ]); - - $apiClient = new ApiClient( - self::BASE_URL, - self::API_TOKEN, - new ApiClientConfiguration( - backoffMaxTries: 2, - requestHandler: $requestHandler(...), - ), - ); - - $this->expectException(ClientException::class); - $this->expectExceptionMessage( - <<sendRequest(new Request('DELETE', 'foo/bar')); - } - - public function testSendRequestWitServerErrorAndDisabledRetry(): void - { - $requestHandler = self::createRequestHandler($requestsHistory, [ - new Response( - 500, - [], - 'Internal Server Error', - ), - ]); - - $apiClient = new ApiClient( - self::BASE_URL, - self::API_TOKEN, - new ApiClientConfiguration( - backoffMaxTries: 0, - requestHandler: $requestHandler(...), - ), - ); - - $this->expectException(ClientException::class); - $this->expectExceptionMessage( - <<sendRequest(new Request('DELETE', 'foo/bar')); - } - - public function testSendRequestAndMapResponseFailingOnResponseMapping(): void - { - $requestHandler = self::createRequestHandler($requestsHistory, [ - new Response( - 200, - ['Content-Type' => 'application/json'], - '{"foo":', - ), - ]); - - $apiClient = new ApiClient( - self::BASE_URL, - self::API_TOKEN, - new ApiClientConfiguration( - requestHandler: $requestHandler(...), - ), - ); - - $this->expectException(ClientException::class); - $this->expectExceptionMessage('Response is not a valid JSON: Syntax error'); - - $apiClient->sendRequestAndMapResponse( - new Request('GET', 'foo/bar'), - DummyTestResponse::class, - ); - } -} diff --git a/libs/vault-api-client/tests/Authentication/StorageApiTokenAuthenticatorTest.php b/libs/vault-api-client/tests/Authentication/StorageApiTokenAuthenticatorTest.php deleted file mode 100644 index ce5fae100..000000000 --- a/libs/vault-api-client/tests/Authentication/StorageApiTokenAuthenticatorTest.php +++ /dev/null @@ -1,33 +0,0 @@ - 'application/json'], - ); - $modifiedRequest = $authenticator->__invoke($request); - - self::assertSame( - [ - 'Host' => ['example.com'], - 'Content-Type' => ['application/json'], - 'X-StorageApi-Token' => ['my-token'], - ], - $modifiedRequest->getHeaders(), - ); - } -} diff --git a/libs/vault-api-client/tests/DummyTestResponse.php b/libs/vault-api-client/tests/DummyTestResponse.php deleted file mode 100644 index a2edbfec9..000000000 --- a/libs/vault-api-client/tests/DummyTestResponse.php +++ /dev/null @@ -1,22 +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/vault-api-client/tests/Mockserver.php b/libs/vault-api-client/tests/Mockserver.php deleted file mode 100644 index 45c7b7cca..000000000 --- a/libs/vault-api-client/tests/Mockserver.php +++ /dev/null @@ -1,116 +0,0 @@ -serverUrl = $serverUrl; - - $this->client = HttpClient::createForBaseUri($this->serverUrl . '/mockserver'); - } - - /** - * @return non-empty-string - */ - public function getServerUrl(): string - { - return $this->serverUrl; - } - - public function reset(): void - { - $this->client->request('PUT', 'reset'); - } - - /** - * @param array{ - * httpRequest: array{ - * method?: string, - * path?: string, - * }, - * httpResponse: array{ - * statusCode?: int, - * headers?: array, - * body?: string, - * }, - * } $expectation - */ - public function expect(array $expectation): void - { - $this->client->request('PUT', 'expectation', [ - 'body' => json_encode($expectation, JSON_THROW_ON_ERROR), - ]); - } - - /** - * @param array{ - * method?: string, - * path?: string, - * } $httpRequest - * - * @return list, - * body?: array{rawBytes: string}, - * keepAlive: bool, - * }> - */ - public function fetchRecordedRequests(array $httpRequest): array - { - $response = $this->client->request('PUT', 'retrieve?type=REQUESTS', [ - 'body' => json_encode($httpRequest, JSON_THROW_ON_ERROR), - ]); - - /** @var list, - * body?: array{rawBytes: string}, - * keepAlive: bool, - * }> $requests - */ - $requests = (array) json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); - - return array_map( - function (array $request) { - $headers = []; - foreach ($request['headers'] ?? [] as $headerName => $headerValues) { - $headers[strtolower($headerName)] = implode(', ', $headerValues); - } - - $request['headers'] = $headers; - return $request; - }, - $requests, - ); - } - - /** - * @param array{ - * method?: string, - * path?: string, - * } $httpRequest - */ - public function hasRecordedRequest(array $httpRequest): bool - { - $records = $this->fetchRecordedRequests($httpRequest); - return count($records) > 0; - } -} diff --git a/libs/vault-api-client/tests/ReflectionPropertyAccessTestCase.php b/libs/vault-api-client/tests/ReflectionPropertyAccessTestCase.php deleted file mode 100644 index 3bf8cc5c0..000000000 --- a/libs/vault-api-client/tests/ReflectionPropertyAccessTestCase.php +++ /dev/null @@ -1,69 +0,0 @@ -setAccessible(true); - $reflection->setValue($object, $value); - $reflection->setAccessible(false); - } - - protected static function getPrivatePropertyValue(object $object, string $property): mixed - { - $reflection = self::findPropertyOnClass(new ReflectionClass($object), $property); - $reflection->setAccessible(true); - $value = $reflection->getValue($object); - $reflection->setAccessible(false); - return $value; - } - - /** - * @param class-string $class - */ - protected static function setPrivateStaticPropertyValue(string $class, string $property, mixed $value): void - { - $reflection = self::findPropertyOnClass(new ReflectionClass($class), $property); - $reflection->setAccessible(true); - $reflection->setValue($value); - $reflection->setAccessible(false); - } - - /** - * @param class-string $class - */ - protected static function getPrivateStaticPropertyValue(string $class, string $property): mixed - { - $reflection = self::findPropertyOnClass(new ReflectionClass($class), $property); - $reflection->setAccessible(true); - $value = $reflection->getValue(); - $reflection->setAccessible(false); - return $value; - } - - /** - * @param ReflectionClass $class - */ - private static function findPropertyOnClass(ReflectionClass $class, string $propertyName): ReflectionProperty - { - try { - return $class->getProperty($propertyName); - } catch (ReflectionException $e) { - $parent = $class->getParentClass(); - if ($parent) { - return self::findPropertyOnClass($parent, $propertyName); - } - - throw $e; - } - } -} diff --git a/libs/vault-api-client/tests/RetryDeciderTest.php b/libs/vault-api-client/tests/RetryDeciderTest.php deleted file mode 100644 index 5a88f8fe3..000000000 --- a/libs/vault-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/vault-api-client/tests/Variables/Model/ListOptionsTest.php b/libs/vault-api-client/tests/Variables/Model/ListOptionsTest.php index 6ed63603f..8320bd8a5 100644 --- a/libs/vault-api-client/tests/Variables/Model/ListOptionsTest.php +++ b/libs/vault-api-client/tests/Variables/Model/ListOptionsTest.php @@ -28,4 +28,10 @@ public function testAllOptionsAsQueryString(): void $options->asQueryString(), ); } + + public function testPartialOptionsExcludeNulls(): void + { + $options = new ListOptions(offset: 5); + self::assertSame('offset=5', $options->asQueryString()); + } } diff --git a/libs/vault-api-client/tests/Variables/Model/VariableTest.php b/libs/vault-api-client/tests/Variables/Model/VariableTest.php index 577af8ab1..c03dbee36 100644 --- a/libs/vault-api-client/tests/Variables/Model/VariableTest.php +++ b/libs/vault-api-client/tests/Variables/Model/VariableTest.php @@ -4,6 +4,7 @@ namespace Keboola\VaultApiClient\Tests\Variables\Model; +use InvalidArgumentException; use Keboola\VaultApiClient\Variables\Model\Variable; use PHPUnit\Framework\TestCase; @@ -34,4 +35,98 @@ public function testFromResponseData(): void ], ), $variable); } + + public function testFromResponseDataWithDefaultFlagsAndAttributes(): void + { + $data = [ + 'hash' => 'hash', + 'key' => 'key', + 'value' => 'value', + ]; + + $variable = Variable::fromResponseData($data); + self::assertEquals([], $variable->flags); + self::assertEquals([], $variable->attributes); + } + + public function testFromResponseDataThrowsOnEmptyHash(): void + { + $this->expectException(InvalidArgumentException::class); + + Variable::fromResponseData([ + 'hash' => '', + 'key' => 'key', + 'value' => 'value', + ]); + } + + public function testFromResponseDataThrowsOnEmptyKey(): void + { + $this->expectException(InvalidArgumentException::class); + + Variable::fromResponseData([ + 'hash' => 'hash', + 'key' => '', + 'value' => 'value', + ]); + } + + public function testFromResponseDataThrowsOnNonStringValue(): void + { + $this->expectException(InvalidArgumentException::class); + + Variable::fromResponseData([ + 'hash' => 'hash', + 'key' => 'key', + 'value' => 123, + ]); + } + + public function testFromResponseDataThrowsOnNonStringFlags(): void + { + $this->expectException(InvalidArgumentException::class); + + Variable::fromResponseData([ + 'hash' => 'hash', + 'key' => 'key', + 'value' => 'value', + 'flags' => [123], + ]); + } + + public function testFromResponseDataThrowsOnNonStringAttributes(): void + { + $this->expectException(InvalidArgumentException::class); + + Variable::fromResponseData([ + 'hash' => 'hash', + 'key' => 'key', + 'value' => 'value', + 'attributes' => ['key' => 123], + ]); + } + + public function testFromResponseDataThrowsOnNonArrayFlags(): void + { + $this->expectException(InvalidArgumentException::class); + + Variable::fromResponseData([ + 'hash' => 'hash', + 'key' => 'key', + 'value' => 'value', + 'flags' => 123, + ]); + } + + public function testFromResponseDataThrowsOnIndexedAttributes(): void + { + $this->expectException(InvalidArgumentException::class); + + Variable::fromResponseData([ + 'hash' => 'hash', + 'key' => 'key', + 'value' => 'value', + 'attributes' => ['val1', 'val2'], + ]); + } } diff --git a/libs/vault-api-client/tests/Variables/VariablesApiClientTest.php b/libs/vault-api-client/tests/Variables/VariablesApiClientTest.php index db186aed2..c9f058ee6 100644 --- a/libs/vault-api-client/tests/Variables/VariablesApiClientTest.php +++ b/libs/vault-api-client/tests/Variables/VariablesApiClientTest.php @@ -5,13 +5,17 @@ namespace Keboola\VaultApiClient\Tests\Variables; use GuzzleHttp\Psr7\Response; -use Keboola\VaultApiClient\ApiClientConfiguration; -use Keboola\VaultApiClient\Json; +use InvalidArgumentException; +use Keboola\ApiClientBase\Json; +use Keboola\VaultApiClient\Exception\VaultClientException; use Keboola\VaultApiClient\Tests\ApiClientTestTrait; use Keboola\VaultApiClient\Variables\Model\ListOptions; use Keboola\VaultApiClient\Variables\Model\Variable; use Keboola\VaultApiClient\Variables\VariablesApiClient; +use Monolog\Handler\TestHandler; +use Monolog\Logger; use PHPUnit\Framework\TestCase; +use ReflectionMethod; class VariablesApiClientTest extends TestCase { @@ -43,9 +47,7 @@ public function testCreateVariable(): void $client = new VariablesApiClient( self::BASE_URL, self::API_TOKEN, - new ApiClientConfiguration( - requestHandler: $requestHandler(...), - ), + requestHandler: $requestHandler(...), ); $variable = $client->createVariable( @@ -97,9 +99,7 @@ public function testDeleteVariable(): void $client = new VariablesApiClient( self::BASE_URL, self::API_TOKEN, - new ApiClientConfiguration( - requestHandler: $requestHandler(...), - ), + requestHandler: $requestHandler(...), ); $client->deleteVariable('hash'); @@ -134,9 +134,7 @@ public function testListVariables(): void $client = new VariablesApiClient( self::BASE_URL, self::API_TOKEN, - new ApiClientConfiguration( - requestHandler: $requestHandler(...), - ), + requestHandler: $requestHandler(...), ); $variables = $client->listVariables(new ListOptions(offset: 5)); @@ -193,9 +191,7 @@ public function testListScopedVariablesForBranch(): void $client = new VariablesApiClient( self::BASE_URL, self::API_TOKEN, - new ApiClientConfiguration( - requestHandler: $requestHandler(...), - ), + requestHandler: $requestHandler(...), ); $variables = $client->listScopedVariablesForBranch('123'); @@ -233,4 +229,212 @@ public function testListScopedVariablesForBranch(): void $variables[1], ); } + + public function testEmptyBaseUrlThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Base URL must be a non-empty string'); + + new VariablesApiClient('', self::API_TOKEN); // @phpstan-ignore-line + } + + public function testEmptyTokenThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Storage API token must not be empty'); + + new VariablesApiClient(self::BASE_URL, ''); // @phpstan-ignore-line + } + + public function testClientErrorWithVaultFormat(): void + { + $requestHandler = self::createRequestHandler($requestsHistory, [ + new Response( + 400, + ['Content-Type' => 'application/json'], + Json::encodeArray([ + 'error' => 'Missing data', + 'code' => 400, + ]), + ), + ]); + + $client = new VariablesApiClient( + self::BASE_URL, + self::API_TOKEN, + backoffMaxTries: 0, + requestHandler: $requestHandler(...), + ); + + $this->expectException(VaultClientException::class); + $this->expectExceptionMessage('400: Missing data'); + + $client->deleteVariable('hash'); + } + + public function testClientErrorWithNonStandardFormat(): void + { + $requestHandler = self::createRequestHandler($requestsHistory, [ + new Response( + 400, + ['Content-Type' => 'application/json'], + Json::encodeArray([ + 'error' => 'Missing data', + ]), + ), + ]); + + $client = new VariablesApiClient( + self::BASE_URL, + self::API_TOKEN, + backoffMaxTries: 0, + requestHandler: $requestHandler(...), + ); + + $this->expectException(VaultClientException::class); + $this->expectExceptionMessage( + 'Client error: `DELETE https://vault.keboola.com/variables/hash` resulted in a `400 Bad Request` response', + ); + + $client->deleteVariable('hash'); + } + + public function testTokenIsSetAsHeader(): void + { + $requestHandler = self::createRequestHandler($requestsHistory, [ + new Response(200), + ]); + + $customToken = 'custom-token'; + $client = new VariablesApiClient( + self::BASE_URL, + $customToken, + requestHandler: $requestHandler(...), + ); + + $client->deleteVariable('hash'); + + self::assertCount(1, $requestsHistory); + self::assertSame($customToken, $requestsHistory[0]['request']->getHeaderLine('X-StorageApi-Token')); + } + + public function testClientErrorMessageTrimsWhitespace(): void + { + $requestHandler = self::createRequestHandler($requestsHistory, [ + new Response( + 400, + ['Content-Type' => 'application/json'], + Json::encodeArray([ + 'error' => ' some error ', + 'code' => ' 400 ', + ]), + ), + ]); + + $client = new VariablesApiClient( + self::BASE_URL, + self::API_TOKEN, + backoffMaxTries: 0, + requestHandler: $requestHandler(...), + ); + + try { + $client->deleteVariable('hash'); + self::fail('Expected VaultClientException to be thrown'); + } catch (VaultClientException $e) { + self::assertSame('400 : some error', $e->getMessage()); + } + } + + public function testBackoffMaxTriesIsForwardedFromOptions(): void + { + $requestHandler = self::createRequestHandler($requestsHistory, [ + new Response(500), + new Response(500), + new Response(200), + ]); + + $client = new VariablesApiClient( + self::BASE_URL, + self::API_TOKEN, + backoffMaxTries: 2, + requestHandler: $requestHandler(...), + ); + + $client->deleteVariable('hash'); + + self::assertCount(3, $requestsHistory); + } + + public function testLoggerIsForwardedFromOptions(): void + { + $requestHandler = self::createRequestHandler($requestsHistory, [ + new Response(200), + ]); + + $testHandler = new TestHandler(); + $logger = new Logger('test', [$testHandler]); + + $client = new VariablesApiClient( + self::BASE_URL, + self::API_TOKEN, + logger: $logger, + requestHandler: $requestHandler(...), + ); + + $client->deleteVariable('hash'); + + self::assertNotEmpty($testHandler->getRecords()); + } + + public function testDefaultBackoffMaxTriesIsUsed(): void + { + // Asserted via the constructor default rather than exhausting 10 real exponential-backoff + // retries (~17 min); testBackoffMaxTriesIsForwardedFromOptions proves a value is wired + // through to actual retries. + $default = null; + foreach ((new ReflectionMethod(VariablesApiClient::class, '__construct'))->getParameters() as $parameter) { + if ($parameter->getName() === 'backoffMaxTries') { + $default = $parameter->getDefaultValue(); + } + } + + self::assertSame(10, $default); + } + + public function testDefaultConnectTimeoutIsForwardedToGuzzle(): void + { + $requestHandler = self::createRequestHandler($requestsHistory, [ + new Response(200), + ]); + + // Use default connectTimeout (10) — mutating it would change this assertion. + $client = new VariablesApiClient( + self::BASE_URL, + self::API_TOKEN, + requestHandler: $requestHandler(...), + ); + + $client->deleteVariable('hash'); + + self::assertSame(10, $requestsHistory[0]['options']['connect_timeout']); + } + + public function testDefaultRequestTimeoutIsForwardedToGuzzle(): void + { + $requestHandler = self::createRequestHandler($requestsHistory, [ + new Response(200), + ]); + + // Use default requestTimeout (120) — mutating it would change this assertion. + $client = new VariablesApiClient( + self::BASE_URL, + self::API_TOKEN, + requestHandler: $requestHandler(...), + ); + + $client->deleteVariable('hash'); + + self::assertSame(120, $requestsHistory[0]['options']['timeout']); + } } diff --git a/libs/vault-api-client/tests/VaultErrorMessageResolverTest.php b/libs/vault-api-client/tests/VaultErrorMessageResolverTest.php new file mode 100644 index 000000000..82918dbe1 --- /dev/null +++ b/libs/vault-api-client/tests/VaultErrorMessageResolverTest.php @@ -0,0 +1,88 @@ +resolver = new VaultErrorMessageResolver(); + } + + public function testResolvesCodeAndError(): void + { + self::assertSame( + 'X: msg', + ($this->resolver)('{"code":"X","error":"msg"}', 400), + ); + } + + public function testTrimsWhitespace(): void + { + self::assertSame( + '400 : some error', + ($this->resolver)('{"code":" 400 ","error":" some error "}', 400), + ); + } + + public function testReturnsNullWhenCodeMissing(): void + { + self::assertNull( + ($this->resolver)('{"error":"msg"}', 400), + ); + } + + public function testReturnsNullWhenErrorMissing(): void + { + self::assertNull( + ($this->resolver)('{"code":"X"}', 400), + ); + } + + public function testReturnsNullWhenCodeEmpty(): void + { + self::assertNull( + ($this->resolver)('{"code":"","error":"msg"}', 400), + ); + } + + public function testReturnsNullWhenErrorEmpty(): void + { + self::assertNull( + ($this->resolver)('{"code":"X","error":""}', 400), + ); + } + + public function testReturnsNullOnInvalidJson(): void + { + self::assertNull( + ($this->resolver)('not-valid-json', 400), + ); + } + + public function testReturnsNullOnEmptyBody(): void + { + self::assertNull( + ($this->resolver)('', 400), + ); + } + + public function testStatusCodeDoesNotAffectResult(): void + { + self::assertSame( + '404: Not found', + ($this->resolver)('{"code":"404","error":"Not found"}', 404), + ); + self::assertSame( + '404: Not found', + ($this->resolver)('{"code":"404","error":"Not found"}', 500), + ); + } +}