From 66267fde44e7e8d7f5bc6b7fd2e94ca0906e6b99 Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Tue, 9 Jun 2026 11:09:26 +0200 Subject: [PATCH 01/26] feat(php-api-client-base): scaffold package --- libs/php-api-client-base/.gitignore | 3 ++ libs/php-api-client-base/composer.json | 56 ++++++++++++++++++++ libs/php-api-client-base/phpstan.neon | 10 ++++ libs/php-api-client-base/phpunit.xml.dist | 17 ++++++ libs/php-api-client-base/tests/bootstrap.php | 5 ++ 5 files changed, 91 insertions(+) create mode 100644 libs/php-api-client-base/.gitignore create mode 100644 libs/php-api-client-base/composer.json create mode 100644 libs/php-api-client-base/phpstan.neon create mode 100644 libs/php-api-client-base/phpunit.xml.dist create mode 100644 libs/php-api-client-base/tests/bootstrap.php 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/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/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/tests/bootstrap.php b/libs/php-api-client-base/tests/bootstrap.php new file mode 100644 index 000000000..06fdcb453 --- /dev/null +++ b/libs/php-api-client-base/tests/bootstrap.php @@ -0,0 +1,5 @@ + Date: Tue, 9 Jun 2026 11:11:29 +0200 Subject: [PATCH 02/26] feat(php-api-client-base): add Json helper --- libs/php-api-client-base/src/Json.php | 31 ++++++++++++++++++ libs/php-api-client-base/tests/JsonTest.php | 35 +++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 libs/php-api-client-base/src/Json.php create mode 100644 libs/php-api-client-base/tests/JsonTest.php diff --git a/libs/php-api-client-base/src/Json.php b/libs/php-api-client-base/src/Json.php new file mode 100644 index 000000000..c6978d831 --- /dev/null +++ b/libs/php-api-client-base/src/Json.php @@ -0,0 +1,31 @@ + $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/tests/JsonTest.php b/libs/php-api-client-base/tests/JsonTest.php new file mode 100644 index 000000000..c2ee73251 --- /dev/null +++ b/libs/php-api-client-base/tests/JsonTest.php @@ -0,0 +1,35 @@ + 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'); + } +} From 39afcadc9e90fa5831099040d2a2a43987a914f7 Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Tue, 9 Jun 2026 11:12:11 +0200 Subject: [PATCH 03/26] feat(php-api-client-base): add ResponseModelInterface --- .../src/ResponseModelInterface.php | 13 ++++++++++++ .../tests/Fixtures/DummyModel.php | 20 +++++++++++++++++++ .../tests/ResponseModelInterfaceTest.php | 19 ++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 libs/php-api-client-base/src/ResponseModelInterface.php create mode 100644 libs/php-api-client-base/tests/Fixtures/DummyModel.php create mode 100644 libs/php-api-client-base/tests/ResponseModelInterfaceTest.php 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..7b30607ab --- /dev/null +++ b/libs/php-api-client-base/src/ResponseModelInterface.php @@ -0,0 +1,13 @@ + $data + */ + public static function fromResponseData(array $data): static; +} 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..4ceb603d7 --- /dev/null +++ b/libs/php-api-client-base/tests/Fixtures/DummyModel.php @@ -0,0 +1,20 @@ + 'foo']); + self::assertInstanceOf(ResponseModelInterface::class, $model); + self::assertSame('foo', $model->name); + } +} From e163e93608a1837927030f76ed08e0e30add57d7 Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Tue, 9 Jun 2026 11:12:50 +0200 Subject: [PATCH 04/26] feat(php-api-client-base): add ClientException --- .../src/Exception/ClientException.php | 11 ++++++++++ .../tests/Exception/ClientExceptionTest.php | 20 +++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 libs/php-api-client-base/src/Exception/ClientException.php create mode 100644 libs/php-api-client-base/tests/Exception/ClientExceptionTest.php diff --git a/libs/php-api-client-base/src/Exception/ClientException.php b/libs/php-api-client-base/src/Exception/ClientException.php new file mode 100644 index 000000000..13146904a --- /dev/null +++ b/libs/php-api-client-base/src/Exception/ClientException.php @@ -0,0 +1,11 @@ +getMessage()); + self::assertSame(500, $e->getCode()); + } +} From f0843b08f56aeaa1febaa4b1bdb233db1f369ae3 Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Tue, 9 Jun 2026 11:15:26 +0200 Subject: [PATCH 05/26] fix(php-api-client-base): add phpcs.xml (Keboola standard) and satisfy phpcs --- libs/php-api-client-base/phpcs.xml | 8 ++++++++ libs/php-api-client-base/tests/Fixtures/DummyModel.php | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 libs/php-api-client-base/phpcs.xml 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/tests/Fixtures/DummyModel.php b/libs/php-api-client-base/tests/Fixtures/DummyModel.php index 4ceb603d7..b7e1337fb 100644 --- a/libs/php-api-client-base/tests/Fixtures/DummyModel.php +++ b/libs/php-api-client-base/tests/Fixtures/DummyModel.php @@ -5,6 +5,7 @@ namespace Keboola\ApiClientBase\Tests\Fixtures; use Keboola\ApiClientBase\ResponseModelInterface; +use function assert; final class DummyModel implements ResponseModelInterface { @@ -14,7 +15,7 @@ public function __construct(public readonly string $name) public static function fromResponseData(array $data): static { - \assert(is_string($data['name'])); + assert(is_string($data['name'])); return new self($data['name']); } } From 1f6710f7f6ba5fdc750139a991ed347c779a39b7 Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Tue, 9 Jun 2026 11:16:54 +0200 Subject: [PATCH 06/26] feat(php-api-client-base): add RequestAuthenticatorInterface --- .../src/Auth/RequestAuthenticatorInterface.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 libs/php-api-client-base/src/Auth/RequestAuthenticatorInterface.php diff --git a/libs/php-api-client-base/src/Auth/RequestAuthenticatorInterface.php b/libs/php-api-client-base/src/Auth/RequestAuthenticatorInterface.php new file mode 100644 index 000000000..998656f8c --- /dev/null +++ b/libs/php-api-client-base/src/Auth/RequestAuthenticatorInterface.php @@ -0,0 +1,12 @@ + Date: Tue, 9 Jun 2026 11:17:27 +0200 Subject: [PATCH 07/26] feat(php-api-client-base): add StorageApiTokenAuthenticator --- .../src/Auth/StorageApiTokenAuthenticator.php | 29 +++++++++++++++++++ .../Auth/StorageApiTokenAuthenticatorTest.php | 29 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 libs/php-api-client-base/src/Auth/StorageApiTokenAuthenticator.php create mode 100644 libs/php-api-client-base/tests/Auth/StorageApiTokenAuthenticatorTest.php diff --git a/libs/php-api-client-base/src/Auth/StorageApiTokenAuthenticator.php b/libs/php-api-client-base/src/Auth/StorageApiTokenAuthenticator.php new file mode 100644 index 000000000..53c771852 --- /dev/null +++ b/libs/php-api-client-base/src/Auth/StorageApiTokenAuthenticator.php @@ -0,0 +1,29 @@ +withHeader(self::HEADER, $this->token); + } +} 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(''); + } +} From 7a4170cedd66b2321d51496923d3b5d5492c9148 Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Tue, 9 Jun 2026 11:17:59 +0200 Subject: [PATCH 08/26] feat(php-api-client-base): add ManageApiTokenAuthenticator --- .../src/Auth/ManageApiTokenAuthenticator.php | 29 +++++++++++++++++++ .../Auth/ManageApiTokenAuthenticatorTest.php | 29 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 libs/php-api-client-base/src/Auth/ManageApiTokenAuthenticator.php create mode 100644 libs/php-api-client-base/tests/Auth/ManageApiTokenAuthenticatorTest.php 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/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(''); + } +} From 19de1505382727169f237fa76187979ee483bb23 Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Tue, 9 Jun 2026 11:18:44 +0200 Subject: [PATCH 09/26] feat(php-api-client-base): add KeboolaServiceAccountAuthenticator --- .../KeboolaServiceAccountAuthenticator.php | 64 ++++++++++++++++++ ...KeboolaServiceAccountAuthenticatorTest.php | 65 +++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 libs/php-api-client-base/src/Auth/KeboolaServiceAccountAuthenticator.php create mode 100644 libs/php-api-client-base/tests/Auth/KeboolaServiceAccountAuthenticatorTest.php 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/tests/Auth/KeboolaServiceAccountAuthenticatorTest.php b/libs/php-api-client-base/tests/Auth/KeboolaServiceAccountAuthenticatorTest.php new file mode 100644 index 000000000..4e7de39b9 --- /dev/null +++ b/libs/php-api-client-base/tests/Auth/KeboolaServiceAccountAuthenticatorTest.php @@ -0,0 +1,65 @@ +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 { + $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 { + $authenticator = new KeboolaServiceAccountAuthenticator($path); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('is empty'); + $authenticator(new Request('GET', 'https://example.test')); + } finally { + @unlink($path); + } + } +} From cd4c0476a874a37f8f82bd7e33a3770f0a1ffe51 Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Tue, 9 Jun 2026 11:19:43 +0200 Subject: [PATCH 10/26] fix(php-api-client-base): suppress phpstan non-empty-string warnings in KeboolaServiceAccountAuthenticatorTest tempnam() returns string, not non-empty-string; add @phpstan-ignore-next-line on the three constructor call-sites that pass tempnam results so level-max CI passes. --- .../tests/Auth/KeboolaServiceAccountAuthenticatorTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libs/php-api-client-base/tests/Auth/KeboolaServiceAccountAuthenticatorTest.php b/libs/php-api-client-base/tests/Auth/KeboolaServiceAccountAuthenticatorTest.php index 4e7de39b9..b22902b5d 100644 --- a/libs/php-api-client-base/tests/Auth/KeboolaServiceAccountAuthenticatorTest.php +++ b/libs/php-api-client-base/tests/Auth/KeboolaServiceAccountAuthenticatorTest.php @@ -16,6 +16,7 @@ public function testReadsTokenFileAndSetsBearerHeader(): void $path = (string) tempnam(sys_get_temp_dir(), 'sa-token-'); file_put_contents($path, "the-token\n"); try { + /** @phpstan-ignore-next-line argument.type — tempnam returns string, not non-empty-string */ $authenticator = new KeboolaServiceAccountAuthenticator($path); $request = $authenticator(new Request('GET', 'https://example.test')); self::assertSame('Bearer the-token', $request->getHeaderLine('X-Kubernetes-Authorization')); @@ -29,6 +30,7 @@ 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')); @@ -54,6 +56,7 @@ 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'); From fa84b1c23f455f872941d3a9b910486d196cc407 Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Tue, 9 Jun 2026 11:22:00 +0200 Subject: [PATCH 11/26] feat(php-api-client-base): add RetryDecider with configurable retryable codes --- libs/php-api-client-base/src/RetryDecider.php | 73 +++++++++++++++++++ .../tests/RetryDeciderTest.php | 54 ++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 libs/php-api-client-base/src/RetryDecider.php create mode 100644 libs/php-api-client-base/tests/RetryDeciderTest.php diff --git a/libs/php-api-client-base/src/RetryDecider.php b/libs/php-api-client-base/src/RetryDecider.php new file mode 100644 index 000000000..f4b8fc57d --- /dev/null +++ b/libs/php-api-client-base/src/RetryDecider.php @@ -0,0 +1,73 @@ + $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 = null; + if ($response !== null) { + $code = $response->getStatusCode(); + } elseif ($error instanceof Throwable) { + $errorCode = $error->getCode(); + $code = is_int($errorCode) ? $errorCode : null; + } + + // 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, + $this->maxRetries, + )); + + return true; + } +} 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 @@ + Date: Tue, 9 Jun 2026 11:22:51 +0200 Subject: [PATCH 12/26] feat(php-api-client-base): add ApiClientConfiguration --- .../src/ApiClientConfiguration.php | 33 +++++++++++++++ .../tests/ApiClientConfigurationTest.php | 42 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 libs/php-api-client-base/src/ApiClientConfiguration.php create mode 100644 libs/php-api-client-base/tests/ApiClientConfigurationTest.php diff --git a/libs/php-api-client-base/src/ApiClientConfiguration.php b/libs/php-api-client-base/src/ApiClientConfiguration.php new file mode 100644 index 000000000..764fb48f1 --- /dev/null +++ b/libs/php-api-client-base/src/ApiClientConfiguration.php @@ -0,0 +1,33 @@ + $backoffMaxTries + * @param list $retryableStatusCodes Non-5xx status codes to also retry (e.g. [429]). + * @param (Closure(string, int): ?string)|null $errorMessageResolver + * Maps a (responseBody, statusCode) to an error message, or null to fall back to the default. + */ + public function __construct( + public readonly ?RequestAuthenticatorInterface $authenticator = null, + public readonly string $userAgent = 'Keboola PHP API Client', + public readonly int $backoffMaxTries = 5, + public readonly array $retryableStatusCodes = [], + public readonly int $connectTimeout = 10, + public readonly int $requestTimeout = 120, + public readonly null|Closure|HandlerStack $requestHandler = null, + public readonly LoggerInterface $logger = new NullLogger(), + public readonly ?Closure $errorMessageResolver = null, + ) { + } +} diff --git a/libs/php-api-client-base/tests/ApiClientConfigurationTest.php b/libs/php-api-client-base/tests/ApiClientConfigurationTest.php new file mode 100644 index 000000000..50864a521 --- /dev/null +++ b/libs/php-api-client-base/tests/ApiClientConfigurationTest.php @@ -0,0 +1,42 @@ +authenticator); + self::assertSame('Keboola PHP API Client', $config->userAgent); + self::assertSame(5, $config->backoffMaxTries); + self::assertSame([], $config->retryableStatusCodes); + self::assertSame(10, $config->connectTimeout); + self::assertSame(120, $config->requestTimeout); + self::assertNull($config->requestHandler); + self::assertInstanceOf(NullLogger::class, $config->logger); + self::assertNull($config->errorMessageResolver); + } + + public function testOverrides(): void + { + $auth = new ManageApiTokenAuthenticator('t'); + $config = new ApiClientConfiguration( + authenticator: $auth, + userAgent: 'My Client', + backoffMaxTries: 2, + retryableStatusCodes: [429], + ); + self::assertSame($auth, $config->authenticator); + self::assertSame('My Client', $config->userAgent); + self::assertSame(2, $config->backoffMaxTries); + self::assertSame([429], $config->retryableStatusCodes); + } +} From e206a99d7801af88ccbf7ca97c90d52c255e7866 Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Tue, 9 Jun 2026 11:27:37 +0200 Subject: [PATCH 13/26] feat(php-api-client-base): add ApiClient --- libs/php-api-client-base/src/ApiClient.php | 158 ++++++++++++++++++ .../tests/ApiClientTest.php | 110 ++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 libs/php-api-client-base/src/ApiClient.php create mode 100644 libs/php-api-client-base/tests/ApiClientTest.php 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..9703936c6 --- /dev/null +++ b/libs/php-api-client-base/src/ApiClient.php @@ -0,0 +1,158 @@ +errorMessageResolver = $configuration->errorMessageResolver; + + $stack = $configuration->requestHandler instanceof HandlerStack + ? $configuration->requestHandler + : HandlerStack::create($configuration->requestHandler); + + if ($configuration->authenticator !== null) { + $stack->push(Middleware::mapRequest($configuration->authenticator)); + } + + if ($configuration->backoffMaxTries > 0) { + $stack->push(Middleware::retry(new RetryDecider( + $configuration->backoffMaxTries, + $configuration->logger, + $configuration->retryableStatusCodes, + ))); + } + + $stack->push(Middleware::log( + $configuration->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' => $configuration->userAgent, + ], + 'connect_timeout' => $configuration->connectTimeout, + 'timeout' => $configuration->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); + } + } + + 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(); + + if ($this->errorMessageResolver !== null) { + $message = ($this->errorMessageResolver)($body, $statusCode); + if ($message !== null && $message !== '') { + return new ClientException($message, $statusCode, $e); + } + return new ClientException(trim($e->getMessage()), $statusCode, $e); + } + + return new ClientException($this->defaultErrorMessage($body) ?? trim($e->getMessage()), $statusCode, $e); + } + + private function defaultErrorMessage(string $body): ?string + { + try { + $data = Json::decodeArray($body); + } catch (JsonException) { + return null; + } + + foreach (['error', 'message'] as $key) { + if (isset($data[$key]) && is_string($data[$key]) && $data[$key] !== '') { + return $data[$key]; + } + } + + return null; + } +} 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..97fc4e82d --- /dev/null +++ b/libs/php-api-client-base/tests/ApiClientTest.php @@ -0,0 +1,110 @@ +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 ApiClientConfiguration( + authenticator: new ManageApiTokenAuthenticator('secret-token'), + 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 ApiClientConfiguration( + 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 ApiClientConfiguration( + 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 ApiClientConfiguration( + 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 ApiClientConfiguration( + 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"}')]); + $client = new ApiClient('https://example.test', new ApiClientConfiguration( + requestHandler: HandlerStack::create($mock), + errorMessageResolver: static function (string $body): string { + /** @var array{code?: string, error?: string} $data */ + $data = json_decode($body, true); + return ($data['code'] ?? '') . ': ' . ($data['error'] ?? ''); + }, + )); + $this->expectException(ClientException::class); + $this->expectExceptionMessage('CONFLICT: already exists'); + $client->sendRequest(new Request('GET', 'foo')); + } +} From e7021cd29c227cf63c2fa0ca4e777cc1a9aca081 Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Tue, 9 Jun 2026 11:48:48 +0200 Subject: [PATCH 14/26] fix(php-api-client-base): re-run auth inside retry loop; harden RetryDecider; add retry-path tests --- libs/php-api-client-base/src/ApiClient.php | 13 +++-- libs/php-api-client-base/src/RetryDecider.php | 8 +-- .../tests/ApiClientTest.php | 52 +++++++++++++++++++ 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/libs/php-api-client-base/src/ApiClient.php b/libs/php-api-client-base/src/ApiClient.php index 9703936c6..9c973e33c 100644 --- a/libs/php-api-client-base/src/ApiClient.php +++ b/libs/php-api-client-base/src/ApiClient.php @@ -37,10 +37,11 @@ public function __construct( ? $configuration->requestHandler : HandlerStack::create($configuration->requestHandler); - if ($configuration->authenticator !== null) { - $stack->push(Middleware::mapRequest($configuration->authenticator)); - } - + // 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 ($configuration->backoffMaxTries > 0) { $stack->push(Middleware::retry(new RetryDecider( $configuration->backoffMaxTries, @@ -49,6 +50,10 @@ public function __construct( ))); } + if ($configuration->authenticator !== null) { + $stack->push(Middleware::mapRequest($configuration->authenticator)); + } + $stack->push(Middleware::log( $configuration->logger, new MessageFormatter('{method} {uri} : {code} {res_header_Content-Length}'), diff --git a/libs/php-api-client-base/src/RetryDecider.php b/libs/php-api-client-base/src/RetryDecider.php index f4b8fc57d..08428fd00 100644 --- a/libs/php-api-client-base/src/RetryDecider.php +++ b/libs/php-api-client-base/src/RetryDecider.php @@ -31,13 +31,7 @@ public function __invoke( return false; } - $code = null; - if ($response !== null) { - $code = $response->getStatusCode(); - } elseif ($error instanceof Throwable) { - $errorCode = $error->getCode(); - $code = is_int($errorCode) ? $errorCode : null; - } + $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)) { diff --git a/libs/php-api-client-base/tests/ApiClientTest.php b/libs/php-api-client-base/tests/ApiClientTest.php index 97fc4e82d..8ad8f74c5 100644 --- a/libs/php-api-client-base/tests/ApiClientTest.php +++ b/libs/php-api-client-base/tests/ApiClientTest.php @@ -11,9 +11,11 @@ use Keboola\ApiClientBase\ApiClient; use Keboola\ApiClientBase\ApiClientConfiguration; use Keboola\ApiClientBase\Auth\ManageApiTokenAuthenticator; +use Keboola\ApiClientBase\Auth\RequestAuthenticatorInterface; use Keboola\ApiClientBase\Exception\ClientException; use Keboola\ApiClientBase\Tests\Fixtures\DummyModel; use PHPUnit\Framework\TestCase; +use Psr\Http\Message\RequestInterface; class ApiClientTest extends TestCase { @@ -107,4 +109,54 @@ public function testUsesCustomErrorMessageResolver(): void $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', new ApiClientConfiguration( + authenticator: $authenticator, + 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 ApiClientConfiguration( + 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 ApiClientConfiguration( + backoffMaxTries: 2, + retryableStatusCodes: [429], + requestHandler: HandlerStack::create($mock), + )); + $client->sendRequest(new Request('GET', 'foo')); + self::assertSame(0, $mock->count()); + } } From 166996e6a8428ac6ff2c8981115ac263415ce7bb Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Tue, 9 Jun 2026 11:51:00 +0200 Subject: [PATCH 15/26] docs(php-api-client-base): add README framed for Keboola service clients --- libs/php-api-client-base/README.md | 89 ++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 libs/php-api-client-base/README.md diff --git a/libs/php-api-client-base/README.md b/libs/php-api-client-base/README.md new file mode 100644 index 000000000..db4b172e5 --- /dev/null +++ b/libs/php-api-client-base/README.md @@ -0,0 +1,89 @@ +# 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. +- `ApiClientConfiguration` — auth, retries, timeouts, logger, error resolver. +- `Auth\RequestAuthenticatorInterface` + ready authenticators for the Keboola + auth schemes: `StorageApiTokenAuthenticator` (`X-StorageApi-Token`), + `ManageApiTokenAuthenticator` (`X-KBC-ManageApiToken`), + `KeboolaServiceAccountAuthenticator` (projected SA token → + `X-Kubernetes-Authorization`). +- `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\ApiClientConfiguration; +use Keboola\ApiClientBase\Auth\StorageApiTokenAuthenticator; +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 MyServiceClient +{ + private ApiClient $apiClient; + + public function __construct(string $baseUrl, ?ApiClientConfiguration $configuration = null) + { + $this->apiClient = new ApiClient($baseUrl, $configuration); + } + + 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 ApiClientConfiguration( + authenticator: new StorageApiTokenAuthenticator($storageApiToken), + ), +); +``` + +## Authentication + +Pick the authenticator matching the service's scheme, or implement +`RequestAuthenticatorInterface` for a service-specific scheme (e.g. azure's +OAuth). `Content-Type` is set per request on calls with a body; the only Guzzle +default header is `User-Agent` (set via `ApiClientConfiguration::$userAgent`). + +## License + +MIT From 1aae1bf97e8ec1e76ae0a4496a37f90872c55367 Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Tue, 9 Jun 2026 11:53:42 +0200 Subject: [PATCH 16/26] ci(php-api-client-base): register lib for tests in monorepo pipeline --- azure-pipelines.yml | 11 +++++++++++ libs/php-api-client-base/azure-pipelines.tests.yml | 6 ++++++ 2 files changed, 17 insertions(+) create mode 100644 libs/php-api-client-base/azure-pipelines.tests.yml 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/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' From 4a79d06fa28d602bb306776a9ecb34d902d021df Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Tue, 9 Jun 2026 13:44:03 +0200 Subject: [PATCH 17/26] refactor(php-api-client-base): rename ApiClientConfiguration to ApiClientOptions Remove the authenticator field from the options object in preparation for making it a first-class ApiClient constructor argument. Rename the test file to match the new class name and drop the authenticator-related assertions. --- ...Configuration.php => ApiClientOptions.php} | 4 +- .../tests/ApiClientConfigurationTest.php | 42 ------------------- .../tests/ApiClientOptionsTest.php | 37 ++++++++++++++++ 3 files changed, 38 insertions(+), 45 deletions(-) rename libs/php-api-client-base/src/{ApiClientConfiguration.php => ApiClientOptions.php} (85%) delete mode 100644 libs/php-api-client-base/tests/ApiClientConfigurationTest.php create mode 100644 libs/php-api-client-base/tests/ApiClientOptionsTest.php diff --git a/libs/php-api-client-base/src/ApiClientConfiguration.php b/libs/php-api-client-base/src/ApiClientOptions.php similarity index 85% rename from libs/php-api-client-base/src/ApiClientConfiguration.php rename to libs/php-api-client-base/src/ApiClientOptions.php index 764fb48f1..7a98f9573 100644 --- a/libs/php-api-client-base/src/ApiClientConfiguration.php +++ b/libs/php-api-client-base/src/ApiClientOptions.php @@ -6,11 +6,10 @@ use Closure; use GuzzleHttp\HandlerStack; -use Keboola\ApiClientBase\Auth\RequestAuthenticatorInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; -class ApiClientConfiguration +class ApiClientOptions { /** * @param int<0, max> $backoffMaxTries @@ -19,7 +18,6 @@ class ApiClientConfiguration * Maps a (responseBody, statusCode) to an error message, or null to fall back to the default. */ public function __construct( - public readonly ?RequestAuthenticatorInterface $authenticator = null, public readonly string $userAgent = 'Keboola PHP API Client', public readonly int $backoffMaxTries = 5, public readonly array $retryableStatusCodes = [], diff --git a/libs/php-api-client-base/tests/ApiClientConfigurationTest.php b/libs/php-api-client-base/tests/ApiClientConfigurationTest.php deleted file mode 100644 index 50864a521..000000000 --- a/libs/php-api-client-base/tests/ApiClientConfigurationTest.php +++ /dev/null @@ -1,42 +0,0 @@ -authenticator); - self::assertSame('Keboola PHP API Client', $config->userAgent); - self::assertSame(5, $config->backoffMaxTries); - self::assertSame([], $config->retryableStatusCodes); - self::assertSame(10, $config->connectTimeout); - self::assertSame(120, $config->requestTimeout); - self::assertNull($config->requestHandler); - self::assertInstanceOf(NullLogger::class, $config->logger); - self::assertNull($config->errorMessageResolver); - } - - public function testOverrides(): void - { - $auth = new ManageApiTokenAuthenticator('t'); - $config = new ApiClientConfiguration( - authenticator: $auth, - userAgent: 'My Client', - backoffMaxTries: 2, - retryableStatusCodes: [429], - ); - self::assertSame($auth, $config->authenticator); - self::assertSame('My Client', $config->userAgent); - self::assertSame(2, $config->backoffMaxTries); - self::assertSame([429], $config->retryableStatusCodes); - } -} 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..b8384fc9c --- /dev/null +++ b/libs/php-api-client-base/tests/ApiClientOptionsTest.php @@ -0,0 +1,37 @@ +userAgent); + self::assertSame(5, $options->backoffMaxTries); + self::assertSame([], $options->retryableStatusCodes); + self::assertSame(10, $options->connectTimeout); + self::assertSame(120, $options->requestTimeout); + self::assertNull($options->requestHandler); + self::assertInstanceOf(NullLogger::class, $options->logger); + self::assertNull($options->errorMessageResolver); + } + + public function testOverrides(): void + { + $options = new ApiClientOptions( + userAgent: 'My Client', + backoffMaxTries: 2, + retryableStatusCodes: [429], + ); + self::assertSame('My Client', $options->userAgent); + self::assertSame(2, $options->backoffMaxTries); + self::assertSame([429], $options->retryableStatusCodes); + } +} From 523ade69240ea851af76bd46a7666445be947a48 Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Tue, 9 Jun 2026 13:44:11 +0200 Subject: [PATCH 18/26] refactor(php-api-client-base): make authenticator a first-class ApiClient argument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ApiClient now accepts the authenticator as an explicit second constructor parameter rather than a field on ApiClientOptions. This makes auth intent clear at the call site and decouples the options bag from authentication concerns. Middleware push order (retry → auth → log) is preserved so the authenticator re-runs on every retry attempt. --- libs/php-api-client-base/README.md | 21 ++++++----- libs/php-api-client-base/src/ApiClient.php | 34 +++++++++-------- .../tests/ApiClientTest.php | 37 ++++++++++--------- 3 files changed, 49 insertions(+), 43 deletions(-) diff --git a/libs/php-api-client-base/README.md b/libs/php-api-client-base/README.md index db4b172e5..59e226dc6 100644 --- a/libs/php-api-client-base/README.md +++ b/libs/php-api-client-base/README.md @@ -20,7 +20,8 @@ composer require keboola/php-api-client-base - `ApiClient` — Guzzle wrapper with per-request auth, retry, logging, and response-to-model mapping. -- `ApiClientConfiguration` — auth, retries, timeouts, logger, error resolver. +- `ApiClientOptions` — retries, timeouts, logger, error resolver (no auth — the + authenticator is a first-class `ApiClient` constructor argument). - `Auth\RequestAuthenticatorInterface` + ready authenticators for the Keboola auth schemes: `StorageApiTokenAuthenticator` (`X-StorageApi-Token`), `ManageApiTokenAuthenticator` (`X-KBC-ManageApiToken`), @@ -35,7 +36,7 @@ Compose an `ApiClient` inside your service facade and map responses to models: ```php use GuzzleHttp\Psr7\Request; use Keboola\ApiClientBase\ApiClient; -use Keboola\ApiClientBase\ApiClientConfiguration; +use Keboola\ApiClientBase\ApiClientOptions; use Keboola\ApiClientBase\Auth\StorageApiTokenAuthenticator; use Keboola\ApiClientBase\Json; use Keboola\ApiClientBase\ResponseModelInterface; @@ -55,9 +56,12 @@ final class MyServiceClient { private ApiClient $apiClient; - public function __construct(string $baseUrl, ?ApiClientConfiguration $configuration = null) - { - $this->apiClient = new ApiClient($baseUrl, $configuration); + public function __construct( + string $baseUrl, + StorageApiTokenAuthenticator $authenticator, + ?ApiClientOptions $options = null, + ) { + $this->apiClient = new ApiClient($baseUrl, $authenticator, $options); } public function createWidget(string $name): WidgetModel @@ -71,9 +75,8 @@ final class MyServiceClient $client = new MyServiceClient( 'https://my-service.keboola.com', - new ApiClientConfiguration( - authenticator: new StorageApiTokenAuthenticator($storageApiToken), - ), + new StorageApiTokenAuthenticator($storageApiToken), + new ApiClientOptions(backoffMaxTries: 3), ); ``` @@ -82,7 +85,7 @@ $client = new MyServiceClient( Pick the authenticator matching the service's scheme, or implement `RequestAuthenticatorInterface` for a service-specific scheme (e.g. azure's OAuth). `Content-Type` is set per request on calls with a body; the only Guzzle -default header is `User-Agent` (set via `ApiClientConfiguration::$userAgent`). +default header is `User-Agent` (set via `ApiClientOptions::$userAgent`). ## License diff --git a/libs/php-api-client-base/src/ApiClient.php b/libs/php-api-client-base/src/ApiClient.php index 9c973e33c..5759eedbd 100644 --- a/libs/php-api-client-base/src/ApiClient.php +++ b/libs/php-api-client-base/src/ApiClient.php @@ -12,6 +12,7 @@ use GuzzleHttp\MessageFormatter; use GuzzleHttp\Middleware; use JsonException; +use Keboola\ApiClientBase\Auth\RequestAuthenticatorInterface; use Keboola\ApiClientBase\Exception\ClientException; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; @@ -28,34 +29,35 @@ class ApiClient */ public function __construct( ?string $baseUrl = null, - ?ApiClientConfiguration $configuration = null, + ?RequestAuthenticatorInterface $authenticator = null, + ?ApiClientOptions $options = null, ) { - $configuration ??= new ApiClientConfiguration(); - $this->errorMessageResolver = $configuration->errorMessageResolver; + $options ??= new ApiClientOptions(); + $this->errorMessageResolver = $options->errorMessageResolver; - $stack = $configuration->requestHandler instanceof HandlerStack - ? $configuration->requestHandler - : HandlerStack::create($configuration->requestHandler); + $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 ($configuration->backoffMaxTries > 0) { + if ($options->backoffMaxTries > 0) { $stack->push(Middleware::retry(new RetryDecider( - $configuration->backoffMaxTries, - $configuration->logger, - $configuration->retryableStatusCodes, + $options->backoffMaxTries, + $options->logger, + $options->retryableStatusCodes, ))); } - if ($configuration->authenticator !== null) { - $stack->push(Middleware::mapRequest($configuration->authenticator)); + if ($authenticator !== null) { + $stack->push(Middleware::mapRequest($authenticator)); } $stack->push(Middleware::log( - $configuration->logger, + $options->logger, new MessageFormatter('{method} {uri} : {code} {res_header_Content-Length}'), )); @@ -63,10 +65,10 @@ public function __construct( 'base_uri' => $baseUrl === null ? null : rtrim($baseUrl, '/') . '/', 'handler' => $stack, 'headers' => [ - 'User-Agent' => $configuration->userAgent, + 'User-Agent' => $options->userAgent, ], - 'connect_timeout' => $configuration->connectTimeout, - 'timeout' => $configuration->requestTimeout, + 'connect_timeout' => $options->connectTimeout, + 'timeout' => $options->requestTimeout, ]); } diff --git a/libs/php-api-client-base/tests/ApiClientTest.php b/libs/php-api-client-base/tests/ApiClientTest.php index 8ad8f74c5..291cda77c 100644 --- a/libs/php-api-client-base/tests/ApiClientTest.php +++ b/libs/php-api-client-base/tests/ApiClientTest.php @@ -9,7 +9,7 @@ use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use Keboola\ApiClientBase\ApiClient; -use Keboola\ApiClientBase\ApiClientConfiguration; +use Keboola\ApiClientBase\ApiClientOptions; use Keboola\ApiClientBase\Auth\ManageApiTokenAuthenticator; use Keboola\ApiClientBase\Auth\RequestAuthenticatorInterface; use Keboola\ApiClientBase\Exception\ClientException; @@ -22,7 +22,7 @@ class ApiClientTest extends TestCase public function testSendsWithoutAuthHeaderWhenNoAuthenticator(): void { $mock = new MockHandler([new Response(200, [], '{}')]); - $client = new ApiClient('https://example.test', new ApiClientConfiguration( + $client = new ApiClient('https://example.test', null, new ApiClientOptions( requestHandler: HandlerStack::create($mock), )); $client->sendRequest(new Request('GET', 'foo')); @@ -36,10 +36,11 @@ public function testSendsWithoutAuthHeaderWhenNoAuthenticator(): void public function testAddsAuthHeaderPerRequest(): void { $mock = new MockHandler([new Response(200, [], '{}')]); - $client = new ApiClient('https://example.test', new ApiClientConfiguration( - authenticator: new ManageApiTokenAuthenticator('secret-token'), - requestHandler: HandlerStack::create($mock), - )); + $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(); @@ -50,7 +51,7 @@ public function testAddsAuthHeaderPerRequest(): void public function testMapsResponseToModel(): void { $mock = new MockHandler([new Response(200, [], '{"name":"foo"}')]); - $client = new ApiClient('https://example.test', new ApiClientConfiguration( + $client = new ApiClient('https://example.test', null, new ApiClientOptions( requestHandler: HandlerStack::create($mock), )); $model = $client->sendRequestAndMapResponse(new Request('GET', 'foo'), DummyModel::class); @@ -62,7 +63,7 @@ public function testMapsResponseToModel(): void public function testMapsResponseToList(): void { $mock = new MockHandler([new Response(200, [], '[{"name":"a"},{"name":"b"}]')]); - $client = new ApiClient('https://example.test', new ApiClientConfiguration( + $client = new ApiClient('https://example.test', null, new ApiClientOptions( requestHandler: HandlerStack::create($mock), )); $models = $client->sendRequestAndMapResponse(new Request('GET', 'foo'), DummyModel::class, [], true); @@ -75,7 +76,7 @@ public function testMapsResponseToList(): void public function testRetriesOn5xxThenSucceeds(): void { $mock = new MockHandler([new Response(500), new Response(200, [], '{}')]); - $client = new ApiClient('https://example.test', new ApiClientConfiguration( + $client = new ApiClient('https://example.test', null, new ApiClientOptions( requestHandler: HandlerStack::create($mock), )); $client->sendRequest(new Request('GET', 'foo')); @@ -85,7 +86,7 @@ public function testRetriesOn5xxThenSucceeds(): void public function testThrowsClientExceptionWithDefaultMessageExtraction(): void { $mock = new MockHandler([new Response(400, [], '{"error":"bad input"}')]); - $client = new ApiClient('https://example.test', new ApiClientConfiguration( + $client = new ApiClient('https://example.test', null, new ApiClientOptions( requestHandler: HandlerStack::create($mock), )); $this->expectException(ClientException::class); @@ -97,7 +98,7 @@ public function testThrowsClientExceptionWithDefaultMessageExtraction(): void public function testUsesCustomErrorMessageResolver(): void { $mock = new MockHandler([new Response(409, [], '{"code":"CONFLICT","error":"already exists"}')]); - $client = new ApiClient('https://example.test', new ApiClientConfiguration( + $client = new ApiClient('https://example.test', null, new ApiClientOptions( requestHandler: HandlerStack::create($mock), errorMessageResolver: static function (string $body): string { /** @var array{code?: string, error?: string} $data */ @@ -123,11 +124,11 @@ public function __invoke(RequestInterface $request): RequestInterface }; $mock = new MockHandler([new Response(500), new Response(200, [], '{}')]); - $client = new ApiClient('https://example.test', new ApiClientConfiguration( - authenticator: $authenticator, - backoffMaxTries: 2, - requestHandler: HandlerStack::create($mock), - )); + $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); @@ -139,7 +140,7 @@ public function __invoke(RequestInterface $request): RequestInterface public function testThrowsClientExceptionAfterRetriesExhausted(): void { $mock = new MockHandler([new Response(500), new Response(500)]); - $client = new ApiClient('https://example.test', new ApiClientConfiguration( + $client = new ApiClient('https://example.test', null, new ApiClientOptions( backoffMaxTries: 1, requestHandler: HandlerStack::create($mock), )); @@ -151,7 +152,7 @@ public function testThrowsClientExceptionAfterRetriesExhausted(): void public function testRetriesConfiguredStatusCodeThroughClient(): void { $mock = new MockHandler([new Response(429), new Response(200, [], '{}')]); - $client = new ApiClient('https://example.test', new ApiClientConfiguration( + $client = new ApiClient('https://example.test', null, new ApiClientOptions( backoffMaxTries: 2, retryableStatusCodes: [429], requestHandler: HandlerStack::create($mock), From c29d762a9c1c05c72ff85979364f3e605e600f50 Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Tue, 9 Jun 2026 15:53:08 +0200 Subject: [PATCH 19/26] feat(php-api-client-base): require authenticator on ApiClient; add NoAuthAuthenticator Make `RequestAuthenticatorInterface $authenticator` a mandatory second constructor argument on `ApiClient` (removing the `= null` default and the null-guard around the mapRequest push). Add `NoAuthAuthenticator` as the explicit no-op authenticator for unauthenticated clients, covered by a new TDD test. Update all existing tests to pass `new NoAuthAuthenticator()` where the authenticator was previously omitted, and document the new signature in the README. --- libs/php-api-client-base/README.md | 7 ++++-- libs/php-api-client-base/src/ApiClient.php | 8 +++---- .../src/Auth/NoAuthAuthenticator.php | 15 +++++++++++++ .../tests/ApiClientTest.php | 19 ++++++++-------- .../tests/Auth/NoAuthAuthenticatorTest.php | 22 +++++++++++++++++++ 5 files changed, 55 insertions(+), 16 deletions(-) create mode 100644 libs/php-api-client-base/src/Auth/NoAuthAuthenticator.php create mode 100644 libs/php-api-client-base/tests/Auth/NoAuthAuthenticatorTest.php diff --git a/libs/php-api-client-base/README.md b/libs/php-api-client-base/README.md index 59e226dc6..2ab884759 100644 --- a/libs/php-api-client-base/README.md +++ b/libs/php-api-client-base/README.md @@ -19,14 +19,17 @@ composer require keboola/php-api-client-base ## What it provides - `ApiClient` — Guzzle wrapper with per-request auth, retry, logging, and - response-to-model mapping. + response-to-model mapping. Constructed as + `new ApiClient($baseUrl, $authenticator, $options)` — the authenticator is + **required**; pass `new NoAuthAuthenticator()` for unauthenticated clients. - `ApiClientOptions` — retries, timeouts, logger, error resolver (no auth — the authenticator is a first-class `ApiClient` constructor argument). - `Auth\RequestAuthenticatorInterface` + ready authenticators for the Keboola auth schemes: `StorageApiTokenAuthenticator` (`X-StorageApi-Token`), `ManageApiTokenAuthenticator` (`X-KBC-ManageApiToken`), `KeboolaServiceAccountAuthenticator` (projected SA token → - `X-Kubernetes-Authorization`). + `X-Kubernetes-Authorization`), `NoAuthAuthenticator` (explicit no-op for + unauthenticated calls). - `RetryDecider`, `Json`, `ResponseModelInterface`, `Exception\ClientException`. ## Building a Keboola service client diff --git a/libs/php-api-client-base/src/ApiClient.php b/libs/php-api-client-base/src/ApiClient.php index 5759eedbd..537bb7c54 100644 --- a/libs/php-api-client-base/src/ApiClient.php +++ b/libs/php-api-client-base/src/ApiClient.php @@ -28,8 +28,8 @@ class ApiClient * @param non-empty-string|null $baseUrl */ public function __construct( - ?string $baseUrl = null, - ?RequestAuthenticatorInterface $authenticator = null, + ?string $baseUrl, + RequestAuthenticatorInterface $authenticator, ?ApiClientOptions $options = null, ) { $options ??= new ApiClientOptions(); @@ -52,9 +52,7 @@ public function __construct( ))); } - if ($authenticator !== null) { - $stack->push(Middleware::mapRequest($authenticator)); - } + $stack->push(Middleware::mapRequest($authenticator)); $stack->push(Middleware::log( $options->logger, 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 @@ +sendRequest(new Request('GET', 'foo')); @@ -51,7 +52,7 @@ public function testAddsAuthHeaderPerRequest(): void public function testMapsResponseToModel(): void { $mock = new MockHandler([new Response(200, [], '{"name":"foo"}')]); - $client = new ApiClient('https://example.test', null, new ApiClientOptions( + $client = new ApiClient('https://example.test', new NoAuthAuthenticator(), new ApiClientOptions( requestHandler: HandlerStack::create($mock), )); $model = $client->sendRequestAndMapResponse(new Request('GET', 'foo'), DummyModel::class); @@ -63,7 +64,7 @@ public function testMapsResponseToModel(): void public function testMapsResponseToList(): void { $mock = new MockHandler([new Response(200, [], '[{"name":"a"},{"name":"b"}]')]); - $client = new ApiClient('https://example.test', null, new ApiClientOptions( + $client = new ApiClient('https://example.test', new NoAuthAuthenticator(), new ApiClientOptions( requestHandler: HandlerStack::create($mock), )); $models = $client->sendRequestAndMapResponse(new Request('GET', 'foo'), DummyModel::class, [], true); @@ -76,7 +77,7 @@ public function testMapsResponseToList(): void public function testRetriesOn5xxThenSucceeds(): void { $mock = new MockHandler([new Response(500), new Response(200, [], '{}')]); - $client = new ApiClient('https://example.test', null, new ApiClientOptions( + $client = new ApiClient('https://example.test', new NoAuthAuthenticator(), new ApiClientOptions( requestHandler: HandlerStack::create($mock), )); $client->sendRequest(new Request('GET', 'foo')); @@ -86,7 +87,7 @@ public function testRetriesOn5xxThenSucceeds(): void public function testThrowsClientExceptionWithDefaultMessageExtraction(): void { $mock = new MockHandler([new Response(400, [], '{"error":"bad input"}')]); - $client = new ApiClient('https://example.test', null, new ApiClientOptions( + $client = new ApiClient('https://example.test', new NoAuthAuthenticator(), new ApiClientOptions( requestHandler: HandlerStack::create($mock), )); $this->expectException(ClientException::class); @@ -98,7 +99,7 @@ public function testThrowsClientExceptionWithDefaultMessageExtraction(): void public function testUsesCustomErrorMessageResolver(): void { $mock = new MockHandler([new Response(409, [], '{"code":"CONFLICT","error":"already exists"}')]); - $client = new ApiClient('https://example.test', null, new ApiClientOptions( + $client = new ApiClient('https://example.test', new NoAuthAuthenticator(), new ApiClientOptions( requestHandler: HandlerStack::create($mock), errorMessageResolver: static function (string $body): string { /** @var array{code?: string, error?: string} $data */ @@ -140,7 +141,7 @@ public function __invoke(RequestInterface $request): RequestInterface public function testThrowsClientExceptionAfterRetriesExhausted(): void { $mock = new MockHandler([new Response(500), new Response(500)]); - $client = new ApiClient('https://example.test', null, new ApiClientOptions( + $client = new ApiClient('https://example.test', new NoAuthAuthenticator(), new ApiClientOptions( backoffMaxTries: 1, requestHandler: HandlerStack::create($mock), )); @@ -152,7 +153,7 @@ public function testThrowsClientExceptionAfterRetriesExhausted(): void public function testRetriesConfiguredStatusCodeThroughClient(): void { $mock = new MockHandler([new Response(429), new Response(200, [], '{}')]); - $client = new ApiClient('https://example.test', null, new ApiClientOptions( + $client = new ApiClient('https://example.test', new NoAuthAuthenticator(), new ApiClientOptions( backoffMaxTries: 2, retryableStatusCodes: [429], requestHandler: HandlerStack::create($mock), 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()); + } +} From 9fc407b7edfba3f8711e120d3f22f30164a81520 Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Tue, 9 Jun 2026 16:56:36 +0200 Subject: [PATCH 20/26] refactor(php-api-client-base): make errorMessageResolver and retryableStatusCodes ApiClient args These two knobs describe the service's API contract (error shape and which status codes are retryable), not per-call caller preferences. Move them from ApiClientOptions to explicit constructor parameters on ApiClient so that the service facade (not the library consumer) controls them. --- libs/php-api-client-base/README.md | 24 +++++++++++++++---- libs/php-api-client-base/src/ApiClient.php | 9 +++++-- .../src/ApiClientOptions.php | 5 ---- .../tests/ApiClientOptionsTest.php | 4 ---- .../tests/ApiClientTest.php | 17 +++++++------ 5 files changed, 36 insertions(+), 23 deletions(-) diff --git a/libs/php-api-client-base/README.md b/libs/php-api-client-base/README.md index 2ab884759..0ea512218 100644 --- a/libs/php-api-client-base/README.md +++ b/libs/php-api-client-base/README.md @@ -20,10 +20,14 @@ composer require keboola/php-api-client-base - `ApiClient` — Guzzle wrapper with per-request auth, retry, logging, and response-to-model mapping. Constructed as - `new ApiClient($baseUrl, $authenticator, $options)` — the authenticator is - **required**; pass `new NoAuthAuthenticator()` for unauthenticated clients. -- `ApiClientOptions` — retries, timeouts, logger, error resolver (no auth — the - authenticator is a first-class `ApiClient` constructor argument). + `new ApiClient($baseUrl, $authenticator, $options, errorMessageResolver: ..., retryableStatusCodes: [...])`. + The authenticator is **required**; pass `new NoAuthAuthenticator()` for + unauthenticated clients. `errorMessageResolver` and `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`), @@ -64,7 +68,17 @@ final class MyServiceClient StorageApiTokenAuthenticator $authenticator, ?ApiClientOptions $options = null, ) { - $this->apiClient = new ApiClient($baseUrl, $authenticator, $options); + $this->apiClient = new ApiClient( + $baseUrl, + $authenticator, + $options, + errorMessageResolver: static function (string $body, int $statusCode): ?string { + /** @var array{error?: string} $data */ + $data = json_decode($body, true) ?? []; + return $data['error'] ?? null; + }, + retryableStatusCodes: [429], + ); } public function createWidget(string $name): WidgetModel diff --git a/libs/php-api-client-base/src/ApiClient.php b/libs/php-api-client-base/src/ApiClient.php index 537bb7c54..afc4f9ac8 100644 --- a/libs/php-api-client-base/src/ApiClient.php +++ b/libs/php-api-client-base/src/ApiClient.php @@ -26,14 +26,19 @@ class ApiClient /** * @param non-empty-string|null $baseUrl + * @param list $retryableStatusCodes Non-5xx status codes to also retry (e.g. [429]). + * @param (Closure(string, int): ?string)|null $errorMessageResolver + * Maps a (responseBody, statusCode) to an error message, or null to fall back to the default. */ public function __construct( ?string $baseUrl, RequestAuthenticatorInterface $authenticator, ?ApiClientOptions $options = null, + ?Closure $errorMessageResolver = null, + array $retryableStatusCodes = [], ) { $options ??= new ApiClientOptions(); - $this->errorMessageResolver = $options->errorMessageResolver; + $this->errorMessageResolver = $errorMessageResolver; $stack = $options->requestHandler instanceof HandlerStack ? $options->requestHandler @@ -48,7 +53,7 @@ public function __construct( $stack->push(Middleware::retry(new RetryDecider( $options->backoffMaxTries, $options->logger, - $options->retryableStatusCodes, + $retryableStatusCodes, ))); } diff --git a/libs/php-api-client-base/src/ApiClientOptions.php b/libs/php-api-client-base/src/ApiClientOptions.php index 7a98f9573..97ba7115e 100644 --- a/libs/php-api-client-base/src/ApiClientOptions.php +++ b/libs/php-api-client-base/src/ApiClientOptions.php @@ -13,19 +13,14 @@ class ApiClientOptions { /** * @param int<0, max> $backoffMaxTries - * @param list $retryableStatusCodes Non-5xx status codes to also retry (e.g. [429]). - * @param (Closure(string, int): ?string)|null $errorMessageResolver - * Maps a (responseBody, statusCode) to an error message, or null to fall back to the default. */ public function __construct( public readonly string $userAgent = 'Keboola PHP API Client', public readonly int $backoffMaxTries = 5, - public readonly array $retryableStatusCodes = [], public readonly int $connectTimeout = 10, public readonly int $requestTimeout = 120, public readonly null|Closure|HandlerStack $requestHandler = null, public readonly LoggerInterface $logger = new NullLogger(), - public readonly ?Closure $errorMessageResolver = null, ) { } } diff --git a/libs/php-api-client-base/tests/ApiClientOptionsTest.php b/libs/php-api-client-base/tests/ApiClientOptionsTest.php index b8384fc9c..de0881a58 100644 --- a/libs/php-api-client-base/tests/ApiClientOptionsTest.php +++ b/libs/php-api-client-base/tests/ApiClientOptionsTest.php @@ -15,12 +15,10 @@ public function testDefaults(): void $options = new ApiClientOptions(); self::assertSame('Keboola PHP API Client', $options->userAgent); self::assertSame(5, $options->backoffMaxTries); - self::assertSame([], $options->retryableStatusCodes); self::assertSame(10, $options->connectTimeout); self::assertSame(120, $options->requestTimeout); self::assertNull($options->requestHandler); self::assertInstanceOf(NullLogger::class, $options->logger); - self::assertNull($options->errorMessageResolver); } public function testOverrides(): void @@ -28,10 +26,8 @@ public function testOverrides(): void $options = new ApiClientOptions( userAgent: 'My Client', backoffMaxTries: 2, - retryableStatusCodes: [429], ); self::assertSame('My Client', $options->userAgent); self::assertSame(2, $options->backoffMaxTries); - self::assertSame([429], $options->retryableStatusCodes); } } diff --git a/libs/php-api-client-base/tests/ApiClientTest.php b/libs/php-api-client-base/tests/ApiClientTest.php index 5f989f6dc..8a85d0322 100644 --- a/libs/php-api-client-base/tests/ApiClientTest.php +++ b/libs/php-api-client-base/tests/ApiClientTest.php @@ -99,14 +99,16 @@ public function testThrowsClientExceptionWithDefaultMessageExtraction(): void public function testUsesCustomErrorMessageResolver(): void { $mock = new MockHandler([new Response(409, [], '{"code":"CONFLICT","error":"already exists"}')]); - $client = new ApiClient('https://example.test', new NoAuthAuthenticator(), new ApiClientOptions( - requestHandler: HandlerStack::create($mock), + $client = new ApiClient( + 'https://example.test', + new NoAuthAuthenticator(), + new ApiClientOptions(requestHandler: HandlerStack::create($mock)), errorMessageResolver: static function (string $body): string { /** @var array{code?: string, error?: string} $data */ $data = json_decode($body, true); return ($data['code'] ?? '') . ': ' . ($data['error'] ?? ''); }, - )); + ); $this->expectException(ClientException::class); $this->expectExceptionMessage('CONFLICT: already exists'); $client->sendRequest(new Request('GET', 'foo')); @@ -153,11 +155,12 @@ public function testThrowsClientExceptionAfterRetriesExhausted(): void 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, + $client = new ApiClient( + 'https://example.test', + new NoAuthAuthenticator(), + new ApiClientOptions(backoffMaxTries: 2, requestHandler: HandlerStack::create($mock)), retryableStatusCodes: [429], - requestHandler: HandlerStack::create($mock), - )); + ); $client->sendRequest(new Request('GET', 'foo')); self::assertSame(0, $mock->count()); } From 861a1dda36dbbca193a8488ca50ca6082269c33b Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Wed, 10 Jun 2026 09:29:49 +0200 Subject: [PATCH 21/26] feat(php-api-client-base): make error-message resolver a typed ErrorMessageResolverInterface with DefaultErrorMessageResolver --- libs/php-api-client-base/README.md | 29 ++++++--- libs/php-api-client-base/src/ApiClient.php | 38 ++---------- .../src/DefaultErrorMessageResolver.php | 27 +++++++++ .../src/ErrorMessageResolverInterface.php | 14 +++++ .../src/ResponseModelInterface.php | 2 +- .../tests/ApiClientTest.php | 15 +++-- .../tests/DefaultErrorMessageResolverTest.php | 60 +++++++++++++++++++ 7 files changed, 137 insertions(+), 48 deletions(-) create mode 100644 libs/php-api-client-base/src/DefaultErrorMessageResolver.php create mode 100644 libs/php-api-client-base/src/ErrorMessageResolverInterface.php create mode 100644 libs/php-api-client-base/tests/DefaultErrorMessageResolverTest.php diff --git a/libs/php-api-client-base/README.md b/libs/php-api-client-base/README.md index 0ea512218..240b9ab74 100644 --- a/libs/php-api-client-base/README.md +++ b/libs/php-api-client-base/README.md @@ -22,9 +22,12 @@ composer require keboola/php-api-client-base response-to-model mapping. Constructed as `new ApiClient($baseUrl, $authenticator, $options, errorMessageResolver: ..., retryableStatusCodes: [...])`. The authenticator is **required**; pass `new NoAuthAuthenticator()` for - unauthenticated clients. `errorMessageResolver` and `retryableStatusCodes` are - `ApiClient` constructor arguments supplied by the service facade (they describe - the service's API contract, not caller preferences). + 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). @@ -34,7 +37,8 @@ composer require keboola/php-api-client-base `KeboolaServiceAccountAuthenticator` (projected SA token → `X-Kubernetes-Authorization`), `NoAuthAuthenticator` (explicit no-op for unauthenticated calls). -- `RetryDecider`, `Json`, `ResponseModelInterface`, `Exception\ClientException`. +- `ErrorMessageResolverInterface`, `DefaultErrorMessageResolver`, `RetryDecider`, + `Json`, `ResponseModelInterface`, `Exception\ClientException`. ## Building a Keboola service client @@ -45,6 +49,7 @@ 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; @@ -59,6 +64,16 @@ final class WidgetModel implements ResponseModelInterface } } +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; @@ -72,11 +87,7 @@ final class MyServiceClient $baseUrl, $authenticator, $options, - errorMessageResolver: static function (string $body, int $statusCode): ?string { - /** @var array{error?: string} $data */ - $data = json_decode($body, true) ?? []; - return $data['error'] ?? null; - }, + errorMessageResolver: new MyServiceErrorResolver(), retryableStatusCodes: [429], ); } diff --git a/libs/php-api-client-base/src/ApiClient.php b/libs/php-api-client-base/src/ApiClient.php index afc4f9ac8..78265bc58 100644 --- a/libs/php-api-client-base/src/ApiClient.php +++ b/libs/php-api-client-base/src/ApiClient.php @@ -4,7 +4,6 @@ namespace Keboola\ApiClientBase; -use Closure; use GuzzleHttp\Client as GuzzleClient; use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Exception\RequestException; @@ -21,24 +20,21 @@ class ApiClient { private readonly GuzzleClient $httpClient; - /** @var (Closure(string, int): ?string)|null */ - private readonly ?Closure $errorMessageResolver; + private readonly ErrorMessageResolverInterface $errorMessageResolver; /** * @param non-empty-string|null $baseUrl * @param list $retryableStatusCodes Non-5xx status codes to also retry (e.g. [429]). - * @param (Closure(string, int): ?string)|null $errorMessageResolver - * Maps a (responseBody, statusCode) to an error message, or null to fall back to the default. */ public function __construct( ?string $baseUrl, RequestAuthenticatorInterface $authenticator, ?ApiClientOptions $options = null, - ?Closure $errorMessageResolver = null, + ?ErrorMessageResolverInterface $errorMessageResolver = null, array $retryableStatusCodes = [], ) { $options ??= new ApiClientOptions(); - $this->errorMessageResolver = $errorMessageResolver; + $this->errorMessageResolver = $errorMessageResolver ?? new DefaultErrorMessageResolver(); $stack = $options->requestHandler instanceof HandlerStack ? $options->requestHandler @@ -138,31 +134,7 @@ private function processRequestException(RequestException $e): ClientException $statusCode = $response->getStatusCode(); $body = (string) $response->getBody(); - if ($this->errorMessageResolver !== null) { - $message = ($this->errorMessageResolver)($body, $statusCode); - if ($message !== null && $message !== '') { - return new ClientException($message, $statusCode, $e); - } - return new ClientException(trim($e->getMessage()), $statusCode, $e); - } - - return new ClientException($this->defaultErrorMessage($body) ?? trim($e->getMessage()), $statusCode, $e); - } - - private function defaultErrorMessage(string $body): ?string - { - try { - $data = Json::decodeArray($body); - } catch (JsonException) { - return null; - } - - foreach (['error', 'message'] as $key) { - if (isset($data[$key]) && is_string($data[$key]) && $data[$key] !== '') { - return $data[$key]; - } - } - - return null; + $message = ($this->errorMessageResolver)($body, $statusCode); + return new ClientException($message ?? trim($e->getMessage()), $statusCode, $e); } } 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 + * @param array $data */ public static function fromResponseData(array $data): static; } diff --git a/libs/php-api-client-base/tests/ApiClientTest.php b/libs/php-api-client-base/tests/ApiClientTest.php index 8a85d0322..0f0d4c66f 100644 --- a/libs/php-api-client-base/tests/ApiClientTest.php +++ b/libs/php-api-client-base/tests/ApiClientTest.php @@ -13,6 +13,7 @@ use Keboola\ApiClientBase\Auth\ManageApiTokenAuthenticator; use Keboola\ApiClientBase\Auth\NoAuthAuthenticator; use Keboola\ApiClientBase\Auth\RequestAuthenticatorInterface; +use Keboola\ApiClientBase\ErrorMessageResolverInterface; use Keboola\ApiClientBase\Exception\ClientException; use Keboola\ApiClientBase\Tests\Fixtures\DummyModel; use PHPUnit\Framework\TestCase; @@ -99,15 +100,19 @@ public function testThrowsClientExceptionWithDefaultMessageExtraction(): void 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: static function (string $body): string { - /** @var array{code?: string, error?: string} $data */ - $data = json_decode($body, true); - return ($data['code'] ?? '') . ': ' . ($data['error'] ?? ''); - }, + errorMessageResolver: $resolver, ); $this->expectException(ClientException::class); $this->expectExceptionMessage('CONFLICT: already exists'); 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); + } +} From 4203c54adff8f473a2a632209cd172a81496d20d Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Wed, 10 Jun 2026 09:45:07 +0200 Subject: [PATCH 22/26] refactor(php-api-client-base): make ResponseModelInterface::fromResponseData take an untyped array Decoded JSON is untyped; an explicit array<...> value type forces every model to re-annotate $data to dodge phpstan's cast-from-mixed rule. An untyped array lets implementers cast the values they need without per-method annotations. --- libs/php-api-client-base/src/ResponseModelInterface.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/libs/php-api-client-base/src/ResponseModelInterface.php b/libs/php-api-client-base/src/ResponseModelInterface.php index 8434b62fe..f3ff427ca 100644 --- a/libs/php-api-client-base/src/ResponseModelInterface.php +++ b/libs/php-api-client-base/src/ResponseModelInterface.php @@ -7,7 +7,10 @@ interface ResponseModelInterface { /** - * @param array $data + * Build the model from a decoded API response body. + * + * `$data` is deliberately an untyped array (decoded JSON is untyped): implementers + * cast/validate the values they need, without per-method type-narrowing annotations. */ public static function fromResponseData(array $data): static; } From 7d2ed51bc6beef718b6af63596687d72a24df37f Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Wed, 10 Jun 2026 10:44:54 +0200 Subject: [PATCH 23/26] feat(php-api-client-base): expose ApiClientOptions default constants (backoff, timeouts) --- libs/php-api-client-base/src/ApiClientOptions.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/libs/php-api-client-base/src/ApiClientOptions.php b/libs/php-api-client-base/src/ApiClientOptions.php index 97ba7115e..2a675cac8 100644 --- a/libs/php-api-client-base/src/ApiClientOptions.php +++ b/libs/php-api-client-base/src/ApiClientOptions.php @@ -11,14 +11,18 @@ class ApiClientOptions { + public const DEFAULT_BACKOFF_MAX_TRIES = 5; + public const DEFAULT_CONNECT_TIMEOUT = 10; + public const DEFAULT_REQUEST_TIMEOUT = 120; + /** * @param int<0, max> $backoffMaxTries */ public function __construct( public readonly string $userAgent = 'Keboola PHP API Client', - public readonly int $backoffMaxTries = 5, - public readonly int $connectTimeout = 10, - public readonly int $requestTimeout = 120, + 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 = new NullLogger(), ) { From fc81dace1c5b1d2918edf2b76179370099b2ec60 Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Wed, 10 Jun 2026 10:49:23 +0200 Subject: [PATCH 24/26] feat(php-api-client-base): make ApiClientOptions logger nullable; coalesce to NullLogger in ApiClient --- libs/php-api-client-base/src/ApiClient.php | 6 ++++-- libs/php-api-client-base/src/ApiClientOptions.php | 3 +-- libs/php-api-client-base/tests/ApiClientOptionsTest.php | 3 +-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/libs/php-api-client-base/src/ApiClient.php b/libs/php-api-client-base/src/ApiClient.php index 78265bc58..3914660b1 100644 --- a/libs/php-api-client-base/src/ApiClient.php +++ b/libs/php-api-client-base/src/ApiClient.php @@ -15,6 +15,7 @@ use Keboola\ApiClientBase\Exception\ClientException; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; +use Psr\Log\NullLogger; use Throwable; class ApiClient @@ -35,6 +36,7 @@ public function __construct( ) { $options ??= new ApiClientOptions(); $this->errorMessageResolver = $errorMessageResolver ?? new DefaultErrorMessageResolver(); + $logger = $options->logger ?? new NullLogger(); $stack = $options->requestHandler instanceof HandlerStack ? $options->requestHandler @@ -48,7 +50,7 @@ public function __construct( if ($options->backoffMaxTries > 0) { $stack->push(Middleware::retry(new RetryDecider( $options->backoffMaxTries, - $options->logger, + $logger, $retryableStatusCodes, ))); } @@ -56,7 +58,7 @@ public function __construct( $stack->push(Middleware::mapRequest($authenticator)); $stack->push(Middleware::log( - $options->logger, + $logger, new MessageFormatter('{method} {uri} : {code} {res_header_Content-Length}'), )); diff --git a/libs/php-api-client-base/src/ApiClientOptions.php b/libs/php-api-client-base/src/ApiClientOptions.php index 2a675cac8..5232e2b38 100644 --- a/libs/php-api-client-base/src/ApiClientOptions.php +++ b/libs/php-api-client-base/src/ApiClientOptions.php @@ -7,7 +7,6 @@ use Closure; use GuzzleHttp\HandlerStack; use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; class ApiClientOptions { @@ -24,7 +23,7 @@ public function __construct( 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 = new NullLogger(), + public readonly ?LoggerInterface $logger = null, ) { } } diff --git a/libs/php-api-client-base/tests/ApiClientOptionsTest.php b/libs/php-api-client-base/tests/ApiClientOptionsTest.php index de0881a58..60a61403d 100644 --- a/libs/php-api-client-base/tests/ApiClientOptionsTest.php +++ b/libs/php-api-client-base/tests/ApiClientOptionsTest.php @@ -6,7 +6,6 @@ use Keboola\ApiClientBase\ApiClientOptions; use PHPUnit\Framework\TestCase; -use Psr\Log\NullLogger; class ApiClientOptionsTest extends TestCase { @@ -18,7 +17,7 @@ public function testDefaults(): void self::assertSame(10, $options->connectTimeout); self::assertSame(120, $options->requestTimeout); self::assertNull($options->requestHandler); - self::assertInstanceOf(NullLogger::class, $options->logger); + self::assertNull($options->logger); } public function testOverrides(): void From 344cb346e19adb05d0c1a1a9ce524c369ee7a5cc Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Thu, 11 Jun 2026 14:33:31 +0200 Subject: [PATCH 25/26] ci(php-api-client-base): add dev-php-api-client-base docker-compose service The test pipeline runs 'docker compose run dev-php-api-client-base', which needs a matching compose service; every other lib has a dev- block but the new base lib's was missing (CI failed with 'no such service'). --- docker-compose.yml | 5 +++++ 1 file changed, 5 insertions(+) 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 From cb1352cd6aac94dd2e6019fb6d2e5d5adab6941f Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Mon, 15 Jun 2026 09:28:56 +0200 Subject: [PATCH 26/26] fix(php-api-client-base): route authenticator failures through retry + ClientException Review on #513: an authenticator that throws (e.g. a projected SA token unreadable during rotation) escaped the handler stack as a raw RuntimeException, bypassing both retry and error handling. Auth is now a middleware that converts the throw into a rejected promise (so RetryDecider sees it) and doSendRequest maps it to ClientException. Also fix the off-by-one in the RetryDecider retry log (1-based), and drop the Content-Type note from the README. --- libs/php-api-client-base/README.md | 3 +- libs/php-api-client-base/src/ApiClient.php | 36 ++++++++++++++++++- libs/php-api-client-base/src/RetryDecider.php | 2 +- .../tests/ApiClientTest.php | 34 ++++++++++++++++++ 4 files changed, 71 insertions(+), 4 deletions(-) diff --git a/libs/php-api-client-base/README.md b/libs/php-api-client-base/README.md index 240b9ab74..afabe5ff0 100644 --- a/libs/php-api-client-base/README.md +++ b/libs/php-api-client-base/README.md @@ -112,8 +112,7 @@ $client = new MyServiceClient( Pick the authenticator matching the service's scheme, or implement `RequestAuthenticatorInterface` for a service-specific scheme (e.g. azure's -OAuth). `Content-Type` is set per request on calls with a body; the only Guzzle -default header is `User-Agent` (set via `ApiClientOptions::$userAgent`). +OAuth). ## License diff --git a/libs/php-api-client-base/src/ApiClient.php b/libs/php-api-client-base/src/ApiClient.php index 3914660b1..9eba0e5d0 100644 --- a/libs/php-api-client-base/src/ApiClient.php +++ b/libs/php-api-client-base/src/ApiClient.php @@ -10,6 +10,8 @@ use GuzzleHttp\HandlerStack; use GuzzleHttp\MessageFormatter; use GuzzleHttp\Middleware; +use GuzzleHttp\Promise\Create; +use GuzzleHttp\Promise\PromiseInterface; use JsonException; use Keboola\ApiClientBase\Auth\RequestAuthenticatorInterface; use Keboola\ApiClientBase\Exception\ClientException; @@ -55,7 +57,35 @@ public function __construct( ))); } - $stack->push(Middleware::mapRequest($authenticator)); + // 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, @@ -123,6 +153,10 @@ private function doSendRequest(RequestInterface $request, array $options = []): 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); } } diff --git a/libs/php-api-client-base/src/RetryDecider.php b/libs/php-api-client-base/src/RetryDecider.php index 08428fd00..df9a5a6aa 100644 --- a/libs/php-api-client-base/src/RetryDecider.php +++ b/libs/php-api-client-base/src/RetryDecider.php @@ -58,7 +58,7 @@ private function logAndRetry(?int $code, mixed $error, int $retries): bool $code !== null => 'HTTP ' . $code, default => 'unknown', }, - $retries, + $retries + 1, $this->maxRetries, )); diff --git a/libs/php-api-client-base/tests/ApiClientTest.php b/libs/php-api-client-base/tests/ApiClientTest.php index 0f0d4c66f..4cedbd52d 100644 --- a/libs/php-api-client-base/tests/ApiClientTest.php +++ b/libs/php-api-client-base/tests/ApiClientTest.php @@ -18,6 +18,7 @@ use Keboola\ApiClientBase\Tests\Fixtures\DummyModel; use PHPUnit\Framework\TestCase; use Psr\Http\Message\RequestInterface; +use RuntimeException; class ApiClientTest extends TestCase { @@ -169,4 +170,37 @@ public function testRetriesConfiguredStatusCodeThroughClient(): void $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); + } }