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 @@
+