From f5da58e985de73967f5b311a9fa7b64cd5bba2b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milan=20Felix=20=C5=A0ulc?= Date: Thu, 1 Oct 2026 22:30:54 +0200 Subject: [PATCH 1/4] QA: use contributte/qa base ruleset (ruleset-8.0.xml was removed in qa 0.4) --- ruleset.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ruleset.xml b/ruleset.xml index 215c909..0df4458 100644 --- a/ruleset.xml +++ b/ruleset.xml @@ -1,7 +1,8 @@ - + + From aafc8c0cf2a66950062be889ef0a18e793652497 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milan=20Felix=20=C5=A0ulc?= Date: Thu, 1 Oct 2026 22:30:55 +0200 Subject: [PATCH 2/4] Elastica: support ruflin/elastica ^8.0 || ^9.0 --- composer.json | 9 +- phpstan.neon | 56 ------- src/Client.php | 46 ++++-- src/DI/ElasticaExtension.php | 86 ++++++---- src/Diagnostics/Panel.php | 191 ++++++++++++++--------- src/Diagnostics/panel.phtml | 28 ++-- tests/Cases/DI/ElasticaExtensionTest.php | 117 ++++++++++++++ tests/Cases/Diagnostics/PanelTest.php | 78 +++++++++ tests/Fixtures/FakeHttpClient.php | 41 +++++ 9 files changed, 465 insertions(+), 187 deletions(-) create mode 100644 tests/Cases/Diagnostics/PanelTest.php create mode 100644 tests/Fixtures/FakeHttpClient.php diff --git a/composer.json b/composer.json index 45d05b8..d02f6a6 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ ], "require": { "php": ">=8.1", - "ruflin/elastica": "^7.3.1", + "ruflin/elastica": "^8.0 || ^9.0", "nette/di": "^3.1.8", "nette/utils": "^4.0.3" }, @@ -29,10 +29,15 @@ "nette/http": "^3.2.3", "nette/bootstrap": "^3.2.1", "mockery/mockery": "^1.6.6", + "guzzlehttp/guzzle": "^7.8", "contributte/qa": "^0.4", "contributte/tester": "^0.4", "contributte/phpstan": "^0.1" }, + "suggest": { + "guzzlehttp/guzzle": "PSR-18 HTTP client used by elastic/transport (or any other PSR-18 client, e.g. symfony/http-client)", + "tracy/tracy": "Tracy bar panel and BlueScreen panel" + }, "conflict": { "nette/schema": "<1.2.0" }, @@ -56,7 +61,7 @@ }, "extra": { "branch-alias": { - "dev-master": "2.1.0-dev" + "dev-master": "3.0.0-dev" } } } diff --git a/phpstan.neon b/phpstan.neon index 5a67418..d20c295 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -14,59 +14,3 @@ parameters: paths: - src - .docs - - ignoreErrors: - - - message: "#^Method Contributte\\\\Elastica\\\\Client\\:\\:request\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Client.php - - - - message: "#^Method Contributte\\\\Elastica\\\\Client\\:\\:request\\(\\) has parameter \\$query with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Client.php - - - - message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#" - count: 1 - path: src/Diagnostics/Panel.php - - - - message: "#^Cannot access an offset on mixed\\.$#" - count: 2 - path: src/Diagnostics/Panel.php - - - - message: "#^Casting to string something that's already string\\.$#" - count: 1 - path: src/Diagnostics/Panel.php - - - - message: "#^Only booleans are allowed in a negated boolean, array given\\.$#" - count: 1 - path: src/Diagnostics/Panel.php - - - - message: "#^Only booleans are allowed in a ternary operator condition, string\\|null given\\.$#" - count: 1 - path: src/Diagnostics/Panel.php - - - - message: "#^Only booleans are allowed in an if condition, Elastica\\\\Response\\|null given\\.$#" - count: 1 - path: src/Diagnostics/Panel.php - - - - message: "#^Only booleans are allowed in an if condition, int given\\.$#" - count: 1 - path: src/Diagnostics/Panel.php - - - - message: "#^Return type \\(string\\|null\\) of method Contributte\\\\Elastica\\\\Diagnostics\\\\Panel\\:\\:getPanel\\(\\) should be covariant with return type \\(string\\) of method Tracy\\\\IBarPanel\\:\\:getPanel\\(\\)$#" - count: 1 - path: src/Diagnostics/Panel.php - - - - message: "#^Short ternary operator is not allowed\\. Use null coalesce operator if applicable or consider using long ternary\\.$#" - count: 1 - path: src/Diagnostics/Panel.php diff --git a/src/Client.php b/src/Client.php index 85e9c5c..d2af99b 100644 --- a/src/Client.php +++ b/src/Client.php @@ -2,40 +2,64 @@ namespace Contributte\Elastica; +use Elastic\Elasticsearch\Response\Elasticsearch; use Elastica\Client as ElasticaClient; -use Elastica\Request; -use Elastica\Response; +use Error; use Nette\SmartObject; +use Psr\Http\Message\RequestInterface; use Throwable; +/** + * @method void onSuccess(Client $client, RequestInterface $request, Elasticsearch $response, float $time) + * @method void onFailure(Client $client, RequestInterface $request, Throwable $e, float $time) + */ class Client extends ElasticaClient { use SmartObject; - /** @var callable[] */ + /** @var array */ public array $onSuccess = []; - /** @var callable[] */ + /** @var array */ public array $onFailure = []; - /** - * {@inheritdoc} - */ - public function request(string $path, string $method = Request::GET, $data = [], array $query = [], string $contentType = Request::DEFAULT_CONTENT_TYPE): Response + public function sendRequest(RequestInterface $request): Elasticsearch { $start = microtime(true); + $previous = $this->getTransportLastRequest(); try { - $response = parent::request($path, $method, $data, $query, $contentType); - $this->onSuccess($this, $this->_lastRequest, $response, microtime(true) - $start); + $response = parent::sendRequest($request); + $this->onSuccess($this, $this->resolveSentRequest($request, $previous), $response, microtime(true) - $start); return $response; } catch (Throwable $e) { - $this->onFailure($this, $this->_lastRequest, $e, microtime(true) - $start); + $this->onFailure($this, $this->resolveSentRequest($request, $previous), $e, microtime(true) - $start); throw $e; } } + /** + * Request built by elastica does not contain the node (host), it is resolved by transport, + * so prefer the request really sent by the transport. + */ + private function resolveSentRequest(RequestInterface $request, ?RequestInterface $previous): RequestInterface + { + $last = $this->getTransportLastRequest(); + + return $last !== null && $last !== $previous ? $last : $request; + } + + private function getTransportLastRequest(): ?RequestInterface + { + try { + return $this->getTransport()->getLastRequest(); + } catch (Error) { + // older elastic/transport 8.x throws on uninitialized typed property before the first request + return null; + } + } + } diff --git a/src/DI/ElasticaExtension.php b/src/DI/ElasticaExtension.php index 5f73d99..d9a76c2 100644 --- a/src/DI/ElasticaExtension.php +++ b/src/DI/ElasticaExtension.php @@ -5,6 +5,7 @@ use Contributte\Elastica\Client as ContributteClient; use Contributte\Elastica\Diagnostics\Panel; use Nette\DI\CompilerExtension; +use Nette\DI\Definitions\Statement; use Nette\PhpGenerator\ClassType; use Nette\PhpGenerator\Literal; use Nette\Schema\Expect; @@ -20,48 +21,58 @@ class ElasticaExtension extends CompilerExtension public function getConfigSchema(): Schema { - // https://github.com/ruflin/Elastica/blob/master/src/ClientConfiguration.php#L26 - $clientConfig = [ - 'host' => Expect::string()->nullable()->dynamic(), - 'port' => Expect::int()->nullable()->dynamic(), - 'path' => Expect::string()->nullable(), - 'url' => Expect::string()->nullable(), - 'proxy' => Expect::string()->nullable(), - 'transport' => Expect::string()->nullable(), - 'compression' => Expect::bool(), - 'persistent' => Expect::bool(), - 'timeout' => Expect::int()->nullable(), - 'retryOnConflict' => Expect::int(), - 'bigintConversion' => Expect::bool(), - 'username' => Expect::string()->nullable()->dynamic(), - 'password' => Expect::string()->nullable()->dynamic(), - 'auth_type' => Expect::anyOf('basic', 'digest', 'gssnegotiate', 'ntlm')->nullable()->dynamic(), - 'curl' => Expect::arrayOf('mixed', 'int'), - 'headers' => Expect::arrayOf('string', 'string'), - ]; + $service = static fn () => Expect::anyOf(Expect::string(), Expect::type(Statement::class))->nullable(); + // https://github.com/ruflin/Elastica/blob/8.x/src/ClientConfiguration.php + // https://github.com/ruflin/Elastica/blob/8.x/src/Client.php (_buildTransport) return Expect::structure([ 'debug' => Expect::bool(false), - 'config' => Expect::structure(array_merge( - $clientConfig, - [ - 'connections' => Expect::arrayOf( - Expect::structure($clientConfig)->skipDefaults()->castTo('array') - ), - 'roundRobin' => Expect::bool(), - ] - ))->skipDefaults()->castTo('array'), + 'logger' => $service(), + 'config' => Expect::structure([ + 'hosts' => Expect::listOf(Expect::string()->dynamic()), + 'cloud_id' => Expect::string()->nullable()->dynamic(), + 'api_key' => Expect::string()->nullable()->dynamic(), + 'username' => Expect::string()->nullable()->dynamic(), + 'password' => Expect::string()->nullable()->dynamic(), + 'retries' => Expect::int()->nullable()->dynamic(), + 'retryOnConflict' => Expect::int(), + 'transport_config' => Expect::structure([ + 'http_client' => $service(), + 'http_client_config' => Expect::arrayOf('mixed', 'string'), + 'http_client_options' => Expect::array(), + 'node_pool' => $service(), + ])->skipDefaults()->castTo('array'), + 'document' => Expect::structure([ + 'autoPopulate' => Expect::bool(), + ])->skipDefaults()->castTo('array'), + ])->skipDefaults()->castTo('array'), ]); } public function loadConfiguration(): void { $builder = $this->getContainerBuilder(); + $config = $this->config; + + /** @var array $clientConfig */ + $clientConfig = $config->config; + + // Do not pass empty (null / []) values, so elastica defaults just work + foreach (['transport_config', 'document'] as $key) { + if (isset($clientConfig[$key]) && is_array($clientConfig[$key])) { + $clientConfig[$key] = self::filterEmpty($clientConfig[$key]); + } + } + + $clientConfig = self::filterEmpty($clientConfig); $elastica = $builder->addDefinition($this->prefix('client')) - ->setFactory(ContributteClient::class, [$this->config->config]); + ->setFactory(ContributteClient::class, [ + 'config' => $clientConfig, + 'logger' => $config->logger, + ]); - if ($this->config->debug) { + if ($config->debug) { $builder->addDefinition($this->prefix('panel')) ->setFactory(Panel::class); @@ -75,4 +86,19 @@ public function afterCompile(ClassType $class): void $initialize->addBody('?::getBlueScreen()->addPanel(?);', [new Literal(Debugger::class), Panel::class . '::renderException']); } + /** + * @param array $values + * @return array + */ + private static function filterEmpty(array $values): array + { + foreach ($values as $key => $value) { + if ($value === null || $value === []) { + unset($values[$key]); + } + } + + return $values; + } + } diff --git a/src/Diagnostics/Panel.php b/src/Diagnostics/Panel.php index 76d3804..a8571ae 100644 --- a/src/Diagnostics/Panel.php +++ b/src/Diagnostics/Panel.php @@ -3,19 +3,25 @@ namespace Contributte\Elastica\Diagnostics; use Contributte\Elastica\Client; +use Elastic\Elasticsearch\Exception\ClientResponseException; +use Elastic\Elasticsearch\Exception\ServerResponseException; +use Elastic\Elasticsearch\Response\Elasticsearch; +use Elastica\Exception\Bulk\ResponseException as BulkResponseException; use Elastica\Exception\ExceptionInterface; -use Elastica\Exception\ResponseException; -use Elastica\Request; -use Elastica\Response; -use Nette\Http\Url; use Nette\Utils\Html; use Nette\Utils\Json; use Nette\Utils\JsonException; +use Psr\Http\Message\MessageInterface; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; use Throwable; use Tracy\Debugger; use Tracy\Dumper; use Tracy\IBarPanel; +/** + * @phpstan-type Query array{request: RequestInterface, response: ResponseInterface|null, time: float, exception: Throwable|null} + */ class Panel implements IBarPanel { @@ -23,48 +29,83 @@ class Panel implements IBarPanel public int $queriesCount = 0; - /** @var mixed[] */ + /** @var array> */ public array $queries = []; private Client $client; /** - * @return array|NULL + * @return array{tab: string, panel: string}|null */ public static function renderException(Throwable|null $e = null): ?array { - if (!$e instanceof ExceptionInterface) { - return null; - } - - $panel = null; + $panel = ''; - if ($e instanceof ResponseException) { - $panel .= '

Request

'; - $panel .= Dumper::toHtml($e->getRequest()); + if ($e instanceof ClientResponseException || $e instanceof ServerResponseException) { + $response = $e->getResponse(); $panel .= '

Response

'; - $panel .= Dumper::toHtml($e->getResponse()); + $panel .= Dumper::toHtml([ + 'status' => $response->getStatusCode(), + 'body' => self::decodeBody($response), + ]); - } elseif ($e instanceof \Elastica\Exception\Bulk\ResponseException) { + } elseif ($e instanceof BulkResponseException) { $panel .= '

Failures

'; $panel .= Dumper::toHtml($e->getFailures()); + } elseif (!$e instanceof ExceptionInterface) { + return null; } - return $panel ? [ + return $panel !== '' ? [ 'tab' => 'ElasticSearch', 'panel' => $panel, ] : null; } + /** + * Decodes JSON (or NDJSON) body of PSR-7 message, falls back to raw string + */ + public static function decodeBody(MessageInterface $message): mixed + { + $body = $message->getBody(); + + if ($body->isSeekable()) { + $body->rewind(); + } + + $data = $body->getContents(); + + if ($body->isSeekable()) { + $body->rewind(); + } + + if ($data === '') { + return null; + } + + try { + return Json::decode($data, true); + } catch (JsonException) { + try { + return array_map( + static fn (string $row): mixed => Json::decode($row, true), + explode("\n", trim($data)) + ); + } catch (JsonException) { + return $data; + } + } + } + public function getTab(): string { $img = Html::el('')->addHtml(''); //phpcs: ignore $tab = Html::el('span')->title('Elastica')->addHtml($img); $title = Html::el('span')->class('tracy-label'); - if ($this->queriesCount) { + if ($this->queriesCount > 0) { $title->setText( $this->queriesCount . ' call' . ($this->queriesCount > 1 ? 's' : '') . ' / ' . sprintf('%0.2f', $this->totalTime * 1000) . ' ms' @@ -74,95 +115,74 @@ public function getTab(): string return $tab->addHtml($title)->toHtml(); } - public function getPanel(): ?string + public function getPanel(): string { - if (!$this->queries) { - return null; + if ($this->queries === []) { + return ''; } - /** - * @param Request|Response|mixed $object - */ - $extractData = function ($object) { - if (!($object instanceof Request) && !($object instanceof Response)) { - return []; - } - - /** @var string|mixed[] $data */ - $data = $object->getData(); - - try { - return !is_array($data) ? Json::decode($data, Json::FORCE_ARRAY) : $data; - } catch (JsonException $e) { - try { - /** @phpstan-var mixed $data */ - return array_map(fn ($row) => Json::decode((string) $row, Json::FORCE_ARRAY), is_string($data) ? explode("\n", trim($data)) : []); - } catch (JsonException $e) { - return $data; - } - } - }; - $processedQueries = []; $allQueries = $this->queries; $totalTime = $this->totalTime; // @phpcs:ignore foreach ($allQueries as $authority => $requests) { - /** @var Request[] $item */ foreach ($requests as $i => $item) { $processedQueries[$authority][$i] = $item; - if (isset($item[3])) { + if ($item['exception'] !== null) { continue; // exception, do not re-execute } - if (stripos($item[0]->getPath(), '_search') === false || $item[0]->getMethod() !== 'GET') { - continue; // explain only search queries - } + $request = $item['request']; - if (!is_array($data = $extractData($item[0]))) { - continue; + if (stripos($request->getUri()->getPath(), '_search') === false || !in_array($request->getMethod(), ['GET', 'POST'], true)) { + continue; // explain only search queries } try { - $response = $this->client->request( - $item[0]->getPath(), - $item[0]->getMethod(), - $item[0]->getData(), - ['explain' => 1] + $item[0]->getQuery() - ); + $response = $this->client->sendRequest($this->withExplain($request)); // replace the search response with the explained response - $processedQueries[$authority][$i][1] = $response; - - } catch (Throwable $e) { + $processedQueries[$authority][$i]['response'] = $response; + } catch (Throwable) { // ignore } } } + $decode = [self::class, 'decodeBody']; // @phpcs:ignore + ob_start(); require __DIR__ . '/panel.phtml'; $result = ob_get_clean(); - return $result === false ? null : $result; + return $result === false ? '' : $result; } - public function success(Client $client, Request $request, Response $response, float $time): void + public function success(Client $client, RequestInterface $request, Elasticsearch $response, float $time): void { - $this->queries[$this->requestAuthority($response)][] = [$request, $response, $time]; + $this->queries[$this->requestAuthority($request)][] = [ + 'request' => $request, + 'response' => $response, + 'time' => $time, + 'exception' => null, + ]; $this->totalTime += $time; $this->queriesCount++; } - public function failure(Client $client, Request $request, Throwable $e, float $time): void + public function failure(Client $client, RequestInterface $request, Throwable $e, float $time): void { - /** @var Response $response */ - $response = method_exists($e, 'getResponse') ? $e->getResponse() : null; - - $this->queries[$this->requestAuthority($response)][] = [$request, $response, $time, $e]; + $response = $e instanceof ClientResponseException || $e instanceof ServerResponseException ? $e->getResponse() : null; + + $this->queries[$this->requestAuthority($request)][] = [ + 'request' => $request, + 'response' => $response, + 'time' => $time, + 'exception' => $e, + ]; $this->totalTime += $time; $this->queriesCount++; } @@ -176,19 +196,34 @@ public function register(Client $client): void Debugger::getBar()->addPanel($this); } - protected function requestAuthority(?Response $response = null): string + protected function requestAuthority(RequestInterface $request): string { - if ($response) { - $info = $response->getTransferInfo(); - $url = new Url($info['url']); - - } else { - /** @var string $current */ - $current = key($this->queries); - $url = new Url($current ?: 'http://localhost:9200/'); + $uri = $request->getUri(); + + if ($uri->getHost() === '') { + return 'http://localhost:9200'; + } + + // do not use getAuthority(), it contains user info (credentials) + $port = $uri->getPort(); + + return ($uri->getScheme() !== '' ? $uri->getScheme() : 'http') . '://' . $uri->getHost() . ($port !== null ? ':' . $port : ''); + } + + private function withExplain(RequestInterface $request): RequestInterface + { + $uri = $request->getUri(); + $query = []; + parse_str($uri->getQuery(), $query); + $query['explain'] = 'true'; + + $body = $request->getBody(); + + if ($body->isSeekable()) { + $body->rewind(); } - return $url->hostUrl; + return $request->withUri($uri->withQuery(http_build_query($query))); } } diff --git a/src/Diagnostics/panel.phtml b/src/Diagnostics/panel.phtml index 8dfaa44..c393096 100644 --- a/src/Diagnostics/panel.phtml +++ b/src/Diagnostics/panel.phtml @@ -30,14 +30,22 @@ $esc = function ($s) {

- Queries: , time:

- getTransferInfo() : array('url' => 'http://localhost:9200/'); $url = new \Nette\Http\Url($info['url']); ?> + getUri()->getPath(); + parse_str($request->getUri()->getQuery(), $requestQuery); + ?>

- getMethod())); ?> path, '/')); ?> + getMethod())); ?> ms

- getData()): $requestData = $extractData($request); ?> + @@ -46,10 +54,10 @@ $esc = function ($s) { - + - isOk()) { $responsesData = $extractData($response); ?> + getStatusCode() >= 200 && $response->getStatusCode() < 300) { $responsesData = $decode($response); ?> $responseData) { ?> @@ -59,7 +67,7 @@ $esc = function ($s) {
Request:
Search type:getQuery()[\Elastica\Search::OPTION_SEARCH_TYPE]) ? $esc($request->getQuery()[\Elastica\Search::OPTION_SEARCH_TYPE]) : 'search' ?>
- getPath(), '_search') !== FALSE || stripos($request->getPath(), '_msearch') !== FALSE) { ?> + @@ -91,7 +99,7 @@ $esc = function ($s) { - + @@ -99,17 +107,17 @@ $esc = function ($s) { - + - + - + diff --git a/tests/Cases/DI/ElasticaExtensionTest.php b/tests/Cases/DI/ElasticaExtensionTest.php index 055e958..7322264 100644 --- a/tests/Cases/DI/ElasticaExtensionTest.php +++ b/tests/Cases/DI/ElasticaExtensionTest.php @@ -6,10 +6,14 @@ use Contributte\Elastica\DI\ElasticaExtension; use Contributte\Elastica\Diagnostics\Panel; use Contributte\Tester\Utils\ContainerBuilder; +use Contributte\Tester\Utils\Neonkit; use Nette\DI\Compiler; +use Nette\DI\InvalidConfigurationException; use Nette\DI\MissingServiceException; +use Psr\Log\NullLogger; use Tester\Assert; use Tester\TestCase; +use Tests\Fixtures\FakeHttpClient; require_once __DIR__ . '/../../bootstrap.php'; @@ -42,6 +46,119 @@ public function testWithoutTracyPanel(): void }, MissingServiceException::class); } + public function testDefaultConfig(): void + { + $container = ContainerBuilder::of() + ->withCompiler(static function (Compiler $compiler): void { + $compiler->addExtension('elastica', new ElasticaExtension()); + }) + ->build(); + + /** @var Client $client */ + $client = $container->getService('elastica.client'); + + // elastica defaults are kept + Assert::same(['localhost:9200'], $client->getConfig('hosts')); + Assert::same(0, $client->getConfig('retryOnConflict')); + Assert::same([], $client->getConfig('transport_config')); + } + + public function testDocumentedConfig(): void + { + $container = ContainerBuilder::of() + ->withCompiler(static function (Compiler $compiler): void { + $compiler->addExtension('elastica', new ElasticaExtension()); + $compiler->addConfig(Neonkit::load(<<<'NEON' + elastica: + debug: false + logger: null + config: + hosts: [] + cloud_id: null + api_key: null + username: null + password: null + retries: null + retryOnConflict: 0 + transport_config: + http_client: null + http_client_config: [] + http_client_options: [] + node_pool: null + document: + autoPopulate: false + NEON)); + }) + ->build(); + + /** @var Client $client */ + $client = $container->getService('elastica.client'); + + Assert::same(['localhost:9200'], $client->getConfig('hosts')); + Assert::same([], $client->getConfig('transport_config')); + } + + public function testFullConfig(): void + { + $container = ContainerBuilder::of() + ->withCompiler(static function (Compiler $compiler): void { + $compiler->addExtension('elastica', new ElasticaExtension()); + $compiler->addConfig(Neonkit::load(<<<'NEON' + services: + http: Tests\Fixtures\FakeHttpClient + logger: Psr\Log\NullLogger + + elastica: + logger: @logger + config: + hosts: + - http://es1.local:9200 + - http://es2.local:9200 + username: elastic + password: secret + retries: 1 + retryOnConflict: 3 + transport_config: + http_client: @http + document: + autoPopulate: true + NEON)); + }) + ->build(); + + /** @var Client $client */ + $client = $container->getService('elastica.client'); + + Assert::same(['http://es1.local:9200', 'http://es2.local:9200'], $client->getConfig('hosts')); + Assert::same('elastic', $client->getConfig('username')); + Assert::same('secret', $client->getConfig('password')); + Assert::same(1, $client->getConfig('retries')); + Assert::same(3, $client->getConfig('retryOnConflict')); + Assert::same(['autoPopulate' => true], $client->getConfig('document')); + Assert::type(NullLogger::class, $client->getLogger()); + + /** @var FakeHttpClient $http */ + $http = $container->getService('http'); + Assert::same($http, $client->getTransport()->getClient()); + + $http->addResponse(200, '{"version":{"number":"8.11.0"}}'); + Assert::same('8.11.0', $client->getVersion()); + Assert::count(1, $http->requests); + Assert::contains($http->requests[0]->getUri()->getHost(), ['es1.local', 'es2.local']); + } + + public function testLegacyConfigIsRejected(): void + { + Assert::exception(static function (): void { + ContainerBuilder::of() + ->withCompiler(static function (Compiler $compiler): void { + $compiler->addExtension('elastica', new ElasticaExtension()); + $compiler->addConfig(['elastica' => ['config' => ['host' => 'localhost', 'port' => 9200]]]); + }) + ->build(); + }, InvalidConfigurationException::class, "Unexpected item 'elastica%a%config%a%host'%a%"); + } + } (new ElasticaExtensionTest())->run(); diff --git a/tests/Cases/Diagnostics/PanelTest.php b/tests/Cases/Diagnostics/PanelTest.php new file mode 100644 index 0000000..3a80cb7 --- /dev/null +++ b/tests/Cases/Diagnostics/PanelTest.php @@ -0,0 +1,78 @@ +addResponse(200, '{"took":1,"timed_out":false,"_shards":{"total":1,"successful":1,"failed":0},"hits":{"total":{"value":0,"relation":"eq"},"max_score":null,"hits":[]}}'); + $http->addResponse(404, '{"error":{"type":"index_not_found_exception","reason":"no such index [missing]"},"status":404}'); + + $client = new Client([ + 'hosts' => ['http://elastic:secret@es.local:9200'], + 'transport_config' => ['http_client' => $http], + ]); + + $panel = new Panel(); + $panel->register($client); + + $client->getIndex('articles')->search(new Query(new MatchAll())); + + $e = Assert::exception(static function () use ($client): void { + $client->getIndex('missing')->delete(); + }, ClientResponseException::class); + + Assert::same(2, $panel->queriesCount); + Assert::same(['http://es.local:9200'], array_keys($panel->queries)); + + [$success, $failure] = $panel->queries['http://es.local:9200']; + Assert::same('/articles/_search', $success['request']->getUri()->getPath()); + Assert::same(200, $success['response']?->getStatusCode()); + Assert::null($success['exception']); + Assert::same(404, $failure['response']?->getStatusCode()); + Assert::same($e, $failure['exception']); + + // Tracy tab and panel + Assert::contains('2 calls', $panel->getTab()); + + $html = $panel->getPanel(); + Assert::contains('/articles/_search', $html); + Assert::contains('/missing', $html); + Assert::notContains('secret', $html); + + // search query was re-executed with explain + Assert::count(3, $http->requests); + Assert::contains('explain=true', $http->requests[2]->getUri()->getQuery()); + + // BlueScreen + $bluescreen = Panel::renderException($e); + Assert::notNull($bluescreen); + Assert::contains('index_not_found_exception', $bluescreen['panel'] ?? ''); + Assert::null(Panel::renderException(new \RuntimeException('foo'))); + } + + public function testEmpty(): void + { + $panel = new Panel(); + + Assert::same('', $panel->getPanel()); + Assert::notContains('call', $panel->getTab()); + } + +} + +(new PanelTest())->run(); diff --git a/tests/Fixtures/FakeHttpClient.php b/tests/Fixtures/FakeHttpClient.php new file mode 100644 index 0000000..75dff16 --- /dev/null +++ b/tests/Fixtures/FakeHttpClient.php @@ -0,0 +1,41 @@ + */ + public array $requests = []; + + /** @var list */ + private array $responses = []; + + public function addResponse(int $status, string $body): self + { + $this->responses[] = [$status, $body]; + + return $this; + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->requests[] = $request; + + [$status, $body] = array_shift($this->responses) ?? [200, '{}']; + + return new Response($status, [ + 'Content-Type' => 'application/json', + 'X-Elastic-Product' => 'Elasticsearch', + ], $body); + } + +} From bcef4f7f5abc979a7a2a3bdc8ad00f127d9d93f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milan=20Felix=20=C5=A0ulc?= Date: Thu, 1 Oct 2026 22:30:55 +0200 Subject: [PATCH 3/4] Docs: configuration for Elastica 8+ --- .docs/README.md | 83 ++++++++++++++++++++++++++++++++++++------------- README.md | 8 ++--- 2 files changed, 65 insertions(+), 26 deletions(-) diff --git a/.docs/README.md b/.docs/README.md index 500b542..9bfd176 100644 --- a/.docs/README.md +++ b/.docs/README.md @@ -2,9 +2,10 @@ ## Content -- [Setup](#usage) +- [Setup](#setup) - [Configuration](#configuration) - [Usage](#usage) +- [Events](#events) - [Monolog](#monolog) This extension integrates the [ruflin/elastica](https://github.com/ruflin/Elastica) into Nette Framework. @@ -15,14 +16,21 @@ For more information on how to use Elastica [read the official documentation](ht ```bash composer require contributte/elastica ``` -register extension + +Elastica 8+ is built on top of [elastic/transport](https://github.com/elastic/elastic-transport-php), which needs a [PSR-18](https://www.php-fig.org/psr/psr-18/) HTTP client. +If you don't have one yet, install e.g. Guzzle (or `symfony/http-client`), it is discovered automatically. + +```bash +composer require guzzlehttp/guzzle +``` + +Register extension ```neon extensions: elastica: Contributte\Elastica\DI\ElasticaExtension ``` - ## Configuration Define at least one host, this would be minimal possible config. @@ -30,38 +38,57 @@ Define at least one host, this would be minimal possible config. ```neon elastica: config: - host: localhost + hosts: + - http://localhost:9200 ``` Full config with all possible options. + ```neon elastica: debug: %debugMode% + + # PSR-3 logger passed to Elastica client (and elastic/transport), e.g. @monolog.logger + logger: null + config: - host: null - port: null - path: null - url: null - proxy: null - transport: null - compression: false - persistent: true - timeout: null - connections: [] - roundRobin: null - retryOnConflict: 0 - bigintConversion: null + hosts: [] # list of hosts, e.g. [http://es1:9200, https://user:pass@es2:9200] + cloud_id: null + api_key: null username: null password: null - auth_type: null - curl: [] - headers: [] + retries: null # defaults to number of hosts + retryOnConflict: 0 + transport_config: + http_client: null # PSR-18 client service, e.g. @httpClient + http_client_config: [] # adapter config, e.g. Elastic\Elasticsearch\Transport\Adapter\AdapterOptions + http_client_options: [] # options passed to the HTTP client adapter + node_pool: null # Elastic\Transport\NodePool\NodePoolInterface service + document: + autoPopulate: false ``` Extension does not pass any unset values to elastica so elastica defaults just work. -Take a look to [Elastica docs](https://elastica-docs.readthedocs.io/en/latest/client.html#client-configurations). +Take a look to [Elastica client configuration](https://github.com/ruflin/Elastica/blob/8.x/src/ClientConfiguration.php) +and [elastic/transport](https://github.com/elastic/elastic-transport-php) docs. -In docker environment you should use `host: elasticsearch` and `port: 9200` for example. +In docker environment you should use `hosts: [http://elasticsearch:9200]` for example. + +### Upgrade from Elastica 7 + +Elastica 8 replaced its own connection layer with the official Elasticsearch client, so the client configuration changed +(see [UPGRADE-8.0](https://github.com/ruflin/Elastica/blob/8.x/UPGRADE-8.0.md)). + +| Before (Elastica 7) | After (Elastica 8+) | +|---------------------------------------------|-------------------------------------------------------------------| +| `host: es.local`, `port: 9200` | `hosts: [http://es.local:9200]` | +| `url: http://es.local:9200/` | `hosts: [http://es.local:9200]` | +| `connections: [{host: a}, {host: b}]` | `hosts: [http://a:9200, http://b:9200]` | +| `roundRobin: true` | `transport_config: node_pool: @nodePool` (e.g. `SimpleNodePool` with `RoundRobin` selector) | +| `username`, `password` | unchanged (or credentials in host URL, or `api_key`) | +| `auth_type`, `transport`, `proxy`, `curl`, `compression`, `persistent`, `timeout`, `headers`, `path`, `bigintConversion` | removed, configure the PSR-18 HTTP client instead (`transport_config: http_client` / `http_client_options`) | + +Removed options are rejected by the configuration schema, so you get a clear error after upgrade. ## Usage @@ -80,6 +107,18 @@ class YourService } ``` +## Events + +`Contributte\Elastica\Client` triggers `onSuccess` and `onFailure` events for every request, which are used by the Tracy panel. + +```php +use Elastic\Elasticsearch\Response\Elasticsearch; +use Psr\Http\Message\RequestInterface; + +$client->onSuccess[] = function (Client $client, RequestInterface $request, Elasticsearch $response, float $time): void {}; +$client->onFailure[] = function (Client $client, RequestInterface $request, Throwable $e, float $time): void {}; +``` + ## Monolog You can use monolog to log errors to kibana. diff --git a/README.md b/README.md index 7d23c7e..b991cf8 100755 --- a/README.md +++ b/README.md @@ -32,10 +32,10 @@ For details on how to use this package, check out our [documentation](.docs). ## Versions -| State | Version | Branch | Nette | PHP | -|-------------|---------|----------|-------|---------| -| dev | `^2.1` | `master` | 3.1+ | `>=8.1` | -| stable | `^2.0` | `master` | 3.1+ | `>=8.1` | +| State | Version | Branch | Nette | Elastica | PHP | +|-------------|---------|----------|-------|----------------|---------| +| dev | `^3.0` | `master` | 3.1+ | `^8.0 \|\| ^9.0` | `>=8.1` | +| stable | `^2.0` | `master` | 3.1+ | `^7.3` | `>=8.1` | ## Development From 4032149ebbabc4ce78fb55ea49aea6ce2ef802bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milan=20Felix=20=C5=A0ulc?= Date: Thu, 1 Oct 2026 22:49:44 +0200 Subject: [PATCH 4/4] Composer: require PHP >=8.2 --- .github/workflows/tests.yml | 8 +------- README.md | 2 +- composer.json | 2 +- phpstan.neon | 2 +- ruleset.xml | 2 +- 5 files changed, 5 insertions(+), 11 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a574bd1..cd2f698 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,15 +23,9 @@ jobs: with: php: "8.2" - test81: - name: "Nette Tester" - uses: contributte/.github/.github/workflows/nette-tester.yml@master - with: - php: "8.1" - testlower: name: "Nette Tester" uses: contributte/.github/.github/workflows/nette-tester.yml@master with: - php: "8.1" + php: "8.2" composer: "composer update --no-interaction --no-progress --prefer-dist --prefer-stable --prefer-lowest" diff --git a/README.md b/README.md index b991cf8..3c3bfce 100755 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ For details on how to use this package, check out our [documentation](.docs). | State | Version | Branch | Nette | Elastica | PHP | |-------------|---------|----------|-------|----------------|---------| -| dev | `^3.0` | `master` | 3.1+ | `^8.0 \|\| ^9.0` | `>=8.1` | +| dev | `^3.0` | `master` | 3.1+ | `^8.0 \|\| ^9.0` | `>=8.2` | | stable | `^2.0` | `master` | 3.1+ | `^7.3` | `>=8.1` | ## Development diff --git a/composer.json b/composer.json index d02f6a6..224d1d2 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ } ], "require": { - "php": ">=8.1", + "php": ">=8.2", "ruflin/elastica": "^8.0 || ^9.0", "nette/di": "^3.1.8", "nette/utils": "^4.0.3" diff --git a/phpstan.neon b/phpstan.neon index d20c295..cd64186 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,7 +3,7 @@ includes: parameters: level: 9 - phpVersion: 80100 + phpVersion: 80200 scanDirectories: - src diff --git a/ruleset.xml b/ruleset.xml index 0df4458..7c99d44 100644 --- a/ruleset.xml +++ b/ruleset.xml @@ -1,7 +1,7 @@ - +
Took:
Error:
Response:getData(), TRUE, 25); ?>
Error:getMessage()); ?>getMessage()); ?>