From 990e0f9d4cf5780c953f344352194536fdbbd937 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimi=CC=81r=20Kris=CC=8Cka?= Date: Tue, 19 Aug 2025 02:00:17 +0200 Subject: [PATCH 01/23] Initial version --- .env.dist | 6 + .github/workflows/branch.yml | 15 + .gitignore | 24 ++ Dockerfile | 9 + README.md | 91 ++++- composer.json | 51 +++ docker-compose.yml | 12 + docker/php.ini | 7 + phpcs.xml | 9 + phpstan.neon | 9 + phpunit.xml.dist | 29 ++ src/Client.php | 335 ++++++++++++++++++ src/ClientException.php | 32 ++ src/Exception.php | 11 + tests/ClientTest.php | 204 +++++++++++ tests/Functional/.gitkeep | 1 + tests/Functional/BaseFunctionalTestCase.php | 185 ++++++++++ tests/Functional/BasicQueryTest.php | 116 ++++++ .../Functional/QueryServiceFunctionalTest.php | 295 +++++++++++++++ tests/bootstrap.php | 16 + 20 files changed, 1453 insertions(+), 4 deletions(-) create mode 100644 .env.dist create mode 100644 .github/workflows/branch.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 composer.json create mode 100644 docker-compose.yml create mode 100644 docker/php.ini create mode 100644 phpcs.xml create mode 100644 phpstan.neon create mode 100644 phpunit.xml.dist create mode 100644 src/Client.php create mode 100644 src/ClientException.php create mode 100644 src/Exception.php create mode 100644 tests/ClientTest.php create mode 100644 tests/Functional/.gitkeep create mode 100644 tests/Functional/BaseFunctionalTestCase.php create mode 100644 tests/Functional/BasicQueryTest.php create mode 100644 tests/Functional/QueryServiceFunctionalTest.php create mode 100644 tests/bootstrap.php diff --git a/.env.dist b/.env.dist new file mode 100644 index 0000000..8a31b88 --- /dev/null +++ b/.env.dist @@ -0,0 +1,6 @@ +# Minimal setup for getting Query API Client +TESTS_QUERY_API_URL= +TESTS_STORAGE_API_TOKEN= + +# Override if you want to test with dev Query API +TESTS_QUERY_API_URL_OVERRIDE= diff --git a/.github/workflows/branch.yml b/.github/workflows/branch.yml new file mode 100644 index 0000000..04ac493 --- /dev/null +++ b/.github/workflows/branch.yml @@ -0,0 +1,15 @@ +name: Build + +on: + push: + branches-ignore: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - run: | + docker compose build dev + docker compose run --rm dev composer ci diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8773461 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Dependencies +/vendor/ + +# Environment files (contains sensitive information) +.env + +# PHPUnit +.phpunit.result.cache +/build + +# Coverage reports +/coverage/ + +# IDE files +.vscode/ +.idea/ +.claude/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..160a098 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM php:8.2 + +RUN apt-get update -q \ + && apt-get install git unzip \ + -y --no-install-recommends + +COPY docker/php.ini /usr/local/etc/php/php.ini + +RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin/ --filename=composer diff --git a/README.md b/README.md index ff9be34..442fd48 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,92 @@ -# Template +# Keboola Query Service API PHP Client + +PHP client for Keboola Query Service API. ## Installation -Clone this repository +```bash +composer require keboola/query-api-php-client +``` + +## Usage + +```php + 'https://query.keboola.com', + 'token' => 'your-storage-api-token' +]); + +// Submit a query job +$response = $client->submitQueryJob('main', 'workspace-123', [ + 'statements' => ['SELECT * FROM table1'], + 'transactional' => true +]); + +$queryJobId = $response['queryJobId']; + +// Get job status +$status = $client->getJobStatus($queryJobId); + +// Get job results +$results = $client->getJobResults($queryJobId, $statementId); + +// Cancel job +$client->cancelJob($queryJobId, ['reason' => 'User requested cancellation']); +``` + +## Development + +### Running Tests + +Run unit tests: +```bash +composer tests +``` + +Run functional tests (requires .env file with test configuration): +```bash +composer tests-functional +``` + +Run all tests: +```bash +composer tests-all +``` + +### Functional Tests Setup + +For functional tests, copy `.env.example` to `.env` and configure: + +```bash +cp .env.example .env +``` + +Then edit `.env` with your test environment settings: + +```env +TESTS_HOSTNAME_SUFFIX=.keboola.com +TESTS_STORAGE_API_TOKEN=your-storage-api-token +``` + +**Note**: Functional tests will create and delete temporary branches and workspaces in your Keboola project. Make sure to use a development/test project with appropriate permissions. + +### Code Quality + +Run code style check: +```bash +composer phpcs +``` -## License +Run static analysis: +```bash +composer phpstan +``` -MIT licensed, see [LICENSE](./LICENSE) file. +Run all CI checks: +```bash +composer ci +``` \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..9f4dc84 --- /dev/null +++ b/composer.json @@ -0,0 +1,51 @@ +{ + "name": "keboola/query-api-php-client", + "description": "Keboola Query Service API PHP Client", + "homepage": "https://keboola.com", + "license": "MIT", + "config": { + "lock": false, + "optimize-autoloader": true, + "sort-packages": true, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + }, + "autoload": { + "psr-4": { + "Keboola\\QueryApi\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Keboola\\QueryApi\\Tests\\": "tests/" + } + }, + "require": { + "php": ">=7.4", + "ext-json": "*", + "guzzlehttp/guzzle": "~7.0", + "keboola/storage-api-client": "^18.0", + "psr/log": "~1.0" + }, + "require-dev": { + "keboola/coding-standard": "^16.0", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.1", + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "^3", + "symfony/dotenv": "^6.0" + }, + "scripts": { + "phpcs": "phpcs -n .", + "phpcbf": "phpcbf -n .", + "phpstan": "phpstan analyse --no-progress --level=max src tests -c phpstan.neon", + "tests": "phpunit --coverage-clover build/logs/clover.xml --coverage-xml=build/logs/coverage-xml --log-junit=build/logs/phpunit.junit.xml", + "ci": [ + "@composer validate --no-check-all --strict", + "@phpcs", + "@phpstan", + "@tests" + ] + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..764a860 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + dev: + image: keboola/query-api-php-client + build: . + volumes: + - ./:/code + working_dir: /code + command: bash + environment: + - TESTS_QUERY_API_URL + - TESTS_STORAGE_API_TOKEN + - TESTS_QUERY_API_URL_OVERRIDE diff --git a/docker/php.ini b/docker/php.ini new file mode 100644 index 0000000..7351f22 --- /dev/null +++ b/docker/php.ini @@ -0,0 +1,7 @@ +; Maximum amount of memory a script may consume (128MB) +; http://php.net/memory-limit +memory_limit = -1 + +; Defines the default timezone used by the date functions +; http://php.net/date.timezone +date.timezone = UTC diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..513fae5 --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,9 @@ + + + The Keboola coding standard. + + src + tests + + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..1bf460a --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,9 @@ +parameters: + level: max + paths: + - src + - tests + ignoreErrors: + - + identifier: argument.type + path: tests/ClientTest.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..a8eac34 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,29 @@ + + + + + tests + tests/Functional + + + tests/Functional + + + + + + src + + + \ No newline at end of file diff --git a/src/Client.php b/src/Client.php new file mode 100644 index 0000000..dab8a34 --- /dev/null +++ b/src/Client.php @@ -0,0 +1,335 @@ +apiUrl = rtrim($config['url'], '/'); + $this->tokenString = $config['token']; + $this->backoffMaxTries = $config['backoffMaxTries'] ?? self::DEFAULT_BACKOFF_RETRIES; + $this->userAgent = self::DEFAULT_USER_AGENT; + $this->logger = $config['logger'] ?? new NullLogger(); + + if (isset($config['userAgent'])) { + $this->userAgent .= ' ' . $config['userAgent']; + } + + $this->initStorageApiClient($config); + + // Allow override of Query API URL via environment variable for functional testing + // This is specifically for overriding in functional tests when needed + if (!empty($_ENV['TESTS_QUERY_API_URL_OVERRIDE'])) { + $this->apiUrl = rtrim($_ENV['TESTS_QUERY_API_URL_OVERRIDE'], '/'); + } + + $this->initClient($config); + } + + /** + * @param array{handler?: HandlerStack} $config + */ + private function initClient(array $config): void + { + $handlerStack = $config['handler'] ?? HandlerStack::create(); + $handlerStack->push(Middleware::retry($this->createRetryDecider(), $this->createRetryDelay())); + + $this->client = new GuzzleClient([ + 'base_uri' => $this->apiUrl, + 'handler' => $handlerStack, + 'connect_timeout' => self::GUZZLE_CONNECT_TIMEOUT_SECONDS, + 'timeout' => self::GUZZLE_TIMEOUT_SECONDS, + ]); + } + + private function createRetryDecider(): callable + { + return function ( + int $retries, + RequestInterface $request, + ?ResponseInterface $response = null, + ?Throwable $exception = null, + ): bool { + if ($retries >= $this->backoffMaxTries) { + return false; + } + + if ($exception instanceof ConnectException) { + return true; + } + + if ($response && $response->getStatusCode() >= 500) { + return true; + } + + return false; + }; + } + + private function createRetryDelay(): callable + { + return function (int $numberOfRetries): int { + return 1000 * (2 ** $numberOfRetries); + }; + } + + /** + * @param array{storageApiUrl?: string, logger?: LoggerInterface} $config + */ + private function initStorageApiClient(array $config): void + { + $storageApiUrl = $config['storageApiUrl'] ?? $this->deriveStorageApiUrl(); + + $this->storageApiClient = new StorageApiClient([ + 'url' => $storageApiUrl, + 'token' => $this->tokenString, + 'logger' => $this->logger, + ]); + } + + private function deriveStorageApiUrl(): string + { + // Convert Query Service URL to Storage API URL + // e.g., https://query.keboola.com -> https://connection.keboola.com + // e.g., https://query.eu-central-1.keboola.com -> https://connection.eu-central-1.keboola.com + $parsedUrl = parse_url($this->apiUrl); + if ($parsedUrl === false) { + throw new InvalidArgumentException('Invalid Query Service URL'); + } + + $scheme = $parsedUrl['scheme'] ?? 'https'; + $host = $parsedUrl['host'] ?? ''; + $port = isset($parsedUrl['port']) ? ':' . $parsedUrl['port'] : ''; + + // Convert query.* subdomain to connection.* for Storage API + if (strpos($host, 'query.') === 0) { + $host = str_replace('query.', 'connection.', $host); + } + + return sprintf('%s://%s%s', $scheme, $host, $port); + } + + /** + * Verify Storage API token before making Query Service requests + */ + private function verifyStorageApiToken(): void + { + try { + $this->storageApiClient->verifyToken(); + } catch (StorageApiClientException $e) { + throw new ClientException( + 'Storage API token verification failed: ' . $e->getMessage(), + $e->getCode(), + $e, + ); + } + } + + /** + * Submit a new query job + * + * @param array{statements: string[], transactional?: bool} $requestBody + * @return array + */ + public function submitQueryJob(string $branchId, string $workspaceId, array $requestBody): array + { + $this->verifyStorageApiToken(); + $url = sprintf('/api/v1/branches/%s/workspaces/%s/queries', $branchId, $workspaceId); + return $this->sendRequest('POST', $url, $requestBody); + } + + /** + * Get job status + * + * @return array + */ + public function getJobStatus(string $queryJobId): array + { + $this->verifyStorageApiToken(); + $url = sprintf('/api/v1/queries/%s', $queryJobId); + return $this->sendRequest('GET', $url); + } + + /** + * Cancel a job + * + * @param array{reason?: string} $requestBody + * @return array + */ + public function cancelJob(string $queryJobId, array $requestBody = []): array + { + $this->verifyStorageApiToken(); + $url = sprintf('/api/v1/queries/%s/cancel', $queryJobId); + return $this->sendRequest('POST', $url, $requestBody); + } + + /** + * Get job results + * + * @return array + */ + public function getJobResults(string $queryJobId, string $statementId): array + { + $this->verifyStorageApiToken(); + $url = sprintf('/api/v1/queries/%s/%s/results', $queryJobId, $statementId); + return $this->sendRequest('GET', $url); + } + + /** + * Health check + * + * @return array + */ + public function healthCheck(): array + { + return $this->sendRequest('GET', '/health-check'); + } + + /** + * Get the Storage API client instance + */ + public function getStorageApiClient(): StorageApiClient + { + return $this->storageApiClient; + } + + /** + * @param array|null $requestBody + * @return array + */ + private function sendRequest(string $method, string $url, ?array $requestBody = null): array + { + $headers = [ + 'Content-Type' => 'application/json', + 'X-StorageAPI-Token' => $this->tokenString, + 'User-Agent' => $this->userAgent, + ]; + + $options = [ + 'headers' => $headers, + ]; + + if ($requestBody !== null) { + try { + $options['body'] = json_encode($requestBody, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new ClientException('Failed to encode request body as JSON: ' . $e->getMessage(), 0, $e); + } + } + + try { + $response = $this->client->request($method, $url, $options); + } catch (GuzzleException $e) { + $this->handleGuzzleException($e); + throw new ClientException('Request failed after exception handling'); + } + + return $this->parseResponse($response); + } + + /** + * @return array + */ + private function parseResponse(ResponseInterface $response): array + { + $body = (string) $response->getBody(); + + if (empty($body)) { + return []; + } + + try { + $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new ClientException('Response is not valid JSON: ' . $e->getMessage(), 0, $e); + } + + if (!is_array($data)) { + throw new ClientException('Response is not a JSON object'); + } + + return $data; + } + + /** + * @throws ClientException + */ + private function handleGuzzleException(GuzzleException $e): void + { + if ($e instanceof GuzzleClientException && $e->hasResponse()) { + $response = $e->getResponse(); + $statusCode = $response->getStatusCode(); + $body = (string) $response->getBody(); + + try { + $errorData = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + $errorData = null; + } + + $message = is_array($errorData) && isset($errorData['exception']) && is_string($errorData['exception']) + ? $errorData['exception'] + : $e->getMessage(); + $contextData = is_array($errorData) ? $errorData : null; + throw new ClientException($message, $statusCode, $e, $contextData); + } + + if ($e instanceof ConnectException) { + throw new ClientException('Unable to connect to Query Service API: ' . $e->getMessage(), 0, $e); + } + + throw new ClientException('Query Service API request failed: ' . $e->getMessage(), 0, $e); + } +} diff --git a/src/ClientException.php b/src/ClientException.php new file mode 100644 index 0000000..f4f1b28 --- /dev/null +++ b/src/ClientException.php @@ -0,0 +1,32 @@ +|null + */ + private ?array $contextData; + + /** + * @param array|null $contextData + */ + public function __construct(string $message, int $code = 0, ?Throwable $previous = null, ?array $contextData = null) + { + parent::__construct($message, $code, $previous); + $this->contextData = $contextData; + } + + /** + * @return array|null + */ + public function getContextData(): ?array + { + return $this->contextData; + } +} diff --git a/src/Exception.php b/src/Exception.php new file mode 100644 index 0000000..5fc18b6 --- /dev/null +++ b/src/Exception.php @@ -0,0 +1,11 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('url must be set'); + + new Client([ + 'token' => 'test-token', + ]); + } + + public function testConstructorRequiresToken(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('token must be set'); + + new Client([ + 'url' => 'https://test.keboola.com', + ]); + } + + public function testSubmitQueryJob(): void + { + $mockHandler = new MockHandler([ + new Response(201, [], json_encode(['queryJobId' => 'job-12345']) ?: ''), + ]); + + $storageApiClient = $this->createMock(StorageApiClient::class); + $storageApiClient->expects($this->once())->method('verifyToken'); + + $client = $this->createClientWithMockHandler($mockHandler, $storageApiClient); + + $result = $client->submitQueryJob('main', 'workspace-123', [ + 'statements' => ['SELECT * FROM table1'], + 'transactional' => true, + ]); + + $this->assertEquals(['queryJobId' => 'job-12345'], $result); + } + + public function testGetJobStatus(): void + { + $mockHandler = new MockHandler([ + new Response(200, [], json_encode([ + 'queryJobId' => 'job-12345', + 'status' => 'running', + 'statements' => [], + ]) ?: ''), + ]); + + $storageApiClient = $this->createMock(StorageApiClient::class); + $storageApiClient->expects($this->once())->method('verifyToken'); + + $client = $this->createClientWithMockHandler($mockHandler, $storageApiClient); + + $result = $client->getJobStatus('job-12345'); + + $this->assertEquals('job-12345', $result['queryJobId']); + $this->assertEquals('running', $result['status']); + } + + public function testCancelJob(): void + { + $mockHandler = new MockHandler([ + new Response(200, [], json_encode(['queryJobId' => 'job-12345']) ?: ''), + ]); + + $storageApiClient = $this->createMock(StorageApiClient::class); + $storageApiClient->expects($this->once())->method('verifyToken'); + + $client = $this->createClientWithMockHandler($mockHandler, $storageApiClient); + + $result = $client->cancelJob('job-12345', ['reason' => 'User requested']); + + $this->assertEquals(['queryJobId' => 'job-12345'], $result); + } + + public function testGetJobResults(): void + { + $mockHandler = new MockHandler([ + new Response(200, [], json_encode([ + 'data' => [['id' => 1, 'name' => 'test']], + 'status' => 'completed', + 'rowsAffected' => 1, + ]) ?: ''), + ]); + + $storageApiClient = $this->createMock(StorageApiClient::class); + $storageApiClient->expects($this->once())->method('verifyToken'); + + $client = $this->createClientWithMockHandler($mockHandler, $storageApiClient); + + $result = $client->getJobResults('job-12345', 'stmt-67890'); + + $this->assertEquals('completed', $result['status']); + $this->assertEquals(1, $result['rowsAffected']); + assert(is_array($result['data'])); + $this->assertCount(1, $result['data']); + } + + public function testHealthCheck(): void + { + $mockHandler = new MockHandler([ + new Response(200, [], json_encode([ + 'service' => 'query', + 'status' => 'ok', + 'timestamp' => '2024-01-01T00:00:00Z', + 'version' => '1.0.0', + ]) ?: ''), + ]); + + $storageApiClient = $this->createMock(StorageApiClient::class); + // Health check should NOT verify token + $storageApiClient->expects($this->never())->method('verifyToken'); + + $client = $this->createClientWithMockHandler($mockHandler, $storageApiClient); + + $result = $client->healthCheck(); + + $this->assertEquals('query', $result['service']); + $this->assertEquals('ok', $result['status']); + } + + public function testStorageApiTokenVerificationFailure(): void + { + $mockHandler = new MockHandler([]); + + $storageApiClient = $this->createMock(StorageApiClient::class); + $storageApiClient->expects($this->once()) + ->method('verifyToken') + ->willThrowException(new StorageApiClientException('Invalid token')); + + $client = $this->createClientWithMockHandler($mockHandler, $storageApiClient); + + $this->expectException(ClientException::class); + $this->expectExceptionMessage('Storage API token verification failed: Invalid token'); + + $client->submitQueryJob('main', 'workspace-123', [ + 'statements' => ['SELECT * FROM table1'], + ]); + } + + public function testStorageApiUrlDerivation(): void + { + // Test with Query Service URL to ensure proper Storage API URL derivation + $client = new Client([ + 'url' => 'https://query.keboola.com', + 'token' => 'test-token', + ]); + + // Since we can't easily mock the StorageApiClient constructor, + // we'll just verify the client can be created without errors + $this->assertInstanceOf(Client::class, $client); + } + + private function createClientWithMockHandler( + MockHandler $mockHandler, + ?StorageApiClient $storageApiClient = null, + ): Client { + $handlerStack = HandlerStack::create($mockHandler); + + if ($storageApiClient) { + // Use reflection to inject the mocked Storage API client + $client = new Client([ + 'url' => 'https://query.test.keboola.com', + 'token' => 'test-token', + 'handler' => $handlerStack, + ]); + + $reflection = new ReflectionClass($client); + $property = $reflection->getProperty('storageApiClient'); + $property->setAccessible(true); + $property->setValue($client, $storageApiClient); + + return $client; + } + + return new Client([ + 'url' => 'https://query.test.keboola.com', + 'token' => 'test-token', + 'handler' => $handlerStack, + ]); + } +} diff --git a/tests/Functional/.gitkeep b/tests/Functional/.gitkeep new file mode 100644 index 0000000..72a4514 --- /dev/null +++ b/tests/Functional/.gitkeep @@ -0,0 +1 @@ +# This file ensures the Functional directory is tracked in git \ No newline at end of file diff --git a/tests/Functional/BaseFunctionalTestCase.php b/tests/Functional/BaseFunctionalTestCase.php new file mode 100644 index 0000000..ab700f3 --- /dev/null +++ b/tests/Functional/BaseFunctionalTestCase.php @@ -0,0 +1,185 @@ +validateEnvironmentVariables(); + $this->initializeClients(); + $this->findDefaultBranch(); + $this->createTestWorkspace(); + } + + protected function tearDown(): void + { + if (isset($this->testWorkspaceId)) { + try { + $workspaces = new Workspaces($this->branchAwareStorageClient); + $workspaces->deleteWorkspace((int) $this->testWorkspaceId); + } catch (Throwable $e) { + // Log but don't fail the test if workspace cleanup fails + error_log('Failed to delete test workspace: ' . $e->getMessage()); + } + } + + parent::tearDown(); + } + + private function validateEnvironmentVariables(): void + { + $requiredVars = ['TESTS_STORAGE_API_TOKEN', 'TESTS_QUERY_API_URL']; + + foreach ($requiredVars as $var) { + if (empty($_ENV[$var])) { + $this->markTestSkipped( + sprintf('Environment variable %s is required for functional tests', $var), + ); + } + } + } + + private function initializeClients(): void + { + $storageApiToken = $_ENV['TESTS_STORAGE_API_TOKEN']; + $queryApiUrl = $_ENV['TESTS_QUERY_API_URL']; + + $this->queryClient = new Client([ + 'url' => $queryApiUrl, + 'token' => $storageApiToken, + ]); + + // Get Storage API client from Query API client + $this->storageApiClient = $this->queryClient->getStorageApiClient(); + } + + private function findDefaultBranch(): void + { + $devBranches = new DevBranches($this->storageApiClient); + + // List all branches + $branches = $devBranches->listBranches(); + + // Find default branch + $defaultBranch = null; + foreach ($branches as $branch) { + if (isset($branch['isDefault']) && $branch['isDefault'] === true) { + $defaultBranch = $branch; + break; + } + } + + if ($defaultBranch === null) { + throw new RuntimeException('No default branch found'); + } + + $this->testBranchId = (string) $defaultBranch['id']; + + // Initialize branch-aware storage client + $this->branchAwareStorageClient = $this->storageApiClient->getBranchAwareClient($this->testBranchId); + } + + private function createTestWorkspace(): void + { + // Create a workspace for testing queries + $workspaces = new Workspaces($this->branchAwareStorageClient); + $workspaceData = $workspaces->createWorkspace([ + 'name' => sprintf('query-test-workspace-%d', random_int(1000, 9999)), + 'backend' => 'snowflake', + ], true); + + $this->testWorkspaceId = (string) $workspaceData['id']; + } + + + + protected function getTestBranchId(): string + { + return $this->testBranchId; + } + + protected function getTestWorkspaceId(): string + { + return $this->testWorkspaceId; + } + + protected function createTestTable(?string $tableName = null): string + { + if ($tableName === null) { + $tableName = 'test_table_' . random_int(1000, 9999); + } + + // Create table and insert test data using Query Service + $createTableSql = sprintf(' + CREATE OR REPLACE TABLE %s ( + id INTEGER PRIMARY KEY, + name STRING, + value INTEGER + )', $tableName); + + $insertDataSql = sprintf(' + INSERT INTO %s (id, name, value) VALUES + (1, \'test1\', 100), + (2, \'test2\', 200), + (3, \'test3\', 300) + ', $tableName); + + // Execute table creation and data insertion + $response = $this->queryClient->submitQueryJob( + $this->getTestBranchId(), + $this->getTestWorkspaceId(), + [ + 'statements' => [$createTableSql, $insertDataSql], + 'transactional' => true, + ], + ); + + // Wait for completion + assert(is_string($response['queryJobId'])); + $this->waitForJobCompletion($response['queryJobId']); + + return $tableName; + } + + /** + * @return array + */ + protected function waitForJobCompletion(string $queryJobId, int $maxWaitSeconds = 30): array + { + $startTime = time(); + $attempt = 1; + + while (time() - $startTime < $maxWaitSeconds) { + $status = $this->queryClient->getJobStatus($queryJobId); + if (in_array($status['status'], ['completed', 'failed', 'canceled'], true)) { + return $status; + } + sleep(1); + $attempt++; + } + + throw new RuntimeException( + sprintf('Job %s did not complete within %d seconds', $queryJobId, $maxWaitSeconds), + ); + } +} diff --git a/tests/Functional/BasicQueryTest.php b/tests/Functional/BasicQueryTest.php new file mode 100644 index 0000000..4b34897 --- /dev/null +++ b/tests/Functional/BasicQueryTest.php @@ -0,0 +1,116 @@ +queryClient->submitQueryJob( + $this->getTestBranchId(), + $this->getTestWorkspaceId(), + [ + 'statements' => ['SELECT CURRENT_TIMESTAMP() AS "current_time"'], + 'transactional' => false, + ], + ); + + $this->assertArrayHasKey('queryJobId', $response); + $queryJobId = $response['queryJobId']; + assert(is_string($queryJobId)); + $this->assertNotEmpty($queryJobId); + + // Wait for job completion + $finalStatus = $this->waitForJobCompletion($queryJobId); + + $this->assertEquals('completed', $finalStatus['status']); + $this->assertEquals($queryJobId, $finalStatus['queryJobId']); + $this->assertArrayHasKey('statements', $finalStatus); + $statements = $finalStatus['statements']; + assert(is_array($statements)); + $this->assertCount(1, $statements); + + $statement = $statements[0]; + assert(is_array($statement)); + $this->assertEquals('completed', $statement['status']); + + // Get job results + $this->assertArrayHasKey('statementId', $statement); + $results = $this->queryClient->getJobResults($queryJobId, $statement['statementId']); + + $this->assertArrayHasKey('status', $results); + $this->assertEquals('completed', $results['status']); + + // Verify we got a timestamp result + $this->assertArrayHasKey('data', $results); + $data = $results['data']; + assert(is_array($data)); + $this->assertCount(1, $data); + $row = $data[0]; + assert(is_array($row)); + $this->assertCount(1, $row); + // Query API returns indexed arrays, not associative arrays with column names + assert(isset($row[0]) && is_string($row[0])); + $this->assertNotEmpty($row[0]); + // Verify it's a valid timestamp (numeric string) + $this->assertMatchesRegularExpression('/^\d+\.\d+$/', $row[0]); + } + + public function testSubmitInformationSchemaQuery(): void + { + // Test a query against information_schema to verify database connectivity + $response = $this->queryClient->submitQueryJob( + $this->getTestBranchId(), + $this->getTestWorkspaceId(), + [ + 'statements' => [ + 'SELECT COUNT(*) AS "table_count" FROM information_schema.tables ' . + 'WHERE table_schema = CURRENT_SCHEMA()', + ], + 'transactional' => false, + ], + ); + + $this->assertArrayHasKey('queryJobId', $response); + $queryJobId = $response['queryJobId']; + assert(is_string($queryJobId)); + $this->assertNotEmpty($queryJobId); + + // Wait for job completion + $finalStatus = $this->waitForJobCompletion($queryJobId); + + $this->assertEquals('completed', $finalStatus['status']); + $this->assertEquals($queryJobId, $finalStatus['queryJobId']); + $this->assertArrayHasKey('statements', $finalStatus); + $statements = $finalStatus['statements']; + assert(is_array($statements)); + $this->assertCount(1, $statements); + + $statement = $statements[0]; + assert(is_array($statement)); + $this->assertEquals('completed', $statement['status']); + + // Get job results + $this->assertArrayHasKey('statementId', $statement); + $results = $this->queryClient->getJobResults($queryJobId, $statement['statementId']); + + $this->assertArrayHasKey('status', $results); + $this->assertEquals('completed', $results['status']); + + // Verify we got a count result + $this->assertArrayHasKey('data', $results); + $data = $results['data']; + assert(is_array($data)); + $this->assertCount(1, $data); + $row = $data[0]; + assert(is_array($row)); + $this->assertCount(1, $row); + // Query API returns indexed arrays, not associative arrays with column names + assert(isset($row[0])); + $this->assertIsNumeric($row[0]); + $this->assertGreaterThanOrEqual(0, (int) $row[0]); + } +} diff --git a/tests/Functional/QueryServiceFunctionalTest.php b/tests/Functional/QueryServiceFunctionalTest.php new file mode 100644 index 0000000..7d5231e --- /dev/null +++ b/tests/Functional/QueryServiceFunctionalTest.php @@ -0,0 +1,295 @@ +queryClient->healthCheck(); + + $this->assertArrayHasKey('service', $result); + $this->assertArrayHasKey('status', $result); + $this->assertArrayHasKey('timestamp', $result); + $this->assertArrayHasKey('version', $result); + + $this->assertEquals('query', $result['service']); + $this->assertEquals('ok', $result['status']); + } + + public function testSubmitAndGetSimpleQuery(): void + { + // Create test table with sample data + $tableName = $this->createTestTable(); + + // Submit a simple SELECT query + $response = $this->queryClient->submitQueryJob( + $this->getTestBranchId(), + $this->getTestWorkspaceId(), + [ + 'statements' => [sprintf('SELECT COUNT(*) as row_count FROM %s', $tableName)], + 'transactional' => false, + ], + ); + + $this->assertArrayHasKey('queryJobId', $response); + $queryJobId = $response['queryJobId']; + assert(is_string($queryJobId)); + $this->assertNotEmpty($queryJobId); + + // Wait for job completion + $finalStatus = $this->waitForJobCompletion($queryJobId); + + $this->assertEquals('completed', $finalStatus['status']); + $this->assertEquals($queryJobId, $finalStatus['queryJobId']); + $this->assertArrayHasKey('statements', $finalStatus); + $statements = $finalStatus['statements']; + assert(is_array($statements)); + $this->assertCount(1, $statements); + + $statement = $statements[0]; + assert(is_array($statement)); + $this->assertEquals('completed', $statement['status']); + + // Get job results + $this->assertArrayHasKey('statementId', $statement); + $results = $this->queryClient->getJobResults($queryJobId, $statement['statementId']); + + $this->assertArrayHasKey('data', $results); + $this->assertArrayHasKey('status', $results); + $this->assertEquals('completed', $results['status']); + + // Verify the result contains our count + $this->assertArrayHasKey('data', $results); + $data = $results['data']; + assert(is_array($data)); + $this->assertCount(1, $data); + $row = $data[0]; + assert(is_array($row)); + $this->assertEquals(3, $row[0]); // We inserted 3 rows + } + + public function testSubmitTransactionalQuery(): void + { + // Create test table + $tableName = $this->createTestTable(); + + // Submit transactional queries (INSERT and SELECT) + $response = $this->queryClient->submitQueryJob( + $this->getTestBranchId(), + $this->getTestWorkspaceId(), + [ + 'statements' => [ + sprintf('INSERT INTO %s (id, name, value) VALUES (4, \'test4\', 400)', $tableName), + sprintf('SELECT COUNT(*) as row_count FROM %s', $tableName), + ], + 'transactional' => true, + ], + ); + + $this->assertArrayHasKey('queryJobId', $response); + $queryJobId = $response['queryJobId']; + assert(is_string($queryJobId)); + + // Wait for completion + $finalStatus = $this->waitForJobCompletion($queryJobId); + + $this->assertEquals('completed', $finalStatus['status']); + $this->assertArrayHasKey('statements', $finalStatus); + $statements = $finalStatus['statements']; + assert(is_array($statements)); + $this->assertCount(2, $statements); + + // Check INSERT statement + $insertStatement = $statements[0]; + assert(is_array($insertStatement)); + $this->assertEquals('completed', $insertStatement['status']); + + // Check SELECT statement and its results + $selectStatement = $statements[1]; + assert(is_array($selectStatement)); + $this->assertEquals('completed', $selectStatement['status']); + + $this->assertArrayHasKey('statementId', $selectStatement); + $results = $this->queryClient->getJobResults($queryJobId, $selectStatement['statementId']); + $this->assertArrayHasKey('data', $results); + $data = $results['data']; + assert(is_array($data)); + $row = $data[0]; + assert(is_array($row)); + $this->assertEquals(4, $row[0]); // Should be 4 rows now + } + + public function testCancelQueryJob(): void + { + // Create test table + $tableName = $this->createTestTable(); + + // Submit a cross join query that takes some time to process + $response = $this->queryClient->submitQueryJob( + $this->getTestBranchId(), + $this->getTestWorkspaceId(), + [ + 'statements' => [ + sprintf(' + SELECT a.id, b.id as id2, a.name, b.name as name2 + FROM %s a + CROSS JOIN %s b + CROSS JOIN %s c + ORDER BY 1, 2 + ', $tableName, $tableName, $tableName), + ], + 'transactional' => false, + ], + ); + + $this->assertArrayHasKey('queryJobId', $response); + $queryJobId = $response['queryJobId']; + assert(is_string($queryJobId)); + $this->assertNotEmpty($queryJobId); + + // Cancel the job + $cancelResponse = $this->queryClient->cancelJob($queryJobId, [ + 'reason' => 'Test cancellation', + ]); + + $this->assertEquals($queryJobId, $cancelResponse['queryJobId']); + + // Wait for final status + $finalStatus = $this->waitForJobCompletion($queryJobId, 15); + + // Job should be canceled + $this->assertEquals('canceled', $finalStatus['status']); + $this->assertArrayHasKey('cancellationReason', $finalStatus); + $this->assertEquals('Test cancellation', $finalStatus['cancellationReason']); + $this->assertArrayHasKey('canceledAt', $finalStatus); + + // Verify job has statements but don't assert on their status + $this->assertArrayHasKey('statements', $finalStatus); + $statements = $finalStatus['statements']; + assert(is_array($statements)); + $this->assertCount(1, $statements); + } + + public function testQueryJobWithInvalidSQL(): void + { + // Submit query with invalid SQL + $response = $this->queryClient->submitQueryJob( + $this->getTestBranchId(), + $this->getTestWorkspaceId(), + [ + 'statements' => ['SELECT * FROM non_existent_table_12345'], + 'transactional' => false, + ], + ); + + $this->assertArrayHasKey('queryJobId', $response); + $queryJobId = $response['queryJobId']; + assert(is_string($queryJobId)); + + // Wait for job completion + $finalStatus = $this->waitForJobCompletion($queryJobId); + + // Job should fail due to invalid SQL + $this->assertEquals('failed', $finalStatus['status']); + $this->assertArrayHasKey('statements', $finalStatus); + $statements = $finalStatus['statements']; + assert(is_array($statements)); + $this->assertCount(1, $statements); + + // The statement remains in 'waiting' status because the job failed before execution + $statement = $statements[0]; + assert(is_array($statement)); + $this->assertEquals('waiting', $statement['status']); + assert(is_string($statement['query'])); + $this->assertEquals('SELECT * FROM non_existent_table_12345', $statement['query']); + } + + public function testQueryJobWithEmptyStatements(): void + { + $this->expectException(ClientException::class); + + $this->queryClient->submitQueryJob( + $this->getTestBranchId(), + $this->getTestWorkspaceId(), + [ + 'statements' => [], + 'transactional' => false, + ], + ); + } + + public function testQueryJobWithInvalidBranch(): void + { + // Submit job with an invalid branch ID + $response = $this->queryClient->submitQueryJob( + 'non-existent-branch-12345', + $this->getTestWorkspaceId(), + [ + 'statements' => ['SELECT 1'], + 'transactional' => false, + ], + ); + + $this->assertArrayHasKey('queryJobId', $response); + $queryJobId = $response['queryJobId']; + assert(is_string($queryJobId)); + + // Wait for job completion + $finalStatus = $this->waitForJobCompletion($queryJobId); + + // Query Service accepts invalid branch IDs and executes successfully + $this->assertEquals('completed', $finalStatus['status']); + $this->assertArrayHasKey('statements', $finalStatus); + $statements = $finalStatus['statements']; + assert(is_array($statements)); + $this->assertCount(1, $statements); + + $statement = $statements[0]; + assert(is_array($statement)); + $this->assertEquals('completed', $statement['status']); + assert(is_string($statement['query'])); + $this->assertEquals('SELECT 1', $statement['query']); + assert(is_int($statement['rowsAffected'])); + $this->assertEquals(0, $statement['rowsAffected']); + } + + public function testQueryJobWithInvalidWorkspace(): void + { + $this->expectException(ClientException::class); + + $this->queryClient->submitQueryJob( + $this->getTestBranchId(), + 'non-existent-workspace-12345', + [ + 'statements' => ['SELECT 1'], + 'transactional' => false, + ], + ); + } + + public function testGetJobStatusForNonExistentJob(): void + { + $this->expectException(ClientException::class); + + $this->queryClient->getJobStatus('non-existent-job-12345'); + } + + public function testGetJobResultsForNonExistentJob(): void + { + $this->expectException(ClientException::class); + + $this->queryClient->getJobResults('non-existent-job-12345', 'non-existent-statement-12345'); + } + + public function testCancelNonExistentJob(): void + { + $this->expectException(ClientException::class); + + $this->queryClient->cancelJob('non-existent-job-12345', ['reason' => 'Test']); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..ad8e8b3 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,16 @@ +load($envFile); + } +} From e2d5335056676d1a2983eccc62a4721f81936d87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimi=CC=81r=20Kris=CC=8Cka?= Date: Tue, 19 Aug 2025 02:05:40 +0200 Subject: [PATCH 02/23] Allow root for composer --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index 160a098..8fce465 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,7 @@ FROM php:8.2 +ENV COMPOSER_ALLOW_SUPERUSER 1 + RUN apt-get update -q \ && apt-get install git unzip \ -y --no-install-recommends From 7b5b1fcf78f0c8dbe1011819ce4b2c5a41371526 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimi=CC=81r=20Kris=CC=8Cka?= Date: Tue, 19 Aug 2025 02:13:14 +0200 Subject: [PATCH 03/23] Tune build process --- Dockerfile | 11 +++++++++-- docker/php.ini | 7 ------- 2 files changed, 9 insertions(+), 9 deletions(-) delete mode 100644 docker/php.ini diff --git a/Dockerfile b/Dockerfile index 8fce465..fb06efb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,18 @@ FROM php:8.2 ENV COMPOSER_ALLOW_SUPERUSER 1 +ARG COMPOSER_FLAGS="--prefer-dist --no-interaction --classmap-authoritative --no-scripts" RUN apt-get update -q \ && apt-get install git unzip \ -y --no-install-recommends -COPY docker/php.ini /usr/local/etc/php/php.ini - +RUN echo "memory_limit = -1" >> /usr/local/etc/php/php.ini RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin/ --filename=composer + +WORKDIR /code/ + +COPY composer.json . +RUN composer install $COMPOSER_FLAGS --no-scripts --no-autoloader +COPY . . +RUN composer install $COMPOSER_FLAGS diff --git a/docker/php.ini b/docker/php.ini deleted file mode 100644 index 7351f22..0000000 --- a/docker/php.ini +++ /dev/null @@ -1,7 +0,0 @@ -; Maximum amount of memory a script may consume (128MB) -; http://php.net/memory-limit -memory_limit = -1 - -; Defines the default timezone used by the date functions -; http://php.net/date.timezone -date.timezone = UTC From 2a1c10dba7a2a7c4142617c73b502ba8bab614cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimi=CC=81r=20Kris=CC=8Cka?= Date: Tue, 19 Aug 2025 07:45:16 +0200 Subject: [PATCH 04/23] Install dependencies before running CI --- .github/workflows/branch.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/branch.yml b/.github/workflows/branch.yml index 04ac493..6fc730e 100644 --- a/.github/workflows/branch.yml +++ b/.github/workflows/branch.yml @@ -12,4 +12,5 @@ jobs: - uses: actions/checkout@v2 - run: | docker compose build dev + docker compose run --rm dev composer install docker compose run --rm dev composer ci From 2018c63ced9850e6e6e9acc8564131fa14777a61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimi=CC=81r=20Kris=CC=8Cka?= Date: Tue, 19 Aug 2025 08:02:26 +0200 Subject: [PATCH 05/23] Pass env --- .github/workflows/branch.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/branch.yml b/.github/workflows/branch.yml index 6fc730e..65165da 100644 --- a/.github/workflows/branch.yml +++ b/.github/workflows/branch.yml @@ -13,4 +13,7 @@ jobs: - run: | docker compose build dev docker compose run --rm dev composer install - docker compose run --rm dev composer ci + docker compose run --rm \ + -e TESTS_QUERY_API_URL=${{ env.TESTS_QUERY_API_URL }} \ + -e TESTS_STORAGE_API_TOKEN={{ secrets.TESTS_STORAGE_API_TOKEN }} \ + dev composer ci From d07e4bb8d624507c22a1c4e08e6bf7cc7e8eceb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimi=CC=81r=20Kris=CC=8Cka?= Date: Tue, 19 Aug 2025 08:06:35 +0200 Subject: [PATCH 06/23] Improve readability --- .github/workflows/branch.yml | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/.github/workflows/branch.yml b/.github/workflows/branch.yml index 65165da..be909ec 100644 --- a/.github/workflows/branch.yml +++ b/.github/workflows/branch.yml @@ -8,12 +8,20 @@ on: jobs: build: runs-on: ubuntu-latest + steps: - - uses: actions/checkout@v2 - - run: | - docker compose build dev - docker compose run --rm dev composer install + - name: Checkout code + uses: actions/checkout@v2 + + - name: Build development container + run: docker compose build dev + + - name: Install dependencies + run: docker compose run --rm dev composer install + + - name: Run tests and CI checks + run: | docker compose run --rm \ - -e TESTS_QUERY_API_URL=${{ env.TESTS_QUERY_API_URL }} \ - -e TESTS_STORAGE_API_TOKEN={{ secrets.TESTS_STORAGE_API_TOKEN }} \ + -e TESTS_QUERY_API_URL=${{ env.TESTS_QUERY_API_URL }} \ + -e TESTS_STORAGE_API_TOKEN=${{ secrets.TESTS_STORAGE_API_TOKEN }} \ dev composer ci From aeaece023aa184bf2eec69b51e22e3ac7ed56f67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimi=CC=81r=20Kris=CC=8Cka?= Date: Tue, 19 Aug 2025 08:24:04 +0200 Subject: [PATCH 07/23] Fix typo --- .github/workflows/branch.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/branch.yml b/.github/workflows/branch.yml index be909ec..98daeab 100644 --- a/.github/workflows/branch.yml +++ b/.github/workflows/branch.yml @@ -22,6 +22,6 @@ jobs: - name: Run tests and CI checks run: | docker compose run --rm \ - -e TESTS_QUERY_API_URL=${{ env.TESTS_QUERY_API_URL }} \ + -e TESTS_QUERY_API_URL=${{ vars.TESTS_QUERY_API_URL }} \ -e TESTS_STORAGE_API_TOKEN=${{ secrets.TESTS_STORAGE_API_TOKEN }} \ dev composer ci From 41cb1b875a02cc54436c1907d0fd675be9c10e8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimi=CC=81r=20Kris=CC=8Cka?= Date: Tue, 19 Aug 2025 11:50:46 +0200 Subject: [PATCH 08/23] Fix asserts for statements --- tests/Functional/BasicQueryTest.php | 8 ++++---- tests/Functional/QueryServiceFunctionalTest.php | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/Functional/BasicQueryTest.php b/tests/Functional/BasicQueryTest.php index 4b34897..c464a29 100644 --- a/tests/Functional/BasicQueryTest.php +++ b/tests/Functional/BasicQueryTest.php @@ -38,8 +38,8 @@ public function testSubmitSimpleSelectQuery(): void $this->assertEquals('completed', $statement['status']); // Get job results - $this->assertArrayHasKey('statementId', $statement); - $results = $this->queryClient->getJobResults($queryJobId, $statement['statementId']); + $this->assertArrayHasKey('id', $statement); + $results = $this->queryClient->getJobResults($queryJobId, $statement['id']); $this->assertArrayHasKey('status', $results); $this->assertEquals('completed', $results['status']); @@ -94,8 +94,8 @@ public function testSubmitInformationSchemaQuery(): void $this->assertEquals('completed', $statement['status']); // Get job results - $this->assertArrayHasKey('statementId', $statement); - $results = $this->queryClient->getJobResults($queryJobId, $statement['statementId']); + $this->assertArrayHasKey('id', $statement); + $results = $this->queryClient->getJobResults($queryJobId, $statement['id']); $this->assertArrayHasKey('status', $results); $this->assertEquals('completed', $results['status']); diff --git a/tests/Functional/QueryServiceFunctionalTest.php b/tests/Functional/QueryServiceFunctionalTest.php index 7d5231e..11996fa 100644 --- a/tests/Functional/QueryServiceFunctionalTest.php +++ b/tests/Functional/QueryServiceFunctionalTest.php @@ -56,8 +56,8 @@ public function testSubmitAndGetSimpleQuery(): void $this->assertEquals('completed', $statement['status']); // Get job results - $this->assertArrayHasKey('statementId', $statement); - $results = $this->queryClient->getJobResults($queryJobId, $statement['statementId']); + $this->assertArrayHasKey('id', $statement); + $results = $this->queryClient->getJobResults($queryJobId, $statement['id']); $this->assertArrayHasKey('data', $results); $this->assertArrayHasKey('status', $results); @@ -114,8 +114,8 @@ public function testSubmitTransactionalQuery(): void assert(is_array($selectStatement)); $this->assertEquals('completed', $selectStatement['status']); - $this->assertArrayHasKey('statementId', $selectStatement); - $results = $this->queryClient->getJobResults($queryJobId, $selectStatement['statementId']); + $this->assertArrayHasKey('id', $selectStatement); + $results = $this->queryClient->getJobResults($queryJobId, $selectStatement['id']); $this->assertArrayHasKey('data', $results); $data = $results['data']; assert(is_array($data)); From d98a9efd044fa7cb6fee5965f4b6f612a9016019 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimi=CC=81r=20Kris=CC=8Cka?= Date: Tue, 19 Aug 2025 12:01:34 +0200 Subject: [PATCH 09/23] Use php 8.4 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index fb06efb..7335b4f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM php:8.2 +FROM php:8.4 ENV COMPOSER_ALLOW_SUPERUSER 1 ARG COMPOSER_FLAGS="--prefer-dist --no-interaction --classmap-authoritative --no-scripts" From 1ec5bc2f1afe30c0684052bba21bfe8dd5e6fba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimi=CC=81r=20Kris=CC=8Cka?= Date: Tue, 19 Aug 2025 12:03:57 +0200 Subject: [PATCH 10/23] Tune readme --- README.md | 76 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 42 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 442fd48..ff6cf0d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ PHP client for Keboola Query Service API. ## Installation -```bash +```shell composer require keboola/query-api-php-client ``` @@ -36,57 +36,65 @@ $results = $client->getJobResults($queryJobId, $statementId); // Cancel job $client->cancelJob($queryJobId, ['reason' => 'User requested cancellation']); + +// Health check +$health = $client->healthCheck(); ``` -## Development +## Configuration Options -### Running Tests +The client constructor accepts the following configuration options: -Run unit tests: -```bash -composer tests -``` +- `url` (required): Query Service API URL (e.g., `https://query.keboola.com`) +- `token` (required): Storage API token +- `storageApiUrl` (optional): Storage API URL (auto-derived from Query Service URL if not provided) +- `backoffMaxTries` (optional): Number of retry attempts for failed requests (default: 3) +- `userAgent` (optional): Additional user agent string to append +- `handler` (optional): Custom Guzzle handler stack +- `logger` (optional): PSR-3 logger instance -Run functional tests (requires .env file with test configuration): -```bash -composer tests-functional -``` +## API Methods -Run all tests: -```bash -composer tests-all -``` +- `submitQueryJob(string $branchId, string $workspaceId, array $requestBody): array` +- `getJobStatus(string $queryJobId): array` +- `getJobResults(string $queryJobId, string $statementId): array` +- `cancelJob(string $queryJobId, array $requestBody = []): array` +- `healthCheck(): array` +- `getStorageApiClient(): StorageApiClient` -### Functional Tests Setup +## Development -For functional tests, copy `.env.example` to `.env` and configure: +### Requirements -```bash -cp .env.example .env -``` +- PHP 7.4+ +- ext-json +- Composer -Then edit `.env` with your test environment settings: +### Running Tests -```env -TESTS_HOSTNAME_SUFFIX=.keboola.com -TESTS_STORAGE_API_TOKEN=your-storage-api-token +Run tests: +```shell +composer run tests ``` -**Note**: Functional tests will create and delete temporary branches and workspaces in your Keboola project. Make sure to use a development/test project with appropriate permissions. - ### Code Quality Run code style check: -```bash -composer phpcs +```shell +composer run phpcs +``` + +Fix code style issues: +```shell +composer run phpcbf ``` Run static analysis: -```bash -composer phpstan +```shell +composer run phpstan ``` -Run all CI checks: -```bash -composer ci -``` \ No newline at end of file +Run all CI checks. Check [Github Workflows](./.github/workflows) for more details +```shell +composer run ci +``` From 2bb51db32fcee9f48c4e1248ad4f137aa1e8850e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimi=CC=81r=20Kris=CC=8Cka?= Date: Tue, 19 Aug 2025 12:10:31 +0200 Subject: [PATCH 11/23] Require php ^8.4 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 9f4dc84..a5cf192 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ } }, "require": { - "php": ">=7.4", + "php": "^8.4", "ext-json": "*", "guzzlehttp/guzzle": "~7.0", "keboola/storage-api-client": "^18.0", From 4f27a98ba9b38105dc173fd543fe9e95664c3d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimi=CC=81r=20Kris=CC=8Cka?= Date: Tue, 19 Aug 2025 17:03:39 +0200 Subject: [PATCH 12/23] Keep Storage API client only for dev --- README.md | 1 - composer.json | 2 +- src/Client.php | 75 --------------------- tests/ClientTest.php | 72 ++------------------ tests/Functional/BaseFunctionalTestCase.php | 31 ++++++++- 5 files changed, 37 insertions(+), 144 deletions(-) diff --git a/README.md b/README.md index ff6cf0d..e7e7378 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,6 @@ The client constructor accepts the following configuration options: - `getJobResults(string $queryJobId, string $statementId): array` - `cancelJob(string $queryJobId, array $requestBody = []): array` - `healthCheck(): array` -- `getStorageApiClient(): StorageApiClient` ## Development diff --git a/composer.json b/composer.json index a5cf192..def874d 100644 --- a/composer.json +++ b/composer.json @@ -25,11 +25,11 @@ "php": "^8.4", "ext-json": "*", "guzzlehttp/guzzle": "~7.0", - "keboola/storage-api-client": "^18.0", "psr/log": "~1.0" }, "require-dev": { "keboola/coding-standard": "^16.0", + "keboola/storage-api-client": "^18.0", "phpstan/phpstan": "^1.8", "phpstan/phpstan-phpunit": "^1.1", "phpunit/phpunit": "^9.5", diff --git a/src/Client.php b/src/Client.php index dab8a34..d6c1593 100644 --- a/src/Client.php +++ b/src/Client.php @@ -14,12 +14,8 @@ use GuzzleHttp\Psr7\Request; use InvalidArgumentException; use JsonException; -use Keboola\StorageApi\Client as StorageApiClient; -use Keboola\StorageApi\ClientException as StorageApiClientException; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; -use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; use Throwable; class Client @@ -34,18 +30,14 @@ class Client private int $backoffMaxTries; private string $userAgent; private GuzzleClient $client; - private LoggerInterface $logger; - private StorageApiClient $storageApiClient; /** * @param array{ * url: string, * token: string, - * storageApiUrl?: string, * backoffMaxTries?: int, * userAgent?: string, * handler?: HandlerStack, - * logger?: LoggerInterface, * } $config */ public function __construct(array $config) @@ -61,14 +53,11 @@ public function __construct(array $config) $this->tokenString = $config['token']; $this->backoffMaxTries = $config['backoffMaxTries'] ?? self::DEFAULT_BACKOFF_RETRIES; $this->userAgent = self::DEFAULT_USER_AGENT; - $this->logger = $config['logger'] ?? new NullLogger(); if (isset($config['userAgent'])) { $this->userAgent .= ' ' . $config['userAgent']; } - $this->initStorageApiClient($config); - // Allow override of Query API URL via environment variable for functional testing // This is specifically for overriding in functional tests when needed if (!empty($_ENV['TESTS_QUERY_API_URL_OVERRIDE'])) { @@ -125,58 +114,6 @@ private function createRetryDelay(): callable }; } - /** - * @param array{storageApiUrl?: string, logger?: LoggerInterface} $config - */ - private function initStorageApiClient(array $config): void - { - $storageApiUrl = $config['storageApiUrl'] ?? $this->deriveStorageApiUrl(); - - $this->storageApiClient = new StorageApiClient([ - 'url' => $storageApiUrl, - 'token' => $this->tokenString, - 'logger' => $this->logger, - ]); - } - - private function deriveStorageApiUrl(): string - { - // Convert Query Service URL to Storage API URL - // e.g., https://query.keboola.com -> https://connection.keboola.com - // e.g., https://query.eu-central-1.keboola.com -> https://connection.eu-central-1.keboola.com - $parsedUrl = parse_url($this->apiUrl); - if ($parsedUrl === false) { - throw new InvalidArgumentException('Invalid Query Service URL'); - } - - $scheme = $parsedUrl['scheme'] ?? 'https'; - $host = $parsedUrl['host'] ?? ''; - $port = isset($parsedUrl['port']) ? ':' . $parsedUrl['port'] : ''; - - // Convert query.* subdomain to connection.* for Storage API - if (strpos($host, 'query.') === 0) { - $host = str_replace('query.', 'connection.', $host); - } - - return sprintf('%s://%s%s', $scheme, $host, $port); - } - - /** - * Verify Storage API token before making Query Service requests - */ - private function verifyStorageApiToken(): void - { - try { - $this->storageApiClient->verifyToken(); - } catch (StorageApiClientException $e) { - throw new ClientException( - 'Storage API token verification failed: ' . $e->getMessage(), - $e->getCode(), - $e, - ); - } - } - /** * Submit a new query job * @@ -185,7 +122,6 @@ private function verifyStorageApiToken(): void */ public function submitQueryJob(string $branchId, string $workspaceId, array $requestBody): array { - $this->verifyStorageApiToken(); $url = sprintf('/api/v1/branches/%s/workspaces/%s/queries', $branchId, $workspaceId); return $this->sendRequest('POST', $url, $requestBody); } @@ -197,7 +133,6 @@ public function submitQueryJob(string $branchId, string $workspaceId, array $req */ public function getJobStatus(string $queryJobId): array { - $this->verifyStorageApiToken(); $url = sprintf('/api/v1/queries/%s', $queryJobId); return $this->sendRequest('GET', $url); } @@ -210,7 +145,6 @@ public function getJobStatus(string $queryJobId): array */ public function cancelJob(string $queryJobId, array $requestBody = []): array { - $this->verifyStorageApiToken(); $url = sprintf('/api/v1/queries/%s/cancel', $queryJobId); return $this->sendRequest('POST', $url, $requestBody); } @@ -222,7 +156,6 @@ public function cancelJob(string $queryJobId, array $requestBody = []): array */ public function getJobResults(string $queryJobId, string $statementId): array { - $this->verifyStorageApiToken(); $url = sprintf('/api/v1/queries/%s/%s/results', $queryJobId, $statementId); return $this->sendRequest('GET', $url); } @@ -237,14 +170,6 @@ public function healthCheck(): array return $this->sendRequest('GET', '/health-check'); } - /** - * Get the Storage API client instance - */ - public function getStorageApiClient(): StorageApiClient - { - return $this->storageApiClient; - } - /** * @param array|null $requestBody * @return array diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 74c9a8b..6d88ed4 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -12,10 +12,7 @@ use InvalidArgumentException; use Keboola\QueryApi\Client; use Keboola\QueryApi\ClientException; -use Keboola\StorageApi\Client as StorageApiClient; -use Keboola\StorageApi\ClientException as StorageApiClientException; use PHPUnit\Framework\TestCase; -use ReflectionClass; class ClientTest extends TestCase { @@ -45,10 +42,7 @@ public function testSubmitQueryJob(): void new Response(201, [], json_encode(['queryJobId' => 'job-12345']) ?: ''), ]); - $storageApiClient = $this->createMock(StorageApiClient::class); - $storageApiClient->expects($this->once())->method('verifyToken'); - - $client = $this->createClientWithMockHandler($mockHandler, $storageApiClient); + $client = $this->createClientWithMockHandler($mockHandler); $result = $client->submitQueryJob('main', 'workspace-123', [ 'statements' => ['SELECT * FROM table1'], @@ -68,10 +62,7 @@ public function testGetJobStatus(): void ]) ?: ''), ]); - $storageApiClient = $this->createMock(StorageApiClient::class); - $storageApiClient->expects($this->once())->method('verifyToken'); - - $client = $this->createClientWithMockHandler($mockHandler, $storageApiClient); + $client = $this->createClientWithMockHandler($mockHandler); $result = $client->getJobStatus('job-12345'); @@ -85,10 +76,7 @@ public function testCancelJob(): void new Response(200, [], json_encode(['queryJobId' => 'job-12345']) ?: ''), ]); - $storageApiClient = $this->createMock(StorageApiClient::class); - $storageApiClient->expects($this->once())->method('verifyToken'); - - $client = $this->createClientWithMockHandler($mockHandler, $storageApiClient); + $client = $this->createClientWithMockHandler($mockHandler); $result = $client->cancelJob('job-12345', ['reason' => 'User requested']); @@ -105,10 +93,7 @@ public function testGetJobResults(): void ]) ?: ''), ]); - $storageApiClient = $this->createMock(StorageApiClient::class); - $storageApiClient->expects($this->once())->method('verifyToken'); - - $client = $this->createClientWithMockHandler($mockHandler, $storageApiClient); + $client = $this->createClientWithMockHandler($mockHandler); $result = $client->getJobResults('job-12345', 'stmt-67890'); @@ -129,11 +114,7 @@ public function testHealthCheck(): void ]) ?: ''), ]); - $storageApiClient = $this->createMock(StorageApiClient::class); - // Health check should NOT verify token - $storageApiClient->expects($this->never())->method('verifyToken'); - - $client = $this->createClientWithMockHandler($mockHandler, $storageApiClient); + $client = $this->createClientWithMockHandler($mockHandler); $result = $client->healthCheck(); @@ -141,25 +122,6 @@ public function testHealthCheck(): void $this->assertEquals('ok', $result['status']); } - public function testStorageApiTokenVerificationFailure(): void - { - $mockHandler = new MockHandler([]); - - $storageApiClient = $this->createMock(StorageApiClient::class); - $storageApiClient->expects($this->once()) - ->method('verifyToken') - ->willThrowException(new StorageApiClientException('Invalid token')); - - $client = $this->createClientWithMockHandler($mockHandler, $storageApiClient); - - $this->expectException(ClientException::class); - $this->expectExceptionMessage('Storage API token verification failed: Invalid token'); - - $client->submitQueryJob('main', 'workspace-123', [ - 'statements' => ['SELECT * FROM table1'], - ]); - } - public function testStorageApiUrlDerivation(): void { // Test with Query Service URL to ensure proper Storage API URL derivation @@ -168,33 +130,13 @@ public function testStorageApiUrlDerivation(): void 'token' => 'test-token', ]); - // Since we can't easily mock the StorageApiClient constructor, - // we'll just verify the client can be created without errors $this->assertInstanceOf(Client::class, $client); } - private function createClientWithMockHandler( - MockHandler $mockHandler, - ?StorageApiClient $storageApiClient = null, - ): Client { + private function createClientWithMockHandler(MockHandler $mockHandler): Client + { $handlerStack = HandlerStack::create($mockHandler); - if ($storageApiClient) { - // Use reflection to inject the mocked Storage API client - $client = new Client([ - 'url' => 'https://query.test.keboola.com', - 'token' => 'test-token', - 'handler' => $handlerStack, - ]); - - $reflection = new ReflectionClass($client); - $property = $reflection->getProperty('storageApiClient'); - $property->setAccessible(true); - $property->setValue($client, $storageApiClient); - - return $client; - } - return new Client([ 'url' => 'https://query.test.keboola.com', 'token' => 'test-token', diff --git a/tests/Functional/BaseFunctionalTestCase.php b/tests/Functional/BaseFunctionalTestCase.php index ab700f3..862aacf 100644 --- a/tests/Functional/BaseFunctionalTestCase.php +++ b/tests/Functional/BaseFunctionalTestCase.php @@ -4,6 +4,7 @@ namespace Keboola\QueryApi\Tests\Functional; +use InvalidArgumentException; use Keboola\QueryApi\Client; use Keboola\StorageApi\BranchAwareClient; use Keboola\StorageApi\Client as StorageApiClient; @@ -69,8 +70,34 @@ private function initializeClients(): void 'token' => $storageApiToken, ]); - // Get Storage API client from Query API client - $this->storageApiClient = $this->queryClient->getStorageApiClient(); + // Create Storage API client directly for tests + $storageApiUrl = $this->deriveStorageApiUrl($queryApiUrl); + $this->storageApiClient = new StorageApiClient([ + 'url' => $storageApiUrl, + 'token' => $storageApiToken, + ]); + } + + private function deriveStorageApiUrl(string $queryApiUrl): string + { + // Convert Query Service URL to Storage API URL + // e.g., https://query.keboola.com -> https://connection.keboola.com + // e.g., https://query.eu-central-1.keboola.com -> https://connection.eu-central-1.keboola.com + $parsedUrl = parse_url($queryApiUrl); + if ($parsedUrl === false) { + throw new InvalidArgumentException('Invalid Query Service URL'); + } + + $scheme = $parsedUrl['scheme'] ?? 'https'; + $host = $parsedUrl['host'] ?? ''; + $port = isset($parsedUrl['port']) ? ':' . $parsedUrl['port'] : ''; + + // Replace 'query.' with 'connection.' for Storage API + if (str_starts_with($host, 'query.')) { + $host = str_replace('query.', 'connection.', $host); + } + + return sprintf('%s://%s%s', $scheme, $host, $port); } private function findDefaultBranch(): void From 4d0d73ac5b0f32c64503c4204dfd14f9e126ea23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimi=CC=81r=20Kris=CC=8Cka?= Date: Tue, 19 Aug 2025 17:04:10 +0200 Subject: [PATCH 13/23] Fix test after release (will be changed after another one) --- tests/Functional/QueryServiceFunctionalTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Functional/QueryServiceFunctionalTest.php b/tests/Functional/QueryServiceFunctionalTest.php index 11996fa..e0803ed 100644 --- a/tests/Functional/QueryServiceFunctionalTest.php +++ b/tests/Functional/QueryServiceFunctionalTest.php @@ -204,7 +204,7 @@ public function testQueryJobWithInvalidSQL(): void // The statement remains in 'waiting' status because the job failed before execution $statement = $statements[0]; assert(is_array($statement)); - $this->assertEquals('waiting', $statement['status']); + $this->assertEquals('completed', $statement['status']); assert(is_string($statement['query'])); $this->assertEquals('SELECT * FROM non_existent_table_12345', $statement['query']); } From e394d0b391b43a0c3444ba19fb9cfafe25920e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimi=CC=81r=20Kris=CC=8Cka?= Date: Tue, 19 Aug 2025 17:29:49 +0200 Subject: [PATCH 14/23] Simplify env variables. Remove Storage API resolver --- .env.dist | 4 +--- .github/workflows/branch.yml | 1 + docker-compose.yml | 2 +- src/Client.php | 6 ----- tests/Functional/BaseFunctionalTestCase.php | 26 ++------------------- 5 files changed, 5 insertions(+), 34 deletions(-) diff --git a/.env.dist b/.env.dist index 8a31b88..ad1ae3a 100644 --- a/.env.dist +++ b/.env.dist @@ -1,6 +1,4 @@ # Minimal setup for getting Query API Client TESTS_QUERY_API_URL= TESTS_STORAGE_API_TOKEN= - -# Override if you want to test with dev Query API -TESTS_QUERY_API_URL_OVERRIDE= +TESTS_STORAGE_API_URL= diff --git a/.github/workflows/branch.yml b/.github/workflows/branch.yml index 98daeab..9cae8b2 100644 --- a/.github/workflows/branch.yml +++ b/.github/workflows/branch.yml @@ -24,4 +24,5 @@ jobs: docker compose run --rm \ -e TESTS_QUERY_API_URL=${{ vars.TESTS_QUERY_API_URL }} \ -e TESTS_STORAGE_API_TOKEN=${{ secrets.TESTS_STORAGE_API_TOKEN }} \ + -e TESTS_STORAGE_API_URL=${{ vars.TESTS_STORAGE_API_URL }} \ dev composer ci diff --git a/docker-compose.yml b/docker-compose.yml index 764a860..88ec0d0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,4 +9,4 @@ services: environment: - TESTS_QUERY_API_URL - TESTS_STORAGE_API_TOKEN - - TESTS_QUERY_API_URL_OVERRIDE + - TESTS_STORAGE_API_URL diff --git a/src/Client.php b/src/Client.php index d6c1593..5d82849 100644 --- a/src/Client.php +++ b/src/Client.php @@ -58,12 +58,6 @@ public function __construct(array $config) $this->userAgent .= ' ' . $config['userAgent']; } - // Allow override of Query API URL via environment variable for functional testing - // This is specifically for overriding in functional tests when needed - if (!empty($_ENV['TESTS_QUERY_API_URL_OVERRIDE'])) { - $this->apiUrl = rtrim($_ENV['TESTS_QUERY_API_URL_OVERRIDE'], '/'); - } - $this->initClient($config); } diff --git a/tests/Functional/BaseFunctionalTestCase.php b/tests/Functional/BaseFunctionalTestCase.php index 862aacf..603fddb 100644 --- a/tests/Functional/BaseFunctionalTestCase.php +++ b/tests/Functional/BaseFunctionalTestCase.php @@ -49,7 +49,7 @@ protected function tearDown(): void private function validateEnvironmentVariables(): void { - $requiredVars = ['TESTS_STORAGE_API_TOKEN', 'TESTS_QUERY_API_URL']; + $requiredVars = ['TESTS_STORAGE_API_TOKEN', 'TESTS_QUERY_API_URL', 'TESTS_STORAGE_API_URL']; foreach ($requiredVars as $var) { if (empty($_ENV[$var])) { @@ -64,6 +64,7 @@ private function initializeClients(): void { $storageApiToken = $_ENV['TESTS_STORAGE_API_TOKEN']; $queryApiUrl = $_ENV['TESTS_QUERY_API_URL']; + $storageApiUrl = $_ENV['TESTS_STORAGE_API_URL']; $this->queryClient = new Client([ 'url' => $queryApiUrl, @@ -71,35 +72,12 @@ private function initializeClients(): void ]); // Create Storage API client directly for tests - $storageApiUrl = $this->deriveStorageApiUrl($queryApiUrl); $this->storageApiClient = new StorageApiClient([ 'url' => $storageApiUrl, 'token' => $storageApiToken, ]); } - private function deriveStorageApiUrl(string $queryApiUrl): string - { - // Convert Query Service URL to Storage API URL - // e.g., https://query.keboola.com -> https://connection.keboola.com - // e.g., https://query.eu-central-1.keboola.com -> https://connection.eu-central-1.keboola.com - $parsedUrl = parse_url($queryApiUrl); - if ($parsedUrl === false) { - throw new InvalidArgumentException('Invalid Query Service URL'); - } - - $scheme = $parsedUrl['scheme'] ?? 'https'; - $host = $parsedUrl['host'] ?? ''; - $port = isset($parsedUrl['port']) ? ':' . $parsedUrl['port'] : ''; - - // Replace 'query.' with 'connection.' for Storage API - if (str_starts_with($host, 'query.')) { - $host = str_replace('query.', 'connection.', $host); - } - - return sprintf('%s://%s%s', $scheme, $host, $port); - } - private function findDefaultBranch(): void { $devBranches = new DevBranches($this->storageApiClient); From c529c146aad890441259b5b611bde9af4fd2df99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimi=CC=81r=20Kris=CC=8Cka?= Date: Tue, 19 Aug 2025 17:31:17 +0200 Subject: [PATCH 15/23] Remove duplicate info from README --- README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/README.md b/README.md index e7e7378..ebc6418 100644 --- a/README.md +++ b/README.md @@ -63,12 +63,6 @@ The client constructor accepts the following configuration options: ## Development -### Requirements - -- PHP 7.4+ -- ext-json -- Composer - ### Running Tests Run tests: From 1de95813aa88836247af388c753da3cd76720eb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimi=CC=81r=20Kris=CC=8Cka?= Date: Tue, 19 Aug 2025 17:33:05 +0200 Subject: [PATCH 16/23] Remove unnecessary file --- tests/Functional/.gitkeep | 1 - 1 file changed, 1 deletion(-) delete mode 100644 tests/Functional/.gitkeep diff --git a/tests/Functional/.gitkeep b/tests/Functional/.gitkeep deleted file mode 100644 index 72a4514..0000000 --- a/tests/Functional/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# This file ensures the Functional directory is tracked in git \ No newline at end of file From 04b88d29d93a2027d3123442f1c5db9ee2fde85e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimi=CC=81r=20Kris=CC=8Cka?= Date: Tue, 19 Aug 2025 17:43:06 +0200 Subject: [PATCH 17/23] Add test for invalid token --- .../Functional/QueryServiceFunctionalTest.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/Functional/QueryServiceFunctionalTest.php b/tests/Functional/QueryServiceFunctionalTest.php index e0803ed..5957549 100644 --- a/tests/Functional/QueryServiceFunctionalTest.php +++ b/tests/Functional/QueryServiceFunctionalTest.php @@ -4,6 +4,7 @@ namespace Keboola\QueryApi\Tests\Functional; +use Keboola\QueryApi\Client; use Keboola\QueryApi\ClientException; class QueryServiceFunctionalTest extends BaseFunctionalTestCase @@ -292,4 +293,25 @@ public function testCancelNonExistentJob(): void $this->queryClient->cancelJob('non-existent-job-12345', ['reason' => 'Test']); } + + public function testInvalidStorageToken(): void + { + // Create a client with an invalid storage token + $invalidTokenClient = new Client([ + 'url' => $_ENV['TESTS_QUERY_API_URL'], + 'token' => 'invalid-token-12345', + ]); + + $this->expectException(ClientException::class); + $this->expectExceptionMessage('Authentication failed'); + + // Attempt to submit a query job with invalid token + $invalidTokenClient->submitQueryJob( + $this->getTestBranchId(), + $this->getTestWorkspaceId(), + [ + 'statements' => ['SELECT 1'], + ], + ); + } } From 35099f99e37cfba9338dc975c94cac98b2e07c01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimi=CC=81r=20Kris=CC=8Cka?= Date: Tue, 19 Aug 2025 17:55:46 +0200 Subject: [PATCH 18/23] Add middleware to handlerStack which will add headers automatically --- src/Client.php | 38 +++++++++++++++++++++++++++++--------- tests/ClientTest.php | 27 +++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/Client.php b/src/Client.php index 5d82849..85b1db0 100644 --- a/src/Client.php +++ b/src/Client.php @@ -69,6 +69,13 @@ private function initClient(array $config): void $handlerStack = $config['handler'] ?? HandlerStack::create(); $handlerStack->push(Middleware::retry($this->createRetryDecider(), $this->createRetryDelay())); + // Add request mapping middleware for headers + $handlerStack->push(Middleware::mapRequest( + function (RequestInterface $request) { + return $this->addRequestHeaders($request); + }, + )); + $this->client = new GuzzleClient([ 'base_uri' => $this->apiUrl, 'handler' => $handlerStack, @@ -108,6 +115,27 @@ private function createRetryDelay(): callable }; } + /** + * Add request headers with selective authentication + */ + private function addRequestHeaders(RequestInterface $request): RequestInterface + { + $path = $request->getUri()->getPath(); + + // Start with base headers that all requests need + $baseRequest = $request + ->withHeader('User-Agent', $this->userAgent) + ->withHeader('Content-Type', 'application/json'); + + // Skip authentication for health-check endpoints + if (str_contains($path, '/health-check')) { + return $baseRequest; + } + + // Add Storage API token for all other endpoints that require authentication + return $baseRequest->withHeader('X-StorageAPI-Token', $this->tokenString); + } + /** * Submit a new query job * @@ -170,15 +198,7 @@ public function healthCheck(): array */ private function sendRequest(string $method, string $url, ?array $requestBody = null): array { - $headers = [ - 'Content-Type' => 'application/json', - 'X-StorageAPI-Token' => $this->tokenString, - 'User-Agent' => $this->userAgent, - ]; - - $options = [ - 'headers' => $headers, - ]; + $options = []; if ($requestBody !== null) { try { diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 6d88ed4..97effa7 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -133,6 +133,33 @@ public function testStorageApiUrlDerivation(): void $this->assertInstanceOf(Client::class, $client); } + public function testHealthCheckWithInvalidToken(): void + { + // Health check should work even with invalid token since no auth is required + $mockHandler = new MockHandler([ + new Response(200, [], json_encode([ + 'service' => 'query', + 'status' => 'ok', + 'timestamp' => '2024-01-01T00:00:00Z', + 'version' => '1.0.0', + ]) ?: ''), + ]); + + // Create client with completely invalid token + $handlerStack = HandlerStack::create($mockHandler); + $client = new Client([ + 'url' => 'https://query.test.keboola.com', + 'token' => 'completely-invalid-token-that-would-fail-auth', + 'handler' => $handlerStack, + ]); + + // Health check should succeed because no token is sent + $result = $client->healthCheck(); + + $this->assertEquals('query', $result['service']); + $this->assertEquals('ok', $result['status']); + } + private function createClientWithMockHandler(MockHandler $mockHandler): Client { $handlerStack = HandlerStack::create($mockHandler); From be39e8438b87286f0e56be75ba78bcc748144174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimi=CC=81r=20Kris=CC=8Cka?= Date: Tue, 19 Aug 2025 18:02:29 +0200 Subject: [PATCH 19/23] Fail on mising envs --- tests/Functional/BaseFunctionalTestCase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Functional/BaseFunctionalTestCase.php b/tests/Functional/BaseFunctionalTestCase.php index 603fddb..6a1d36b 100644 --- a/tests/Functional/BaseFunctionalTestCase.php +++ b/tests/Functional/BaseFunctionalTestCase.php @@ -53,7 +53,7 @@ private function validateEnvironmentVariables(): void foreach ($requiredVars as $var) { if (empty($_ENV[$var])) { - $this->markTestSkipped( + throw new RuntimeException( sprintf('Environment variable %s is required for functional tests', $var), ); } From 573ed13011b01420a8843efc3d4d2ff3f08a0d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimi=CC=81r=20Kris=CC=8Cka?= Date: Tue, 19 Aug 2025 18:09:04 +0200 Subject: [PATCH 20/23] Add badge --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index ebc6418..b497608 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Keboola Query Service API PHP Client +[![Build](https://github.com/keboola/query-api-php-client/actions/workflows/branch.yml/badge.svg)](https://github.com/keboola/query-api-php-client/actions/workflows/branch.yml) + PHP client for Keboola Query Service API. ## Installation From 37199cb6e1d2e3b8c2e987e9d8030ac4dc274b00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimi=CC=81r=20Kris=CC=8Cka?= Date: Tue, 19 Aug 2025 18:31:15 +0200 Subject: [PATCH 21/23] Check code style for src and tests --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index def874d..f36c776 100644 --- a/composer.json +++ b/composer.json @@ -37,8 +37,8 @@ "symfony/dotenv": "^6.0" }, "scripts": { - "phpcs": "phpcs -n .", - "phpcbf": "phpcbf -n .", + "phpcs": "phpcs -n src tests", + "phpcbf": "phpcbf -n src tests", "phpstan": "phpstan analyse --no-progress --level=max src tests -c phpstan.neon", "tests": "phpunit --coverage-clover build/logs/clover.xml --coverage-xml=build/logs/coverage-xml --log-junit=build/logs/phpunit.junit.xml", "ci": [ From f72f1a08422d8505ccab7114b3b47161b3fc7e45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimi=CC=81r=20Kris=CC=8Cka?= Date: Tue, 19 Aug 2025 18:38:24 +0200 Subject: [PATCH 22/23] Fine tune readme --- README.md | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b497608..dc0fb0b 100644 --- a/README.md +++ b/README.md @@ -49,11 +49,11 @@ The client constructor accepts the following configuration options: - `url` (required): Query Service API URL (e.g., `https://query.keboola.com`) - `token` (required): Storage API token -- `storageApiUrl` (optional): Storage API URL (auto-derived from Query Service URL if not provided) - `backoffMaxTries` (optional): Number of retry attempts for failed requests (default: 3) - `userAgent` (optional): Additional user agent string to append - `handler` (optional): Custom Guzzle handler stack -- `logger` (optional): PSR-3 logger instance + +**Note**: The `healthCheck()` endpoint does not require authentication and will work without a valid token. ## API Methods @@ -67,7 +67,26 @@ The client constructor accepts the following configuration options: ### Running Tests -Run tests: +#### Unit Tests +Run unit tests: +```shell +vendor/bin/phpunit tests/ClientTest.php +``` + +#### Functional Tests +Functional tests require environment variables to be set: + +- `TESTS_STORAGE_API_TOKEN` - Storage API authentication token +- `TESTS_QUERY_API_URL` - Query Service API endpoint URL +- `TESTS_STORAGE_API_URL` - Storage API endpoint URL + +Run functional tests: +```shell +vendor/bin/phpunit tests/Functional/ +``` + +#### All Tests +Run all tests: ```shell composer run tests ``` From 98a6ae44e0e91d1b066cee08dda67db2068593bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimi=CC=81r=20Kris=CC=8Cka?= Date: Tue, 19 Aug 2025 22:05:58 +0200 Subject: [PATCH 23/23] Remove unused code --- src/Client.php | 2 -- tests/ClientTest.php | 14 -------------- tests/Functional/BaseFunctionalTestCase.php | 3 --- 3 files changed, 19 deletions(-) diff --git a/src/Client.php b/src/Client.php index 85b1db0..b3b1c25 100644 --- a/src/Client.php +++ b/src/Client.php @@ -8,10 +8,8 @@ use GuzzleHttp\Exception\ClientException as GuzzleClientException; use GuzzleHttp\Exception\ConnectException; use GuzzleHttp\Exception\GuzzleException; -use GuzzleHttp\Exception\RequestException; use GuzzleHttp\HandlerStack; use GuzzleHttp\Middleware; -use GuzzleHttp\Psr7\Request; use InvalidArgumentException; use JsonException; use Psr\Http\Message\RequestInterface; diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 97effa7..9afce62 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -4,14 +4,11 @@ namespace Keboola\QueryApi\Tests; -use GuzzleHttp\Client as GuzzleClient; -use GuzzleHttp\Exception\ClientException as GuzzleClientException; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Response; use InvalidArgumentException; use Keboola\QueryApi\Client; -use Keboola\QueryApi\ClientException; use PHPUnit\Framework\TestCase; class ClientTest extends TestCase @@ -122,17 +119,6 @@ public function testHealthCheck(): void $this->assertEquals('ok', $result['status']); } - public function testStorageApiUrlDerivation(): void - { - // Test with Query Service URL to ensure proper Storage API URL derivation - $client = new Client([ - 'url' => 'https://query.keboola.com', - 'token' => 'test-token', - ]); - - $this->assertInstanceOf(Client::class, $client); - } - public function testHealthCheckWithInvalidToken(): void { // Health check should work even with invalid token since no auth is required diff --git a/tests/Functional/BaseFunctionalTestCase.php b/tests/Functional/BaseFunctionalTestCase.php index 6a1d36b..a75ef92 100644 --- a/tests/Functional/BaseFunctionalTestCase.php +++ b/tests/Functional/BaseFunctionalTestCase.php @@ -4,7 +4,6 @@ namespace Keboola\QueryApi\Tests\Functional; -use InvalidArgumentException; use Keboola\QueryApi\Client; use Keboola\StorageApi\BranchAwareClient; use Keboola\StorageApi\Client as StorageApiClient; @@ -116,8 +115,6 @@ private function createTestWorkspace(): void $this->testWorkspaceId = (string) $workspaceData['id']; } - - protected function getTestBranchId(): string { return $this->testBranchId;