diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 7d82d2d80..0ead037f7 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -118,6 +118,7 @@ stages: configurationVariablesResolver:libs/configuration-variables-resolver \ doctrineRetryBundle:libs/doctrine-retry-bundle \ gitServiceApiClient:libs/git-service-api-client \ + phpApiClientBase:libs/php-api-client-base \ inputMapping:libs/input-mapping \ k8sClient:libs/k8s-client \ keyGenerator:libs/key-generator \ @@ -191,6 +192,14 @@ stages: jobs: - template: libs/git-service-api-client/azure-pipelines.tests.yml + - stage: tests_phpApiClientBase + displayName: Tests - PHP API Client Base + lockBehavior: sequential + dependsOn: build + condition: and(succeeded(), dependencies.build.outputs['checkChanges.findChanges.changedProjects_phpApiClientBase']) + jobs: + - template: libs/php-api-client-base/azure-pipelines.tests.yml + - stage: tests_inputMapping displayName: Tests - Input Mapping lockBehavior: sequential @@ -336,6 +345,7 @@ stages: - tests_configurationVariablesResolver - tests_doctrineRetryBundle - tests_gitServiceApiClient + - tests_phpApiClientBase - tests_inputMapping - tests_k8sClient - tests_keyGenerator @@ -360,6 +370,7 @@ stages: in(dependencies.tests_configurationVariablesResolver.result, 'Succeeded', 'Skipped'), in(dependencies.tests_doctrineRetryBundle.result, 'Succeeded', 'Skipped'), in(dependencies.tests_gitServiceApiClient.result, 'Succeeded', 'Skipped'), + in(dependencies.tests_phpApiClientBase.result, 'Succeeded', 'Skipped'), in(dependencies.tests_inputMapping.result, 'Succeeded', 'Skipped'), in(dependencies.tests_k8sClient.result, 'Succeeded', 'Skipped'), in(dependencies.tests_keyGenerator.result, 'Succeeded', 'Skipped'), diff --git a/docker-compose.yml b/docker-compose.yml index 10dc2be97..9a545d9df 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -201,6 +201,11 @@ services: image: keboola/git-service-api-client working_dir: /code/libs/git-service-api-client + dev-php-api-client-base: + <<: *dev82 + image: keboola/php-api-client-base + working_dir: /code/libs/php-api-client-base + dev-vault-api-client: <<: *dev82 image: keboola/vault-api-client diff --git a/libs/php-api-client-base/.gitignore b/libs/php-api-client-base/.gitignore new file mode 100644 index 000000000..c84ab0ca8 --- /dev/null +++ b/libs/php-api-client-base/.gitignore @@ -0,0 +1,3 @@ +/vendor/ +/composer.lock +/.phpunit.result.cache diff --git a/libs/php-api-client-base/README.md b/libs/php-api-client-base/README.md new file mode 100644 index 000000000..afabe5ff0 --- /dev/null +++ b/libs/php-api-client-base/README.md @@ -0,0 +1,119 @@ +# Keboola PHP API Client Base + +Shared base for building PHP clients for **Keboola services** (Storage, Manage, +Vault, Git Service, Sandboxes, Sync Actions, Job Queue, …). It is **not** a +general-purpose HTTP client — it encodes Keboola platform conventions: the +Keboola authentication headers, retry behavior, JSON handling, and error +normalization that every Keboola service client needs. + +Used by `keboola/vault-api-client`, `keboola/sandboxes-service-api-client`, +`keboola/git-service-api-client`, `keboola/sync-actions-client`, +`keboola/azure-api-client`, and new Keboola service clients. + +## Installation + +```bash +composer require keboola/php-api-client-base +``` + +## What it provides + +- `ApiClient` — Guzzle wrapper with per-request auth, retry, logging, and + response-to-model mapping. Constructed as + `new ApiClient($baseUrl, $authenticator, $options, errorMessageResolver: ..., retryableStatusCodes: [...])`. + The authenticator is **required**; pass `new NoAuthAuthenticator()` for + unauthenticated clients. `errorMessageResolver` accepts a + `?ErrorMessageResolverInterface` instance; when `null`, the shipped + `DefaultErrorMessageResolver` (which extracts `error` or `message` from JSON + bodies) is used automatically. `retryableStatusCodes` are `ApiClient` + constructor arguments supplied by the service facade (they describe the + service's API contract, not caller preferences). +- `ApiClientOptions` — retries, timeouts, logger (no auth, no error resolver — the + authenticator is a first-class `ApiClient` constructor argument; the error + resolver and retryable codes are also `ApiClient` constructor arguments). +- `Auth\RequestAuthenticatorInterface` + ready authenticators for the Keboola + auth schemes: `StorageApiTokenAuthenticator` (`X-StorageApi-Token`), + `ManageApiTokenAuthenticator` (`X-KBC-ManageApiToken`), + `KeboolaServiceAccountAuthenticator` (projected SA token → + `X-Kubernetes-Authorization`), `NoAuthAuthenticator` (explicit no-op for + unauthenticated calls). +- `ErrorMessageResolverInterface`, `DefaultErrorMessageResolver`, `RetryDecider`, + `Json`, `ResponseModelInterface`, `Exception\ClientException`. + +## Building a Keboola service client + +Compose an `ApiClient` inside your service facade and map responses to models: + +```php +use GuzzleHttp\Psr7\Request; +use Keboola\ApiClientBase\ApiClient; +use Keboola\ApiClientBase\ApiClientOptions; +use Keboola\ApiClientBase\Auth\StorageApiTokenAuthenticator; +use Keboola\ApiClientBase\ErrorMessageResolverInterface; +use Keboola\ApiClientBase\Json; +use Keboola\ApiClientBase\ResponseModelInterface; + +final class WidgetModel implements ResponseModelInterface +{ + public function __construct(public readonly string $id) {} + + public static function fromResponseData(array $data): static + { + \assert(is_string($data['id'])); + return new self($data['id']); + } +} + +final class MyServiceErrorResolver implements ErrorMessageResolverInterface +{ + public function __invoke(string $responseBody, int $statusCode): ?string + { + /** @var array{error?: string} $data */ + $data = json_decode($responseBody, true) ?? []; + return isset($data['error']) && $data['error'] !== '' ? $data['error'] : null; + } +} + +final class MyServiceClient +{ + private ApiClient $apiClient; + + public function __construct( + string $baseUrl, + StorageApiTokenAuthenticator $authenticator, + ?ApiClientOptions $options = null, + ) { + $this->apiClient = new ApiClient( + $baseUrl, + $authenticator, + $options, + errorMessageResolver: new MyServiceErrorResolver(), + retryableStatusCodes: [429], + ); + } + + public function createWidget(string $name): WidgetModel + { + return $this->apiClient->sendRequestAndMapResponse( + new Request('POST', 'widgets', ['Content-Type' => 'application/json'], Json::encodeArray(['name' => $name])), + WidgetModel::class, + ); + } +} + +$client = new MyServiceClient( + 'https://my-service.keboola.com', + new StorageApiTokenAuthenticator($storageApiToken), + new ApiClientOptions(backoffMaxTries: 3), +); +``` + +## Authentication + +Pick the authenticator matching the service's scheme, or implement +`RequestAuthenticatorInterface` for a service-specific scheme (e.g. azure's +OAuth). + +## License + +MIT diff --git a/libs/php-api-client-base/azure-pipelines.tests.yml b/libs/php-api-client-base/azure-pipelines.tests.yml new file mode 100644 index 000000000..1a1538f06 --- /dev/null +++ b/libs/php-api-client-base/azure-pipelines.tests.yml @@ -0,0 +1,6 @@ +jobs: + - template: ../../azure-pipelines/jobs/run-tests.yml + parameters: + displayName: Tests + serviceName: dev-php-api-client-base + testCommand: bash -c 'composer install && composer ci' diff --git a/libs/php-api-client-base/composer.json b/libs/php-api-client-base/composer.json new file mode 100644 index 000000000..fcedcca4a --- /dev/null +++ b/libs/php-api-client-base/composer.json @@ -0,0 +1,56 @@ +{ + "name": "keboola/php-api-client-base", + "type": "library", + "license": "MIT", + "description": "Shared base for Keboola service API clients (transport, auth, retry)", + "authors": [ + { + "name": "Keboola", + "email": "devel@keboola.com" + } + ], + "autoload": { + "psr-4": { + "Keboola\\ApiClientBase\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Keboola\\ApiClientBase\\Tests\\": "tests/" + } + }, + "require": { + "php": "^8.2", + "guzzlehttp/guzzle": "^7.8", + "psr/http-message": "^1.0|^2.0", + "psr/log": "^1.0|^2.0|^3.0", + "webmozart/assert": "^1.11" + }, + "require-dev": { + "keboola/coding-standard": "^15.0", + "phpstan/phpstan": "^1.10", + "phpstan/phpstan-phpunit": "^1.3", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.6", + "sempro/phpunit-pretty-print": "^1.4" + }, + "config": { + "lock": false, + "sort-packages": true, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + }, + "scripts": { + "ci": [ + "@composer validate --no-check-publish --no-check-all", + "@phpcs", + "@phpstan", + "@phpunit" + ], + "phpcs": "phpcs -n --ignore=vendor,cache --extensions=php .", + "phpcbf": "phpcbf --extensions=php src tests", + "phpstan": "phpstan analyse --no-progress -c phpstan.neon", + "phpunit": "phpunit" + } +} diff --git a/libs/php-api-client-base/phpcs.xml b/libs/php-api-client-base/phpcs.xml new file mode 100644 index 000000000..24c1ee514 --- /dev/null +++ b/libs/php-api-client-base/phpcs.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/libs/php-api-client-base/phpstan.neon b/libs/php-api-client-base/phpstan.neon new file mode 100644 index 000000000..87d678836 --- /dev/null +++ b/libs/php-api-client-base/phpstan.neon @@ -0,0 +1,10 @@ +parameters: + checkMissingIterableValueType: false + level: max + paths: + - src + - tests + +includes: + - vendor/phpstan/phpstan-phpunit/extension.neon + - vendor/phpstan/phpstan-webmozart-assert/extension.neon diff --git a/libs/php-api-client-base/phpunit.xml.dist b/libs/php-api-client-base/phpunit.xml.dist new file mode 100644 index 000000000..6809287bb --- /dev/null +++ b/libs/php-api-client-base/phpunit.xml.dist @@ -0,0 +1,17 @@ + + + + + tests + + + + + src + + + diff --git a/libs/php-api-client-base/src/ApiClient.php b/libs/php-api-client-base/src/ApiClient.php new file mode 100644 index 000000000..9eba0e5d0 --- /dev/null +++ b/libs/php-api-client-base/src/ApiClient.php @@ -0,0 +1,176 @@ + $retryableStatusCodes Non-5xx status codes to also retry (e.g. [429]). + */ + public function __construct( + ?string $baseUrl, + RequestAuthenticatorInterface $authenticator, + ?ApiClientOptions $options = null, + ?ErrorMessageResolverInterface $errorMessageResolver = null, + array $retryableStatusCodes = [], + ) { + $options ??= new ApiClientOptions(); + $this->errorMessageResolver = $errorMessageResolver ?? new DefaultErrorMessageResolver(); + $logger = $options->logger ?? new NullLogger(); + + $stack = $options->requestHandler instanceof HandlerStack + ? $options->requestHandler + : HandlerStack::create($options->requestHandler); + + // Push order matters: Guzzle resolves the stack so the FIRST-pushed + // middleware is OUTERMOST. Push retry before auth so auth sits INSIDE + // the retry loop and re-executes on every attempt — this lets + // file-/token-backed authenticators (e.g. the projected SA token) be + // re-resolved per retry. + if ($options->backoffMaxTries > 0) { + $stack->push(Middleware::retry(new RetryDecider( + $options->backoffMaxTries, + $logger, + $retryableStatusCodes, + ))); + } + + // Apply auth as a middleware that turns a thrown authenticator error into a rejected + // promise (rather than letting it escape the stack synchronously). This way an + // authenticator failure — e.g. a projected SA token momentarily unreadable during + // rotation — flows through the retry middleware above and the normal error handling + // below (surfacing as ClientException), instead of bypassing both. + $stack->push( + /** + * @param callable(RequestInterface, array): PromiseInterface $handler + * @return callable(RequestInterface, array): PromiseInterface + */ + static function (callable $handler) use ($authenticator): callable { + return static function ( + RequestInterface $request, + array $options, + ) use ( + $handler, + $authenticator, + ): PromiseInterface { + try { + $request = $authenticator($request); + } catch (Throwable $e) { + return Create::rejectionFor($e); + } + + return $handler($request, $options); + }; + }, + 'auth', + ); + + $stack->push(Middleware::log( + $logger, + new MessageFormatter('{method} {uri} : {code} {res_header_Content-Length}'), + )); + + $this->httpClient = new GuzzleClient([ + 'base_uri' => $baseUrl === null ? null : rtrim($baseUrl, '/') . '/', + 'handler' => $stack, + 'headers' => [ + 'User-Agent' => $options->userAgent, + ], + 'connect_timeout' => $options->connectTimeout, + 'timeout' => $options->requestTimeout, + ]); + } + + public function sendRequest(RequestInterface $request): void + { + $this->doSendRequest($request); + } + + /** + * @template T of ResponseModelInterface + * @param class-string $responseClass + * @param array $options + * @return ($isList is true ? list : T) + */ + public function sendRequestAndMapResponse( + RequestInterface $request, + string $responseClass, + array $options = [], + bool $isList = false, + ) { + $response = $this->doSendRequest($request, $options); + + try { + $data = Json::decodeArray($response->getBody()->getContents()); + } catch (JsonException $e) { + throw new ClientException('Response is not valid JSON: ' . $e->getMessage(), 0, $e); + } + + try { + if ($isList) { + /** @var list> $data */ + return array_values(array_map( + static fn(array $item): mixed => $responseClass::fromResponseData($item), + $data, + )); + } + return $responseClass::fromResponseData($data); + } catch (Throwable $e) { + throw new ClientException('Failed to map response data: ' . $e->getMessage(), 0, $e); + } + } + + /** + * @param array $options + */ + private function doSendRequest(RequestInterface $request, array $options = []): ResponseInterface + { + try { + return $this->httpClient->send($request, $options); + } catch (RequestException $e) { + throw $this->processRequestException($e); + } catch (GuzzleException $e) { + throw new ClientException($e->getMessage(), 0, $e); + } catch (Throwable $e) { + // Non-Guzzle failure bubbling out of the handler stack — e.g. an authenticator + // that could not produce credentials (after retries are exhausted). + throw new ClientException(trim($e->getMessage()), 0, $e); + } + } + + private function processRequestException(RequestException $e): ClientException + { + $response = $e->getResponse(); + if ($response === null) { + return new ClientException(trim($e->getMessage()), 0, $e); + } + + $statusCode = $response->getStatusCode(); + $body = (string) $response->getBody(); + + $message = ($this->errorMessageResolver)($body, $statusCode); + return new ClientException($message ?? trim($e->getMessage()), $statusCode, $e); + } +} diff --git a/libs/php-api-client-base/src/ApiClientOptions.php b/libs/php-api-client-base/src/ApiClientOptions.php new file mode 100644 index 000000000..5232e2b38 --- /dev/null +++ b/libs/php-api-client-base/src/ApiClientOptions.php @@ -0,0 +1,29 @@ + $backoffMaxTries + */ + public function __construct( + public readonly string $userAgent = 'Keboola PHP API Client', + public readonly int $backoffMaxTries = self::DEFAULT_BACKOFF_MAX_TRIES, + public readonly int $connectTimeout = self::DEFAULT_CONNECT_TIMEOUT, + public readonly int $requestTimeout = self::DEFAULT_REQUEST_TIMEOUT, + public readonly null|Closure|HandlerStack $requestHandler = null, + public readonly ?LoggerInterface $logger = null, + ) { + } +} diff --git a/libs/php-api-client-base/src/Auth/KeboolaServiceAccountAuthenticator.php b/libs/php-api-client-base/src/Auth/KeboolaServiceAccountAuthenticator.php new file mode 100644 index 000000000..b9b60b92a --- /dev/null +++ b/libs/php-api-client-base/src/Auth/KeboolaServiceAccountAuthenticator.php @@ -0,0 +1,64 @@ +withHeader(self::HEADER, 'Bearer ' . $this->readToken()); + } + + /** + * @return non-empty-string + */ + private function readToken(): string + { + if (!is_readable($this->tokenPath)) { + throw new RuntimeException(sprintf( + 'Service account token file "%s" is not readable', + $this->tokenPath, + )); + } + + $token = file_get_contents($this->tokenPath); + if ($token === false) { + throw new RuntimeException(sprintf( + 'Failed to read service account token file "%s"', + $this->tokenPath, + )); + } + + $token = trim($token); + if ($token === '') { + throw new RuntimeException(sprintf( + 'Service account token file is empty: "%s"', + $this->tokenPath, + )); + } + + return $token; + } +} diff --git a/libs/php-api-client-base/src/Auth/ManageApiTokenAuthenticator.php b/libs/php-api-client-base/src/Auth/ManageApiTokenAuthenticator.php new file mode 100644 index 000000000..c62bca528 --- /dev/null +++ b/libs/php-api-client-base/src/Auth/ManageApiTokenAuthenticator.php @@ -0,0 +1,29 @@ +withHeader(self::HEADER, $this->token); + } +} diff --git a/libs/php-api-client-base/src/Auth/NoAuthAuthenticator.php b/libs/php-api-client-base/src/Auth/NoAuthAuthenticator.php new file mode 100644 index 000000000..8c932b958 --- /dev/null +++ b/libs/php-api-client-base/src/Auth/NoAuthAuthenticator.php @@ -0,0 +1,15 @@ +withHeader(self::HEADER, $this->token); + } +} diff --git a/libs/php-api-client-base/src/DefaultErrorMessageResolver.php b/libs/php-api-client-base/src/DefaultErrorMessageResolver.php new file mode 100644 index 000000000..82a2e594b --- /dev/null +++ b/libs/php-api-client-base/src/DefaultErrorMessageResolver.php @@ -0,0 +1,27 @@ + $data + */ + public static function encodeArray(array $data): string + { + return (string) json_encode($data, JSON_THROW_ON_ERROR); + } + + /** + * @return array + */ + public static function decodeArray(string $data): array + { + $result = json_decode($data, true, flags: JSON_THROW_ON_ERROR); + if (!is_array($result)) { + throw new JsonException(sprintf('Decoded data is %s, array expected', get_debug_type($result))); + } + + return $result; + } +} diff --git a/libs/php-api-client-base/src/ResponseModelInterface.php b/libs/php-api-client-base/src/ResponseModelInterface.php new file mode 100644 index 000000000..f3ff427ca --- /dev/null +++ b/libs/php-api-client-base/src/ResponseModelInterface.php @@ -0,0 +1,16 @@ + $retryableStatusCodes Non-5xx status codes that should also be retried (e.g. [429]). + */ + public function __construct( + private readonly int $maxRetries, + private readonly LoggerInterface $logger, + private readonly array $retryableStatusCodes = [], + ) { + } + + public function __invoke( + int $retries, + RequestInterface $request, + ?ResponseInterface $response = null, + mixed $error = null, + ): bool { + if ($retries >= $this->maxRetries) { + return false; + } + + $code = $response?->getStatusCode(); + + // Explicitly retryable codes (e.g. 429) win over the generic 4xx no-retry rule. + if ($code !== null && in_array($code, $this->retryableStatusCodes, true)) { + return $this->logAndRetry($code, $error, $retries); + } + + if ($code !== null && $code >= 400 && $code < 500) { + return false; + } + + if ($error !== null || ($code !== null && $code >= 500)) { + return $this->logAndRetry($code, $error, $retries); + } + + return false; + } + + private function logAndRetry(?int $code, mixed $error, int $retries): bool + { + $this->logger->warning(sprintf( + 'Request failed (%s), retrying (%s of %s)', + match (true) { + $error instanceof Throwable => $error->getMessage(), + $code !== null => 'HTTP ' . $code, + default => 'unknown', + }, + $retries + 1, + $this->maxRetries, + )); + + return true; + } +} diff --git a/libs/php-api-client-base/tests/ApiClientOptionsTest.php b/libs/php-api-client-base/tests/ApiClientOptionsTest.php new file mode 100644 index 000000000..60a61403d --- /dev/null +++ b/libs/php-api-client-base/tests/ApiClientOptionsTest.php @@ -0,0 +1,32 @@ +userAgent); + self::assertSame(5, $options->backoffMaxTries); + self::assertSame(10, $options->connectTimeout); + self::assertSame(120, $options->requestTimeout); + self::assertNull($options->requestHandler); + self::assertNull($options->logger); + } + + public function testOverrides(): void + { + $options = new ApiClientOptions( + userAgent: 'My Client', + backoffMaxTries: 2, + ); + self::assertSame('My Client', $options->userAgent); + self::assertSame(2, $options->backoffMaxTries); + } +} diff --git a/libs/php-api-client-base/tests/ApiClientTest.php b/libs/php-api-client-base/tests/ApiClientTest.php new file mode 100644 index 000000000..4cedbd52d --- /dev/null +++ b/libs/php-api-client-base/tests/ApiClientTest.php @@ -0,0 +1,206 @@ +sendRequest(new Request('GET', 'foo')); + + $last = $mock->getLastRequest(); + self::assertNotNull($last); + self::assertSame([], $last->getHeader('X-KBC-ManageApiToken')); + self::assertStringContainsString('Keboola PHP API Client', $last->getHeaderLine('User-Agent')); + } + + public function testAddsAuthHeaderPerRequest(): void + { + $mock = new MockHandler([new Response(200, [], '{}')]); + $client = new ApiClient( + 'https://example.test', + new ManageApiTokenAuthenticator('secret-token'), + new ApiClientOptions(requestHandler: HandlerStack::create($mock)), + ); + $client->sendRequest(new Request('GET', 'foo')); + + $last = $mock->getLastRequest(); + self::assertNotNull($last); + self::assertSame('secret-token', $last->getHeaderLine('X-KBC-ManageApiToken')); + } + + public function testMapsResponseToModel(): void + { + $mock = new MockHandler([new Response(200, [], '{"name":"foo"}')]); + $client = new ApiClient('https://example.test', new NoAuthAuthenticator(), new ApiClientOptions( + requestHandler: HandlerStack::create($mock), + )); + $model = $client->sendRequestAndMapResponse(new Request('GET', 'foo'), DummyModel::class); + + self::assertInstanceOf(DummyModel::class, $model); + self::assertSame('foo', $model->name); + } + + public function testMapsResponseToList(): void + { + $mock = new MockHandler([new Response(200, [], '[{"name":"a"},{"name":"b"}]')]); + $client = new ApiClient('https://example.test', new NoAuthAuthenticator(), new ApiClientOptions( + requestHandler: HandlerStack::create($mock), + )); + $models = $client->sendRequestAndMapResponse(new Request('GET', 'foo'), DummyModel::class, [], true); + + self::assertCount(2, $models); + self::assertSame('a', $models[0]->name); + self::assertSame('b', $models[1]->name); + } + + public function testRetriesOn5xxThenSucceeds(): void + { + $mock = new MockHandler([new Response(500), new Response(200, [], '{}')]); + $client = new ApiClient('https://example.test', new NoAuthAuthenticator(), new ApiClientOptions( + requestHandler: HandlerStack::create($mock), + )); + $client->sendRequest(new Request('GET', 'foo')); + self::assertSame(0, $mock->count()); + } + + public function testThrowsClientExceptionWithDefaultMessageExtraction(): void + { + $mock = new MockHandler([new Response(400, [], '{"error":"bad input"}')]); + $client = new ApiClient('https://example.test', new NoAuthAuthenticator(), new ApiClientOptions( + requestHandler: HandlerStack::create($mock), + )); + $this->expectException(ClientException::class); + $this->expectExceptionMessage('bad input'); + $this->expectExceptionCode(400); + $client->sendRequest(new Request('GET', 'foo')); + } + + public function testUsesCustomErrorMessageResolver(): void + { + $mock = new MockHandler([new Response(409, [], '{"code":"CONFLICT","error":"already exists"}')]); + $resolver = new class implements ErrorMessageResolverInterface { + public function __invoke(string $responseBody, int $statusCode): ?string + { + /** @var array{code?: string, error?: string} $data */ + $data = json_decode($responseBody, true) ?? []; + return ($data['code'] ?? '') . ': ' . ($data['error'] ?? ''); + } + }; + $client = new ApiClient( + 'https://example.test', + new NoAuthAuthenticator(), + new ApiClientOptions(requestHandler: HandlerStack::create($mock)), + errorMessageResolver: $resolver, + ); + $this->expectException(ClientException::class); + $this->expectExceptionMessage('CONFLICT: already exists'); + $client->sendRequest(new Request('GET', 'foo')); + } + + public function testReExecutesAuthenticatorOnEachRetryAttempt(): void + { + $authenticator = new class implements RequestAuthenticatorInterface { + public int $calls = 0; + + public function __invoke(RequestInterface $request): RequestInterface + { + $this->calls++; + return $request->withHeader('X-Attempt', 'call-' . $this->calls); + } + }; + + $mock = new MockHandler([new Response(500), new Response(200, [], '{}')]); + $client = new ApiClient( + 'https://example.test', + $authenticator, + new ApiClientOptions(backoffMaxTries: 2, requestHandler: HandlerStack::create($mock)), + ); + $client->sendRequest(new Request('GET', 'foo')); + + self::assertSame(2, $authenticator->calls); + $last = $mock->getLastRequest(); + self::assertNotNull($last); + self::assertSame('call-2', $last->getHeaderLine('X-Attempt')); + } + + public function testThrowsClientExceptionAfterRetriesExhausted(): void + { + $mock = new MockHandler([new Response(500), new Response(500)]); + $client = new ApiClient('https://example.test', new NoAuthAuthenticator(), new ApiClientOptions( + backoffMaxTries: 1, + requestHandler: HandlerStack::create($mock), + )); + $this->expectException(ClientException::class); + $this->expectExceptionCode(500); + $client->sendRequest(new Request('GET', 'foo')); + } + + public function testRetriesConfiguredStatusCodeThroughClient(): void + { + $mock = new MockHandler([new Response(429), new Response(200, [], '{}')]); + $client = new ApiClient( + 'https://example.test', + new NoAuthAuthenticator(), + new ApiClientOptions(backoffMaxTries: 2, requestHandler: HandlerStack::create($mock)), + retryableStatusCodes: [429], + ); + $client->sendRequest(new Request('GET', 'foo')); + self::assertSame(0, $mock->count()); + } + + public function testAuthenticatorFailureIsRetriedAndSurfacesAsClientException(): void + { + // An authenticator that throws (e.g. a projected SA token momentarily unreadable) + // must surface as ClientException and flow through retry — not escape as a raw + // RuntimeException that bypasses both error handling and retry. + $authenticator = new class implements RequestAuthenticatorInterface { + public int $calls = 0; + + public function __invoke(RequestInterface $request): RequestInterface + { + $this->calls++; + throw new RuntimeException('SA token file not readable'); + } + }; + + $mock = new MockHandler([new Response(200), new Response(200)]); + $client = new ApiClient( + 'https://example.test', + $authenticator, + new ApiClientOptions(backoffMaxTries: 1, requestHandler: HandlerStack::create($mock)), + ); + + try { + $client->sendRequest(new Request('GET', 'foo')); + self::fail('Expected ClientException to be thrown'); + } catch (ClientException $e) { + self::assertStringContainsString('SA token file not readable', $e->getMessage()); + } + + // initial attempt + one retry — the auth failure went through RetryDecider + self::assertSame(2, $authenticator->calls); + } +} diff --git a/libs/php-api-client-base/tests/Auth/KeboolaServiceAccountAuthenticatorTest.php b/libs/php-api-client-base/tests/Auth/KeboolaServiceAccountAuthenticatorTest.php new file mode 100644 index 000000000..b22902b5d --- /dev/null +++ b/libs/php-api-client-base/tests/Auth/KeboolaServiceAccountAuthenticatorTest.php @@ -0,0 +1,68 @@ +getHeaderLine('X-Kubernetes-Authorization')); + } finally { + @unlink($path); + } + } + + public function testRereadsTokenOnEachCall(): void + { + $path = (string) tempnam(sys_get_temp_dir(), 'sa-token-'); + file_put_contents($path, "first\n"); + try { + /** @phpstan-ignore-next-line argument.type — tempnam returns string, not non-empty-string */ + $authenticator = new KeboolaServiceAccountAuthenticator($path); + $first = $authenticator(new Request('GET', 'https://example.test')); + self::assertSame('Bearer first', $first->getHeaderLine('X-Kubernetes-Authorization')); + + file_put_contents($path, "second\n"); + $second = $authenticator(new Request('GET', 'https://example.test')); + self::assertSame('Bearer second', $second->getHeaderLine('X-Kubernetes-Authorization')); + } finally { + @unlink($path); + } + } + + public function testThrowsWhenFileMissing(): void + { + $authenticator = new KeboolaServiceAccountAuthenticator('/nonexistent/token'); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('/nonexistent/token'); + $authenticator(new Request('GET', 'https://example.test')); + } + + public function testThrowsWhenFileEmpty(): void + { + $path = (string) tempnam(sys_get_temp_dir(), 'sa-token-'); + file_put_contents($path, " \n"); + try { + /** @phpstan-ignore-next-line argument.type — tempnam returns string, not non-empty-string */ + $authenticator = new KeboolaServiceAccountAuthenticator($path); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('is empty'); + $authenticator(new Request('GET', 'https://example.test')); + } finally { + @unlink($path); + } + } +} diff --git a/libs/php-api-client-base/tests/Auth/ManageApiTokenAuthenticatorTest.php b/libs/php-api-client-base/tests/Auth/ManageApiTokenAuthenticatorTest.php new file mode 100644 index 000000000..8adc3ec06 --- /dev/null +++ b/libs/php-api-client-base/tests/Auth/ManageApiTokenAuthenticatorTest.php @@ -0,0 +1,29 @@ +getHeaderLine('X-KBC-ManageApiToken')); + } + + public function testRejectsEmptyToken(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Manage API token must not be empty'); + /** @phpstan-ignore-next-line argument.type — exercising the runtime guard */ + new ManageApiTokenAuthenticator(''); + } +} diff --git a/libs/php-api-client-base/tests/Auth/NoAuthAuthenticatorTest.php b/libs/php-api-client-base/tests/Auth/NoAuthAuthenticatorTest.php new file mode 100644 index 000000000..f4627c7a5 --- /dev/null +++ b/libs/php-api-client-base/tests/Auth/NoAuthAuthenticatorTest.php @@ -0,0 +1,22 @@ + 'keep']); + $result = $authenticator($request); + + self::assertSame('keep', $result->getHeaderLine('X-Existing')); + self::assertSame($request->getHeaders(), $result->getHeaders()); + } +} diff --git a/libs/php-api-client-base/tests/Auth/StorageApiTokenAuthenticatorTest.php b/libs/php-api-client-base/tests/Auth/StorageApiTokenAuthenticatorTest.php new file mode 100644 index 000000000..1bfec39a4 --- /dev/null +++ b/libs/php-api-client-base/tests/Auth/StorageApiTokenAuthenticatorTest.php @@ -0,0 +1,29 @@ +getHeaderLine('X-StorageApi-Token')); + } + + public function testRejectsEmptyToken(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Storage API token must not be empty'); + /** @phpstan-ignore-next-line argument.type — exercising the runtime guard */ + new StorageApiTokenAuthenticator(''); + } +} diff --git a/libs/php-api-client-base/tests/DefaultErrorMessageResolverTest.php b/libs/php-api-client-base/tests/DefaultErrorMessageResolverTest.php new file mode 100644 index 000000000..74d1ffaf6 --- /dev/null +++ b/libs/php-api-client-base/tests/DefaultErrorMessageResolverTest.php @@ -0,0 +1,60 @@ +resolver = new DefaultErrorMessageResolver(); + } + + public function testReturnsErrorField(): void + { + $result = ($this->resolver)('{"error":"something went wrong"}', 400); + self::assertSame('something went wrong', $result); + } + + public function testFallsBackToMessageField(): void + { + $result = ($this->resolver)('{"message":"bad request"}', 400); + self::assertSame('bad request', $result); + } + + public function testPrefersErrorOverMessage(): void + { + $result = ($this->resolver)('{"error":"error value","message":"message value"}', 400); + self::assertSame('error value', $result); + } + + public function testReturnsNullWhenNeitherPresent(): void + { + $result = ($this->resolver)('{"code":42}', 400); + self::assertNull($result); + } + + public function testReturnsNullOnInvalidJson(): void + { + $result = ($this->resolver)('not json at all', 400); + self::assertNull($result); + } + + public function testReturnsNullOnEmptyErrorField(): void + { + $result = ($this->resolver)('{"error":""}', 400); + self::assertNull($result); + } + + public function testReturnsNullOnNonStringErrorField(): void + { + $result = ($this->resolver)('{"error":123}', 400); + self::assertNull($result); + } +} diff --git a/libs/php-api-client-base/tests/Exception/ClientExceptionTest.php b/libs/php-api-client-base/tests/Exception/ClientExceptionTest.php new file mode 100644 index 000000000..435894dcb --- /dev/null +++ b/libs/php-api-client-base/tests/Exception/ClientExceptionTest.php @@ -0,0 +1,20 @@ +getMessage()); + self::assertSame(500, $e->getCode()); + } +} diff --git a/libs/php-api-client-base/tests/Fixtures/DummyModel.php b/libs/php-api-client-base/tests/Fixtures/DummyModel.php new file mode 100644 index 000000000..b7e1337fb --- /dev/null +++ b/libs/php-api-client-base/tests/Fixtures/DummyModel.php @@ -0,0 +1,21 @@ + 1])); + } + + public function testDecodeArray(): void + { + self::assertSame(['a' => 1], Json::decodeArray('{"a":1}')); + } + + public function testDecodeInvalidJsonThrows(): void + { + $this->expectException(JsonException::class); + Json::decodeArray('not-json'); + } + + public function testDecodeNonArrayThrows(): void + { + $this->expectException(JsonException::class); + $this->expectExceptionMessage('Decoded data is int, array expected'); + Json::decodeArray('42'); + } +} diff --git a/libs/php-api-client-base/tests/ResponseModelInterfaceTest.php b/libs/php-api-client-base/tests/ResponseModelInterfaceTest.php new file mode 100644 index 000000000..0f20db284 --- /dev/null +++ b/libs/php-api-client-base/tests/ResponseModelInterfaceTest.php @@ -0,0 +1,19 @@ + 'foo']); + self::assertInstanceOf(ResponseModelInterface::class, $model); + self::assertSame('foo', $model->name); + } +} diff --git a/libs/php-api-client-base/tests/RetryDeciderTest.php b/libs/php-api-client-base/tests/RetryDeciderTest.php new file mode 100644 index 000000000..3d7543efa --- /dev/null +++ b/libs/php-api-client-base/tests/RetryDeciderTest.php @@ -0,0 +1,54 @@ +