diff --git a/libs/php-api-client-base/src/ApiClient.php b/libs/php-api-client-base/src/ApiClient.php index 9640da2a9..8db6e3a14 100644 --- a/libs/php-api-client-base/src/ApiClient.php +++ b/libs/php-api-client-base/src/ApiClient.php @@ -113,9 +113,22 @@ static function (callable $handler) use ($authenticator): callable { ]); } - public function sendRequest(RequestInterface $request): void + /** + * @param array $options + */ + public function sendRequest(RequestInterface $request, array $options = []): ResponseInterface { - $this->doSendRequest($request); + try { + return $this->httpClient->send($request, $options); + } catch (RequestException $e) { + throw $this->processRequestException($e); + } catch (GuzzleException $e) { + throw new $this->exceptionClass($e->getMessage(), 0, $e, null, null); + } 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 $this->exceptionClass(trim($e->getMessage()), 0, $e, null, null); + } } /** @@ -130,7 +143,7 @@ public function sendRequestAndMapResponse( array $options = [], bool $isList = false, ) { - $response = $this->doSendRequest($request, $options); + $response = $this->sendRequest($request, $options); $body = $response->getBody()->getContents(); try { @@ -165,24 +178,6 @@ public function sendRequestAndMapResponse( } } - /** - * @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 $this->exceptionClass($e->getMessage(), 0, $e, null, null); - } 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 $this->exceptionClass(trim($e->getMessage()), 0, $e, null, null); - } - } - private function processRequestException(RequestException $e): ClientException { $response = $e->getResponse(); diff --git a/libs/php-api-client-base/tests/ApiClientTest.php b/libs/php-api-client-base/tests/ApiClientTest.php index 08731ddcb..9ee1c0ce6 100644 --- a/libs/php-api-client-base/tests/ApiClientTest.php +++ b/libs/php-api-client-base/tests/ApiClientTest.php @@ -53,6 +53,19 @@ public function testAddsAuthHeaderPerRequest(): void self::assertSame('secret-token', $last->getHeaderLine('X-KBC-ManageApiToken')); } + public function testSendRequestReturnsResponse(): void + { + $mock = new MockHandler([new Response(201, [], '{"hello":"world"}')]); + $client = new ApiClient('https://example.test', new NoAuthAuthenticator(), new ApiClientOptions( + requestHandler: HandlerStack::create($mock), + )); + + $response = $client->sendRequest(new Request('GET', 'foo')); + + self::assertSame(201, $response->getStatusCode()); + self::assertSame('{"hello":"world"}', (string) $response->getBody()); + } + public function testMapsResponseToModel(): void { $mock = new MockHandler([new Response(200, [], '{"name":"foo"}')]); diff --git a/libs/sync-actions-api-php-client/README.md b/libs/sync-actions-api-php-client/README.md index 69ec8fb8d..ad8e68b2b 100644 --- a/libs/sync-actions-api-php-client/README.md +++ b/libs/sync-actions-api-php-client/README.md @@ -1,30 +1,38 @@ # Sync Actions PHP Client -PHP client for the Job Queue API ([API docs](https://app.swaggerhub.com/apis-docs/keboola/job-queue-api/1.0.0)). +PHP client for the Keboola sync actions API, built on top of +[`keboola/php-api-client-base`](../php-api-client-base). ## Usage ```bash -composer require keboola/sync-actions-api-php-client +composer require keboola/sync-actions-client ``` ```php -use Keboola\SyncActionsClient\Client; -use Keboola\SyncActionsClient\JobData; -use Psr\Log\NullLogger; +use Keboola\SyncActionsClient\ActionData; +use Keboola\SyncActionsClient\SyncActionsApiClient; -$client = new Client( - 'http://sync-actions.keboola.com/', - 'xxx-xxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' +$client = new SyncActionsApiClient( + 'https://sync-actions.keboola.com/', + 'xxx-xxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', ); -$result = $client->createJob(new JobData( + +// Invoke a component action; $response->data is a stdClass with the raw action payload. +$response = $client->callAction(new ActionData( 'keboola.ex-db-snowflake', - 'getTables' + 'getTables', )); -var_dump($result); +var_dump($response->data); + +// List the actions a component exposes. +$actions = $client->getActions('keboola.ex-db-snowflake'); +var_dump($actions->actions); ``` +Failures throw `Keboola\SyncActionsClient\Exception\SyncActionsClientException`. + ## Development - + Clone this repository and init the workspace with following command: ``` @@ -46,7 +54,7 @@ Run the test suite using this command: ``` docker-compose run --rm dev composer tests ``` - + # Integration -For information about deployment and integration with KBC, please refer to the [deployment section of developers documentation](https://developers.keboola.com/extend/component/deployment/) +For information about deployment and integration with KBC, please refer to the [deployment section of developers documentation](https://developers.keboola.com/extend/component/deployment/) diff --git a/libs/sync-actions-api-php-client/composer.json b/libs/sync-actions-api-php-client/composer.json index 4eb5da6c9..b0e607e5a 100644 --- a/libs/sync-actions-api-php-client/composer.json +++ b/libs/sync-actions-api-php-client/composer.json @@ -16,21 +16,27 @@ ], "type": "library", "license": "MIT", + "repositories": { + "libs": { + "type": "path", + "url": "../../libs/*" + } + }, "require": { "php": "^8.4", "guzzlehttp/guzzle": "^7.8", + "keboola/php-api-client-base": "*@dev", "psr/log": "^3.0", - "symfony/config": "^7.3", - "symfony/validator": "^7.3" + "webmozart/assert": "^1.11" }, "require-dev": { "keboola/coding-standard": ">=16.0", "keboola/php-temp": "^2.0", + "monolog/monolog": "^3.9", "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^12.3", - "symfony/process": "^7.3", "symfony/dotenv": "^7.3", - "monolog/monolog": "^3.9" + "symfony/process": "^7.3" }, "autoload": { "psr-4": { diff --git a/libs/sync-actions-api-php-client/phpstan.neon b/libs/sync-actions-api-php-client/phpstan.neon index e748540b3..6f1077a6b 100644 --- a/libs/sync-actions-api-php-client/phpstan.neon +++ b/libs/sync-actions-api-php-client/phpstan.neon @@ -1,7 +1,15 @@ parameters: level: max + # The client validates non-empty-string params at runtime (Assert::stringNotEmpty) for + # callers who bypass the type hints, so don't treat those PHPDoc types as certain. + treatPhpDocTypesAsCertain: false paths: - src - tests ignoreErrors: - - identifier: missingType.iterableValue \ No newline at end of file + - identifier: missingType.iterableValue + # Guzzle's Middleware::history() types its by-ref container as array|ArrayAccess, + # so PHPStan cannot verify the (correct) @param-out type of the test request handler. + - + identifier: paramOut.type + path: tests/ApiClientTestTrait.php \ No newline at end of file diff --git a/libs/sync-actions-api-php-client/src/ApiClientConfiguration.php b/libs/sync-actions-api-php-client/src/ApiClientConfiguration.php deleted file mode 100644 index c951b4498..000000000 --- a/libs/sync-actions-api-php-client/src/ApiClientConfiguration.php +++ /dev/null @@ -1,25 +0,0 @@ - $backoffMaxTries - */ - public function __construct( - public ?string $userAgent = null, - public int $backoffMaxTries = self::DEFAULT_BACKOFF_RETRIES, - public null|Closure $requestHandler = null, - public LoggerInterface $logger = new NullLogger(), - ) { - } -} diff --git a/libs/sync-actions-api-php-client/src/Client.php b/libs/sync-actions-api-php-client/src/Client.php deleted file mode 100644 index 4c7da1b2b..000000000 --- a/libs/sync-actions-api-php-client/src/Client.php +++ /dev/null @@ -1,186 +0,0 @@ -validate($baseUrl, [new Url()]); - $errors->addAll( - $validator->validate($token, [new NotBlank()]), - ); - - $errors->addAll($validator->validate( - $configuration->backoffMaxTries, - [new Range(['min' => 0, 'max' => 100])], - )); - - if ($errors->count() !== 0) { - $messages = ''; - /** @var ConstraintViolationInterface $error */ - foreach ($errors as $error) { - assert(is_scalar($error->getInvalidValue())); - $messages .= 'Value "' . $error->getInvalidValue() . '" is invalid: ' . $error->getMessage() . "\n"; - } - throw new SyncActionsClientException('Invalid parameters when creating client: ' . $messages); - } - - $this->requestHandlerStack = HandlerStack::create($configuration->requestHandler); - $this->requestHandlerStack->push(Middleware::mapRequest(new StorageApiTokenAuthenticator($token))); - - if ($configuration->backoffMaxTries > 0) { - $this->requestHandlerStack->push(Middleware::retry(new RetryDecider( - $configuration->backoffMaxTries, - $configuration->logger, - ))); - } - - $this->requestHandlerStack->push(Middleware::log($configuration->logger, new MessageFormatter( - '{hostname} {req_header_User-Agent} - [{ts}] "{method} {resource} {protocol}/{version}"' . - ' {code} {res_header_Content-Length}', - ))); - - $userAgent = self::DEFAULT_USER_AGENT; - if ($configuration->userAgent) { - $userAgent .= ' - ' . $configuration->userAgent; - } - - $this->httpClient = new GuzzleClient([ - 'base_uri' => $baseUrl, - 'handler' => $this->requestHandlerStack, - 'headers' => [ - 'User-Agent' => $userAgent, - 'Content-Type' => 'application/json', - ], - 'connect_timeout' => 10, - 'timeout' => 120, - ]); - } - - /** - * @template TResponseClass of ResponseModelInterface - * @param class-string $responseClass - * @return TResponseClass - */ - public function sendRequestAndMapResponse( - RequestInterface $request, - string $responseClass, - array $options = [], - ) { - $response = $this->doSendRequest($request, $options); - $responseData = $response->getBody()->getContents(); - try { - $data = (object) json_decode( - $responseData, - false, - flags: JSON_THROW_ON_ERROR, - ); - } catch (JsonException $e) { - throw new ClientException( - 'Response is not a valid JSON: ' . $e->getMessage() . ' ' . $responseData, - $e->getCode(), - $e, - ); - } - - try { - return $responseClass::fromResponseData($data); - } catch (Throwable $e) { - throw new ClientException('Failed to parse response: ' . $e->getMessage(), $e->getCode(), $e); - } - } - - private function doSendRequest(RequestInterface $request, array $options = []): ResponseInterface - { - try { - return $this->httpClient->send($request, $options); - } catch (RequestException $e) { - throw $this->processRequestException($e) ?? new ClientException($e->getMessage(), $e->getCode(), $e); - } catch (GuzzleException $e) { - throw new ClientException($e->getMessage(), $e->getCode(), $e); - } - } - - private function processRequestException(RequestException $e): ?ClientException - { - $response = $e->getResponse(); - if ($response === null) { - return null; - } - - try { - $responseData = $response->getBody()->getContents(); - $data = json_decode($responseData, false, flags: JSON_THROW_ON_ERROR); - if (!is_object($data)) { - throw new ClientException( - 'Response is not a valid error response: ' . $responseData, - ); - } - } catch (JsonException) { - // throw the original one, we don't care about e2 - return new ClientException(trim($e->getMessage()), $response->getStatusCode(), $e); - } - - if (empty($data->error) || empty($data->code) || !is_scalar($data->error) || !is_scalar($data->code)) { - return null; - } - - return new ClientException( - trim($data->code . ': ' . $data->error), - $response->getStatusCode(), - $e, - ); - } - - public function callAction(ActionData $actionData): ActionResponse - { - try { - $jobDataJson = json_encode($actionData->getArray(), JSON_THROW_ON_ERROR); - $request = new Request('POST', 'actions', [], $jobDataJson); - } catch (JsonException $e) { - throw new ClientException('Invalid job data: ' . $e->getMessage(), $e->getCode(), $e); - } - return $this->sendRequestAndMapResponse($request, ActionResponse::class); - } - - public function getActions(string $componentId): ListActionsResponse - { - $request = new Request('GET', sprintf('actions?componentId=%s', $componentId)); - return $this->sendRequestAndMapResponse($request, ListActionsResponse::class); - } -} diff --git a/libs/sync-actions-api-php-client/src/Exception/ClientException.php b/libs/sync-actions-api-php-client/src/Exception/ClientException.php deleted file mode 100644 index cf634c126..000000000 --- a/libs/sync-actions-api-php-client/src/Exception/ClientException.php +++ /dev/null @@ -1,11 +0,0 @@ -data->whatever, preserving the object/array distinction the API returns. + */ +final readonly class ActionResponse { public function __construct( public stdClass $data, ) { } - - public static function fromResponseData(stdClass $data): static - { - return new self($data); - } } diff --git a/libs/sync-actions-api-php-client/src/Model/ListActionsResponse.php b/libs/sync-actions-api-php-client/src/Model/ListActionsResponse.php index 3791d89f8..7bd7acd9f 100644 --- a/libs/sync-actions-api-php-client/src/Model/ListActionsResponse.php +++ b/libs/sync-actions-api-php-client/src/Model/ListActionsResponse.php @@ -4,7 +4,8 @@ namespace Keboola\SyncActionsClient\Model; -use stdClass; +use InvalidArgumentException; +use Keboola\ApiClientBase\ResponseModelInterface; final readonly class ListActionsResponse implements ResponseModelInterface { @@ -16,11 +17,15 @@ public function __construct( ) { } - public static function fromResponseData(stdClass $data): static + public static function fromResponseData(array $data): static { - return new self( - // @phpstan-ignore-next-line - $data->actions, - ); + if (!isset($data['actions']) || !is_array($data['actions'])) { + throw new InvalidArgumentException('Response does not contain an "actions" array'); + } + + /** @var array $actions */ + $actions = $data['actions']; + + return new self($actions); } } diff --git a/libs/sync-actions-api-php-client/src/Model/ResponseModelInterface.php b/libs/sync-actions-api-php-client/src/Model/ResponseModelInterface.php deleted file mode 100644 index c89982189..000000000 --- a/libs/sync-actions-api-php-client/src/Model/ResponseModelInterface.php +++ /dev/null @@ -1,12 +0,0 @@ -= $this->maxRetries) { - return false; - } - - $code = null; - if ($response) { - $code = $response->getStatusCode(); - } elseif ($error && $error instanceof Throwable) { - $code = $error->getCode(); - } - - if ($code >= 400 && $code < 500) { - return false; - } - - if ($error || $code >= 500) { - $this->logger->warning( - sprintf( - 'Request failed (%s), retrying (%s of %s)', - match (true) { - $error instanceof Throwable => $error->getMessage(), - is_scalar($error) => $error, - $response !== null => $response->getBody()->getContents(), - default => 'No error', - }, - $retries, - $this->maxRetries, - ), - ); - return true; - } - - return false; - } -} diff --git a/libs/sync-actions-api-php-client/src/StorageApiTokenAuthenticator.php b/libs/sync-actions-api-php-client/src/StorageApiTokenAuthenticator.php deleted file mode 100644 index 13518d276..000000000 --- a/libs/sync-actions-api-php-client/src/StorageApiTokenAuthenticator.php +++ /dev/null @@ -1,20 +0,0 @@ -withHeader('X-StorageApi-Token', $this->value); - } -} diff --git a/libs/sync-actions-api-php-client/src/SyncActionsApiClient.php b/libs/sync-actions-api-php-client/src/SyncActionsApiClient.php new file mode 100644 index 000000000..219b3413e --- /dev/null +++ b/libs/sync-actions-api-php-client/src/SyncActionsApiClient.php @@ -0,0 +1,108 @@ + $backoffMaxTries + */ + public function __construct( + string $baseUrl, + string $token, + ?LoggerInterface $logger = null, + int $backoffMaxTries = self::DEFAULT_BACKOFF_MAX_TRIES, + int $connectTimeout = ApiClientOptions::DEFAULT_CONNECT_TIMEOUT, + int $requestTimeout = ApiClientOptions::DEFAULT_REQUEST_TIMEOUT, + ?string $userAgent = null, + null|Closure|HandlerStack $requestHandler = null, + ) { + Assert::stringNotEmpty($baseUrl, 'Base URL must be a non-empty string'); + + $fullUserAgent = self::FALLBACK_USER_AGENT; + if ($userAgent !== null && $userAgent !== '') { + $fullUserAgent .= ' - ' . $userAgent; + } + + $this->apiClient = new ApiClient( + $baseUrl, + new StorageApiTokenAuthenticator($token), + new ApiClientOptions( + userAgent: $fullUserAgent, + backoffMaxTries: $backoffMaxTries, + connectTimeout: $connectTimeout, + requestTimeout: $requestTimeout, + requestHandler: $requestHandler, + logger: $logger, + ), + errorMessageResolver: new SyncActionsErrorMessageResolver(), + exceptionClass: SyncActionsClientException::class, + ); + } + + public function callAction(ActionData $actionData): ActionResponse + { + try { + $body = Json::encodeArray($actionData->getArray()); + } catch (JsonException $e) { + throw new SyncActionsClientException('Invalid job data: ' . $e->getMessage(), $e->getCode(), $e); + } + + $response = $this->apiClient->sendRequest( + new Request('POST', 'actions', ['Content-Type' => 'application/json'], $body), + ); + + // Decode straight to stdClass: a sync action returns an arbitrary, component-defined + // payload, and the base client's array decode would collapse empty/integer-keyed + // objects ({} -> [], {"0":..} -> [..]). Callers navigate the result as $response->data->x. + $responseBody = (string) $response->getBody(); + try { + $decoded = json_decode($responseBody, false, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new SyncActionsClientException( + 'Response is not valid JSON: ' . $e->getMessage(), + 0, + $e, + $response->getStatusCode(), + $responseBody, + ); + } + + /** @var stdClass $data */ + $data = (object) $decoded; + + return new ActionResponse($data); + } + + public function getActions(string $componentId): ListActionsResponse + { + return $this->apiClient->sendRequestAndMapResponse( + new Request('GET', sprintf('actions?componentId=%s', $componentId)), + ListActionsResponse::class, + ); + } +} diff --git a/libs/sync-actions-api-php-client/src/SyncActionsErrorMessageResolver.php b/libs/sync-actions-api-php-client/src/SyncActionsErrorMessageResolver.php new file mode 100644 index 000000000..f455e2efa --- /dev/null +++ b/libs/sync-actions-api-php-client/src/SyncActionsErrorMessageResolver.php @@ -0,0 +1,30 @@ + $responses + * @param-out list $requestsHistory + */ + private static function createRequestHandler(?array &$requestsHistory, array $responses): HandlerStack + { + $requestsHistory = []; + + // Deliberately NOT HandlerStack::create(): that would add Guzzle's httpErrors + // middleware inside this handler, turning 4xx/5xx responses into exceptions + // before the base client's retry middleware sees them. Using a bare stack lets + // the base client's own httpErrors (outer to retry) be the only one, so the retry + // decider sees real status codes — matching production behaviour. + $stack = new HandlerStack(new MockHandler($responses)); + $stack->push(Middleware::history($requestsHistory)); + /** @var list $requestsHistory */ + + return $stack; + } + + /** + * @param array $headers + */ + private static function assertRequestEquals( + string $method, + string $uri, + array $headers, + ?string $body, + Request $request, + ): void { + self::assertSame($method, $request->getMethod()); + self::assertSame($uri, $request->getUri()->__toString()); + + foreach ($headers as $headerName => $headerValue) { + self::assertSame($headerValue, $request->getHeaderLine($headerName)); + } + + self::assertSame($body ?? '', $request->getBody()->getContents()); + } +} diff --git a/libs/sync-actions-api-php-client/tests/ClientFunctionalTest.php b/libs/sync-actions-api-php-client/tests/ClientFunctionalTest.php index 4ecd1ee57..d71db46a1 100644 --- a/libs/sync-actions-api-php-client/tests/ClientFunctionalTest.php +++ b/libs/sync-actions-api-php-client/tests/ClientFunctionalTest.php @@ -5,9 +5,10 @@ namespace Keboola\SyncActionsClient\Tests; use Keboola\SyncActionsClient\ActionData; -use Keboola\SyncActionsClient\Client; -use Keboola\SyncActionsClient\Exception\ClientException; +use Keboola\SyncActionsClient\Exception\SyncActionsClientException; +use Keboola\SyncActionsClient\SyncActionsApiClient; use PHPUnit\Framework\TestCase; +use Webmozart\Assert\Assert; class ClientFunctionalTest extends TestCase { @@ -55,7 +56,7 @@ public function testInvalidComponent(): void { $client = $this->getClient(); - $this->expectException(ClientException::class); + $this->expectException(SyncActionsClientException::class); $this->expectExceptionMessage('Component "non-existent-component" not found'); $client->callAction(new ActionData('non-existent-component', 'non-existent-action', [])); } @@ -64,7 +65,7 @@ public function testInvalidAction(): void { $client = $this->getClient(); - $this->expectException(ClientException::class); + $this->expectException(SyncActionsClientException::class); $this->expectExceptionMessage(sprintf( 'Action "non-existent-action" not defined for component "%s".', self::COMPONENT_ID, @@ -72,11 +73,12 @@ public function testInvalidAction(): void $client->callAction(new ActionData(self::COMPONENT_ID, 'non-existent-action', [])); } - private function getClient(): Client + private function getClient(): SyncActionsApiClient { - return new Client( - sprintf('https://sync-actions.%s', getenv('HOSTNAME_SUFFIX')), - (string) getenv('STORAGE_API_TOKEN'), - ); + $baseUrl = sprintf('https://sync-actions.%s', getenv('HOSTNAME_SUFFIX')); + $token = (string) getenv('STORAGE_API_TOKEN'); + Assert::stringNotEmpty($token); + + return new SyncActionsApiClient($baseUrl, $token); } } diff --git a/libs/sync-actions-api-php-client/tests/ClientTest.php b/libs/sync-actions-api-php-client/tests/ClientTest.php deleted file mode 100644 index b47818001..000000000 --- a/libs/sync-actions-api-php-client/tests/ClientTest.php +++ /dev/null @@ -1,416 +0,0 @@ -expectException(ClientException::class); - $this->expectExceptionMessage( - 'Invalid parameters when creating client: Value "-1" is invalid: This value should be between 0 and 100.', - ); - new Client( - 'http://example.com/', - 'testToken', - // @phpstan-ignore-next-line - new ApiClientConfiguration(backoffMaxTries: -1), - ); - } - - public function testCreateClientTooHighBackoff(): void - { - $this->expectException(ClientException::class); - $this->expectExceptionMessage( - 'Invalid parameters when creating client: Value "101" is invalid: This value should be between 0 and 100.', - ); - new Client( - 'http://example.com/', - 'testToken', - new ApiClientConfiguration(backoffMaxTries: 101), - ); - } - - public function testCreateClientInvalidToken(): void - { - $this->expectException(ClientException::class); - $this->expectExceptionMessage( - 'Invalid parameters when creating client: Value "" is invalid: This value should not be blank.', - ); - new Client('http://example.com/', ''); - } - - public function testCreateClientInvalidUrl(): void - { - $this->expectException(ClientException::class); - $this->expectExceptionMessage( - 'Invalid parameters when creating client: Value "invalid url" is invalid: This value is not a valid URL.', - ); - new Client('invalid url', 'testToken'); - } - - public function testCreateClientMultipleErrors(): void - { - $this->expectException(ClientException::class); - $this->expectExceptionMessage( - 'Invalid parameters when creating client: Value "invalid url" is invalid: This value is not a valid URL.' - . "\n" . 'Value "" is invalid: This value should not be blank.' . "\n", - ); - new Client('invalid url', ''); - } - - public function testClientRequestResponse(): void - { - $mock = new MockHandler([ - new Response( - 201, - ['Content-Type' => 'application/json'], - '{ - "foo": "bar" - }', - ), - ]); - // Add the history middleware to the handler stack. - $requestHistory = []; - $history = Middleware::history($requestHistory); - $stack = HandlerStack::create($mock); - $stack->push($history); - $client = $this->getClient(new ApiClientConfiguration(requestHandler: $stack(...))); - - $job = $client->callAction(new ActionData('keboola.runner-config-test', '123')); - - self::assertEquals('bar', $job->data->foo); - /** @var array $requestHistory */ - self::assertCount(1, $requestHistory); - $request = $requestHistory[0]['request']; - self::assertEquals('http://example.com/actions', $request->getUri()->__toString()); - self::assertEquals('POST', $request->getMethod()); - self::assertEquals('testToken', $request->getHeader('X-StorageApi-Token')[0]); - self::assertEquals('Sync Actions PHP Client', $request->getHeader('User-Agent')[0]); - self::assertEquals('application/json', $request->getHeader('Content-type')[0]); - } - - public function testInvalidResponse(): void - { - $mock = new MockHandler([ - new Response( - 200, - ['Content-Type' => 'application/json'], - 'invalid json', - ), - ]); - // Add the history middleware to the handler stack. - $requestHistory = []; - $history = Middleware::history($requestHistory); - $requestHandler = HandlerStack::create($mock); - $requestHandler->push($history); - - $client = $this->getClient(new ApiClientConfiguration(requestHandler: $requestHandler(...))); - - $res = fopen(sys_get_temp_dir() . '/touch', 'w'); - $this->expectException(ClientException::class); - $this->expectExceptionMessage('Invalid job data: Type is not supported'); - $client->callAction(new ActionData('keboola.runner-config-test', '123', ['foo' => $res])); - } - - public function testClientExceptionIsThrownWhenGuzzleRequestErrorOccurs(): void - { - $requestHandler = MockHandler::createWithMiddleware([ - new Response( - 500, - ['Content-Type' => 'text/plain'], - 'Error on server', - ), - ]); - - $client = $this->getClient(new ApiClientConfiguration( - backoffMaxTries: 0, - requestHandler: $requestHandler(...), - )); - - $this->expectException(ClientException::class); - $this->expectExceptionMessage('Error on server'); - $client->callAction(new ActionData('keboola.runner-config-test', '123')); - } - - public function testClientExceptionIsThrownForResponseWithInvalidJson(): void - { - $requestHandler = MockHandler::createWithMiddleware([ - new Response( - 200, - ['Content-Type' => 'application/json'], - '{not a valid json]', - ), - ]); - - $client = $this->getClient(new ApiClientConfiguration(requestHandler: $requestHandler(...))); - - $this->expectException(ClientException::class); - $this->expectExceptionMessage('Response is not a valid JSON: Syntax error'); - $client->callAction(new ActionData('keboola.runner-config-test', '123')); - } - - public function testRequestExceptionIsThrownForValidErrorResponse(): void - { - $requestHandler = MockHandler::createWithMiddleware([ - new Response( - 400, - ['Content-Type' => 'application/json'], - (string) json_encode([]), - ), - ]); - - $client = $this->getClient(new ApiClientConfiguration(requestHandler: $requestHandler(...))); - - $this->expectException(ClientException::class); - $this->expectExceptionMessage('Response is not a valid error response: []'); - $client->callAction(new ActionData('keboola.runner-config-test', '123')); - } - - public function testRequestExceptionIsThrownForErrorResponseWithErrorCode(): void - { - $requestHandler = MockHandler::createWithMiddleware([ - new Response( - 400, - ['Content-Type' => 'application/json'], - (string) json_encode([ - 'context' => [ - 'errorCode' => 'some.error', - ], - ]), - ), - ]); - - $client = $this->getClient(new ApiClientConfiguration(requestHandler: $requestHandler(...))); - - $this->expectExceptionMessageMatches( - '#Client error: `POST http:\/\/example\.com\/actions` resulted in a `400 Bad Request`' - . ' response:.*{"context":{"errorCode":"some\.error"}}#s', - ); - $this->expectException(ClientException::class); - $client->callAction(new ActionData('keboola.runner-config-test', '123')); - } - - public function testLogger(): void - { - $mock = new MockHandler([ - new Response( - 200, - ['Content-Type' => 'application/json'], - '{ - "foo": "bar" - }', - ), - ]); - // Add the history middleware to the handler stack. - $requestHistory = []; - $history = Middleware::history($requestHistory); - $requestHandler = HandlerStack::create($mock); - $requestHandler->push($history); - $logHandler = new TestHandler(); - $logger = new Logger(name: 'test', handlers: [$logHandler]); - - $client = $this->getClient(new ApiClientConfiguration( - userAgent: 'test agent', - requestHandler: $requestHandler(...), - logger: $logger, - )); - - $client->callAction(new ActionData('keboola.runner-config-test', '123')); - - /** @var array $requestHistory */ - $request = $requestHistory[0]['request']; - self::assertEquals('Sync Actions PHP Client - test agent', $request->getHeader('User-Agent')[0]); - self::assertTrue($logHandler->hasInfoThatContains('"POST /1.1" 200 ')); - self::assertTrue($logHandler->hasInfoThatContains('test agent')); - } - - public function testRetrySuccess(): void - { - $mock = new MockHandler([ - new Response( - 500, - ['Content-Type' => 'application/json'], - '{"message" => "Out of order"}', - ), - new Response( - 501, - ['Content-Type' => 'application/json'], - 'Out of order', - ), - new Response( - 200, - ['Content-Type' => 'application/json'], - '{ - "foo": "bar" - }', - ), - ]); - // Add the history middleware to the handler stack. - $requestHistory = []; - $history = Middleware::history($requestHistory); - $requestHandler = HandlerStack::create($mock); - $requestHandler->push($history); - $client = $this->getClient(new ApiClientConfiguration(requestHandler: $requestHandler(...))); - - $job = $client->callAction(new ActionData('keboola.runner-config-test', '123')); - - self::assertEquals('bar', $job->data->foo); - /** @var array $requestHistory */ - self::assertCount(3, $requestHistory); - $request = $requestHistory[0]['request']; - self::assertEquals('http://example.com/actions', $request->getUri()->__toString()); - $request = $requestHistory[1]['request']; - self::assertEquals('http://example.com/actions', $request->getUri()->__toString()); - $request = $requestHistory[2]['request']; - self::assertEquals('http://example.com/actions', $request->getUri()->__toString()); - } - - public function testRetryFailure(): void - { - $responses = []; - for ($i = 0; $i < 30; $i++) { - $responses[] = new Response( - 500, - ['Content-Type' => 'application/json'], - '{"message" => "Out of order"}', - ); - } - $mock = new MockHandler($responses); - $requestHistory = []; - $history = Middleware::history($requestHistory); - $requestStack = HandlerStack::create($mock); - $requestStack->push($history); - $client = $this->getClient(new ApiClientConfiguration( - backoffMaxTries: 1, - requestHandler: $requestStack(...), - )); - - try { - $client->callAction(new ActionData('keboola.runner-config-test', '123')); - self::fail('Must throw exception'); - } catch (ClientException $e) { - self::assertStringContainsString('500 Internal Server Error', $e->getMessage()); - } - self::assertCount(2, (array) $requestHistory); - } - - public function testRetryFailureReducedBackoff(): void - { - $responses = []; - for ($i = 0; $i < 30; $i++) { - $responses[] = new Response( - 500, - ['Content-Type' => 'application/json'], - '{"message" => "Out of order"}', - ); - } - $mock = new MockHandler($responses); - // Add the history middleware to the handler stack. - $requestHistory = []; - $history = Middleware::history($requestHistory); - $requestStack = HandlerStack::create($mock); - $requestStack->push($history); - $client = $this->getClient(new ApiClientConfiguration( - backoffMaxTries: 3, - requestHandler: $requestStack(...), - )); - - try { - $client->callAction(new ActionData('keboola.runner-config-test', '123')); - self::fail('Must throw exception'); - } catch (ClientException $e) { - self::assertStringContainsString('500 Internal Server Error', $e->getMessage()); - } - self::assertCount(4, (array) $requestHistory); - } - - public function testNoRetry(): void - { - $mock = new MockHandler([ - new Response( - 401, - ['Content-Type' => 'application/json'], - '{"message": "Unauthorized"}', - ), - ]); - // Add the history middleware to the handler stack. - $requestHistory = []; - $history = Middleware::history($requestHistory); - $requestStack = HandlerStack::create($mock); - $requestStack->push($history); - $client = $this->getClient(new ApiClientConfiguration( - requestHandler: $requestStack(...), - )); - - $this->expectException(ClientException::class); - $this->expectExceptionMessage('{"message": "Unauthorized"}'); - $client->callAction(new ActionData('keboola.runner-config-test', '123')); - } - - public function testGetActions(): void - { - $mock = new MockHandler([ - new Response( - 200, - ['Content-Type' => 'application/json'], - '{"actions": ["action1", "action2"]}', - ), - ]); - $requestStack = HandlerStack::create($mock); - - $client = $this->getClient(new ApiClientConfiguration( - backoffMaxTries: 3, - requestHandler: $requestStack(...), - )); - $actions = $client->getActions('keboola.runner-config-test'); - - self::assertEquals(['action1', 'action2'], $actions->actions); - } - - - public function testGetActionsInvalidResponse(): void - { - $mock = new MockHandler([ - new Response( - 200, - ['Content-Type' => 'application/json'], - '{"broken": ["action1", "action2"]}', - ), - ]); - $requestStack = HandlerStack::create($mock); - - $client = $this->getClient(new ApiClientConfiguration( - backoffMaxTries: 3, - requestHandler: $requestStack(...), - )); - - $this->expectException(ClientException::class); - $this->expectExceptionMessage('Failed to parse response'); - $client->getActions('keboola.runner-config-test'); - } -} diff --git a/libs/sync-actions-api-php-client/tests/RetryDeciderTest.php b/libs/sync-actions-api-php-client/tests/RetryDeciderTest.php deleted file mode 100644 index 0fdbeac5a..000000000 --- a/libs/sync-actions-api-php-client/tests/RetryDeciderTest.php +++ /dev/null @@ -1,118 +0,0 @@ -logsHandler = new TestHandler(); - $this->logger = new Logger('tests', [$this->logsHandler]); - } - - #[DataProvider('retryDataProvider')] - public function testDecide( - int $retries, - ?ResponseInterface $response, - mixed $error, - bool $expectedResult, - ?string $log, - ): void { - $decider = new RetryDecider(10, $this->logger); - $result = $decider($retries, new Request('GET', ''), $response, $error); - - self::assertSame($expectedResult, $result); - - if ($log === null) { - self::assertCount(0, $this->logsHandler->getRecords()); - } else { - self::assertTrue($this->logsHandler->hasWarning($log)); - } - } - - public static function retryDataProvider(): iterable - { - yield 'too many retries' => [ - 'retries' => 11, - 'response' => null, - 'error' => null, - 'expectedResult' => false, - 'log' => null, - ]; - - yield 'no error, no response' => [ - 'retries' => 0, - 'response' => null, - 'error' => null, - 'expectedResult' => false, - 'log' => null, - ]; - - yield '4xx response without error' => [ - 'retries' => 0, - 'response' => new Response(400), - 'error' => null, - 'expectedResult' => false, - 'log' => null, - ]; - - yield '4xx response with error' => [ - 'retries' => 0, - 'response' => new Response(400), - 'error' => new ClientException('Request failed', new Request('GET', ''), new Response(400)), - 'expectedResult' => false, - 'log' => null, - ]; - - yield '5xx response' => [ - 'retries' => 0, - 'response' => new Response(500, [], 'Error body'), - 'error' => null, - 'expectedResult' => true, - 'log' => 'Request failed (Error body), retrying (0 of 10)', - ]; - - yield 'text error with response' => [ - 'retries' => 0, - 'response' => new Response(200), - 'error' => 'Text error', - 'expectedResult' => true, - 'log' => 'Request failed (Text error), retrying (0 of 10)', - ]; - - yield 'text error without response' => [ - 'retries' => 0, - 'response' => null, - 'error' => 'Text error', - 'expectedResult' => true, - 'log' => 'Request failed (Text error), retrying (0 of 10)', - ]; - - yield 'exception error' => [ - 'retries' => 0, - 'response' => new Response(200), - 'error' => new RuntimeException('Exception error'), - 'expectedResult' => true, - 'log' => 'Request failed (Exception error), retrying (0 of 10)', - ]; - } -} diff --git a/libs/sync-actions-api-php-client/tests/StorageApiTokenAuthenticatorTest.php b/libs/sync-actions-api-php-client/tests/StorageApiTokenAuthenticatorTest.php deleted file mode 100644 index 5b15a17e2..000000000 --- a/libs/sync-actions-api-php-client/tests/StorageApiTokenAuthenticatorTest.php +++ /dev/null @@ -1,25 +0,0 @@ -hasHeader('X-StorageApi-Token')); - self::assertSame([$token], $authenticatedRequest->getHeader('X-StorageApi-Token')); - self::assertNotSame($request, $authenticatedRequest); - } -} diff --git a/libs/sync-actions-api-php-client/tests/SyncActionsApiClientTest.php b/libs/sync-actions-api-php-client/tests/SyncActionsApiClientTest.php new file mode 100644 index 000000000..ae763648e --- /dev/null +++ b/libs/sync-actions-api-php-client/tests/SyncActionsApiClientTest.php @@ -0,0 +1,298 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Storage API token must not be empty'); + + new SyncActionsApiClient(self::BASE_URL, ''); // @phpstan-ignore-line + } + + public function testEmptyBaseUrlThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Base URL must be a non-empty string'); + + new SyncActionsApiClient('', self::TOKEN); // @phpstan-ignore-line + } + + public function testCallActionSendsRequestAndMapsResponse(): void + { + $requestHandler = self::createRequestHandler($requestsHistory, [ + new Response(201, ['Content-Type' => 'application/json'], '{"foo": "bar"}'), + ]); + + $client = new SyncActionsApiClient(self::BASE_URL, self::TOKEN, requestHandler: $requestHandler(...)); + + $response = $client->callAction(new ActionData(self::COMPONENT_ID, 'someAction')); + + self::assertSame('bar', $response->data->foo); + self::assertCount(1, $requestsHistory); + self::assertRequestEquals( + 'POST', + 'http://example.com/actions', + [ + 'X-StorageApi-Token' => self::TOKEN, + 'User-Agent' => 'Sync Actions PHP Client', + 'Content-Type' => 'application/json', + ], + '{"componentId":"keboola.runner-config-test","action":"someAction","configData":[]}', + $requestsHistory[0]['request'], + ); + } + + public function testCallActionPreservesObjectFidelity(): void + { + // Regression guard: decoding via an associative array collapses {} to [] and renumbers + // integer-keyed objects, so callAction must decode the raw body straight to stdClass. + $requestHandler = self::createRequestHandler($requestsHistory, [ + new Response(200, [], '{"filled": {"x": "y"}, "empty": {}, "nested": {"inner": {}}}'), + ]); + + $client = new SyncActionsApiClient(self::BASE_URL, self::TOKEN, requestHandler: $requestHandler(...)); + + $response = $client->callAction(new ActionData(self::COMPONENT_ID, 'someAction')); + + self::assertInstanceOf(stdClass::class, $response->data->filled); + self::assertSame('y', $response->data->filled->x); + self::assertInstanceOf(stdClass::class, $response->data->empty); + self::assertInstanceOf(stdClass::class, $response->data->nested); + self::assertInstanceOf(stdClass::class, $response->data->nested->inner); + } + + public function testCallActionWithUnencodableDataThrows(): void + { + $requestHandler = self::createRequestHandler($requestsHistory, [new Response(200)]); + $client = new SyncActionsApiClient(self::BASE_URL, self::TOKEN, requestHandler: $requestHandler(...)); + + $resource = fopen(sys_get_temp_dir() . '/touch', 'w'); + + $this->expectException(SyncActionsClientException::class); + $this->expectExceptionMessage('Invalid job data: Type is not supported'); + + $client->callAction(new ActionData(self::COMPONENT_ID, 'someAction', ['res' => $resource])); + } + + public function testServerErrorIsWrappedAsClientException(): void + { + $requestHandler = self::createRequestHandler($requestsHistory, [ + new Response(500, ['Content-Type' => 'text/plain'], 'Error on server'), + ]); + + $client = new SyncActionsApiClient( + self::BASE_URL, + self::TOKEN, + backoffMaxTries: 0, + requestHandler: $requestHandler(...), + ); + + $this->expectException(SyncActionsClientException::class); + $this->expectExceptionMessage('Error on server'); + + $client->callAction(new ActionData(self::COMPONENT_ID, 'someAction')); + } + + public function testInvalidJsonResponseIsWrappedAsClientException(): void + { + $requestHandler = self::createRequestHandler($requestsHistory, [ + new Response(200, ['Content-Type' => 'application/json'], '{not a valid json]'), + ]); + + $client = new SyncActionsApiClient(self::BASE_URL, self::TOKEN, requestHandler: $requestHandler(...)); + + $this->expectException(SyncActionsClientException::class); + $this->expectExceptionMessage('Response is not valid JSON: Syntax error'); + + $client->callAction(new ActionData(self::COMPONENT_ID, 'someAction')); + } + + public function testErrorResponseWithErrorAndCodeIsFormatted(): void + { + $requestHandler = self::createRequestHandler($requestsHistory, [ + new Response(400, ['Content-Type' => 'application/json'], '{"error": "Missing data", "code": 400}'), + ]); + + $client = new SyncActionsApiClient( + self::BASE_URL, + self::TOKEN, + backoffMaxTries: 0, + requestHandler: $requestHandler(...), + ); + + $this->expectException(SyncActionsClientException::class); + $this->expectExceptionMessage('400: Missing data'); + + $client->callAction(new ActionData(self::COMPONENT_ID, 'someAction')); + } + + public function testErrorWithoutErrorEnvelopeFallsBackToGuzzleMessage(): void + { + $requestHandler = self::createRequestHandler($requestsHistory, [ + new Response(400, ['Content-Type' => 'application/json'], '{"context": {"errorCode": "some.error"}}'), + ]); + + $client = new SyncActionsApiClient( + self::BASE_URL, + self::TOKEN, + backoffMaxTries: 0, + requestHandler: $requestHandler(...), + ); + + $this->expectException(SyncActionsClientException::class); + $this->expectExceptionMessage('400 Bad Request'); + + $client->callAction(new ActionData(self::COMPONENT_ID, 'someAction')); + } + + public function testUserAgentSuffixIsAppliedAndRequestIsLogged(): void + { + $requestHandler = self::createRequestHandler($requestsHistory, [ + new Response(200, ['Content-Type' => 'application/json'], '{"foo": "bar"}'), + ]); + + $logHandler = new TestHandler(); + $logger = new Logger('test', [$logHandler]); + + $client = new SyncActionsApiClient( + self::BASE_URL, + self::TOKEN, + logger: $logger, + userAgent: 'test agent', + requestHandler: $requestHandler(...), + ); + + $client->callAction(new ActionData(self::COMPONENT_ID, 'someAction')); + + self::assertSame( + 'Sync Actions PHP Client - test agent', + $requestsHistory[0]['request']->getHeaderLine('User-Agent'), + ); + self::assertNotEmpty($logHandler->getRecords()); + } + + public function testRetriesOnServerErrorByDefault(): void + { + $requestHandler = self::createRequestHandler($requestsHistory, [ + new Response(500, [], 'fail'), + new Response(200, ['Content-Type' => 'application/json'], '{"foo": "bar"}'), + ]); + + $client = new SyncActionsApiClient(self::BASE_URL, self::TOKEN, requestHandler: $requestHandler(...)); + + $response = $client->callAction(new ActionData(self::COMPONENT_ID, 'someAction')); + + self::assertSame('bar', $response->data->foo); + self::assertCount(2, $requestsHistory); + } + + public function testStopsRetryingAfterMaxTries(): void + { + $requestHandler = self::createRequestHandler($requestsHistory, [ + new Response(500, [], 'fail'), + new Response(500, [], 'fail'), + ]); + + $client = new SyncActionsApiClient( + self::BASE_URL, + self::TOKEN, + backoffMaxTries: 1, + requestHandler: $requestHandler(...), + ); + + try { + $client->callAction(new ActionData(self::COMPONENT_ID, 'someAction')); + self::fail('Expected SyncActionsClientException to be thrown'); + } catch (SyncActionsClientException $e) { + self::assertStringContainsString('500 Internal Server Error', $e->getMessage()); + } + + self::assertCount(2, $requestsHistory); + } + + public function testDoesNotRetryOnClientError(): void + { + $requestHandler = self::createRequestHandler($requestsHistory, [ + new Response(401, ['Content-Type' => 'application/json'], '{"message": "Unauthorized"}'), + ]); + + $client = new SyncActionsApiClient(self::BASE_URL, self::TOKEN, requestHandler: $requestHandler(...)); + + try { + $client->callAction(new ActionData(self::COMPONENT_ID, 'someAction')); + self::fail('Expected SyncActionsClientException to be thrown'); + } catch (SyncActionsClientException $e) { + self::assertStringContainsString('Unauthorized', $e->getMessage()); + } + + self::assertCount(1, $requestsHistory); + } + + public function testGetActions(): void + { + $requestHandler = self::createRequestHandler($requestsHistory, [ + new Response(200, ['Content-Type' => 'application/json'], '{"actions": ["action1", "action2"]}'), + ]); + + $client = new SyncActionsApiClient(self::BASE_URL, self::TOKEN, requestHandler: $requestHandler(...)); + + $response = $client->getActions(self::COMPONENT_ID); + + self::assertSame(['action1', 'action2'], $response->actions); + self::assertRequestEquals( + 'GET', + 'http://example.com/actions?componentId=keboola.runner-config-test', + ['X-StorageApi-Token' => self::TOKEN], + null, + $requestsHistory[0]['request'], + ); + } + + public function testGetActionsWithInvalidResponseThrows(): void + { + $requestHandler = self::createRequestHandler($requestsHistory, [ + new Response(200, ['Content-Type' => 'application/json'], '{"broken": ["action1"]}'), + ]); + + $client = new SyncActionsApiClient(self::BASE_URL, self::TOKEN, requestHandler: $requestHandler(...)); + + $this->expectException(SyncActionsClientException::class); + $this->expectExceptionMessage('Failed to map response data'); + + $client->getActions(self::COMPONENT_ID); + } + + public function testDefaultBackoffMaxTriesIsTen(): void + { + $default = null; + foreach ((new ReflectionMethod(SyncActionsApiClient::class, '__construct'))->getParameters() as $parameter) { + if ($parameter->getName() === 'backoffMaxTries') { + $default = $parameter->getDefaultValue(); + } + } + + self::assertSame(10, $default); + } +}