├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json └── src ├── Async ├── OnFailureDefault.php ├── OnFailureInterface.php ├── OnSuccessDefault.php └── OnSuccessInterface.php ├── Client └── Curl.php ├── Exception ├── CloudIdParseException.php ├── CurlException.php ├── InvalidArgumentException.php ├── InvalidArrayException.php ├── InvalidIterableException.php ├── InvalidJsonException.php ├── InvalidXmlException.php ├── NoAsyncClientException.php ├── NoNodeAvailableException.php ├── NotFoundException.php ├── RuntimeException.php ├── SerializeException.php ├── TransportException.php ├── UndefinedPropertyException.php └── UnknownContentTypeException.php ├── NodePool ├── Node.php ├── NodePoolInterface.php ├── Resurrect │ ├── ElasticsearchResurrect.php │ ├── NoResurrect.php │ └── ResurrectInterface.php ├── Selector │ ├── RoundRobin.php │ ├── SelectorInterface.php │ └── SelectorTrait.php └── SimpleNodePool.php ├── OpenTelemetry.php ├── Serializer ├── CsvSerializer.php ├── JsonSerializer.php ├── NDJsonSerializer.php ├── SerializerInterface.php ├── TextSerializer.php ├── Utility.php └── XmlSerializer.php ├── Transport.php └── TransportBuilder.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 8.11.0 (2025-04-02) 4 | 5 | - Added Node::getLastPing and Node::getFailedPings for custom NodePool implementations with ping backoffs 6 | [#35](https://github.com/elastic/elastic-transport-php/pull/35) 7 | - Add HTTP network exception as previous exception to NoNodeAvailableException for debugging 8 | [#34](https://github.com/elastic/elastic-transport-php/pull/34) 9 | - Fixed PHP 7.4+ issue - "must not be accessed before initialization" in getLastRequest() & getLastResponse() 10 | [#33](https://github.com/elastic/elastic-transport-php/pull/33) 11 | - Fixed missing response body with guzzle psr7 streams 12 | [#30](https://github.com/elastic/elastic-transport-php/pull/30) 13 | 14 | ## 8.10.0 (2024-08-14) 15 | 16 | - Added the OpenTelemetry support 17 | [#27](https://github.com/elastic/elastic-transport-php/pull/27) 18 | - Refactored the OpenTelemetry using PSR-7 attributes 19 | [2be33cd](https://github.com/elastic/elastic-transport-php/commit/2be33cdc8be161fc7dc9a1989da5d550ffc4a230) 20 | 21 | ## 8.8.0 (2023-11-08) 22 | 23 | - Support path in host connection URI 24 | [#21](https://github.com/elastic/elastic-transport-php/pull/21) 25 | 26 | - Support userInfo in host connection URI 27 | [#22](https://github.com/elastic/elastic-transport-php/pull/22) 28 | 29 | ## 8.7.0 (2023-05-23) 30 | 31 | - Allow installation of psr/http-message v2.0 32 | [#17](https://github.com/elastic/elastic-transport-php/pull/17) 33 | 34 | ## 8.6.0 (2023-01-12) 35 | 36 | - Add full request and response to the log message context for better integration using [Clockwork](https://underground.works/clockwork/) 37 | [#13](https://github.com/elastic/elastic-transport-php/pull/13) 38 | 39 | ## 8.5.0 (2022-10-14) 40 | 41 | - Release created to be compatible with 8.5 Elastic clients 42 | - Fixed the full body message in debug() log for Transport 43 | [#11](https://github.com/elastic/elastic-transport-php/pull/11) 44 | 45 | ## 8.4.0 (2022-08-17) 46 | 47 | - Release created to be compatible with 8.4 Elastic clients 48 | - Added meta header info for Symfony HTTP client 49 | [#9](https://github.com/elastic/elastic-transport-php/pull/9) 50 | - Added composer-runtime-api v2 for InstalledVersions 51 | [#10](https://github.com/elastic/elastic-transport-php/pull/10) 52 | 53 | ## 8.3.0 (2022-06-27) 54 | 55 | - Release created to be compatible with 8.3 Elastic clients 56 | 57 | ## 8.2.0 (2022-06-22) 58 | 59 | - Release created to be compatible with 8.2 Elastic clients 60 | 61 | ## 8.1.0 (2022-04-12) 62 | 63 | - Release created to be compatible with 8.1 Elastic clients 64 | 65 | ## 8.0.1 (2022-03-30) 66 | 67 | - Support of `psr/log` v1, 2 and 3 to fix the dependency with `elasticsearch/elasticsearch`. 68 | [a413687](https://github.com/elastic/elastic-transport-php/commit/a413687ae0fcc3f949b02935731a42a301b383ad) 69 | 70 | ## 8.0.0 (2022-03-24) 71 | 72 | Finally, the 8.0.0 GA. 73 | 74 | ## 8.0.0-RC4 (2022-03-08) 75 | 76 | Added the `TransportException` to extends the `Throwable`interface. 77 | 78 | ## 8.0.0-RC3 (2022-02-26) 79 | 80 | This RC3 release introduces the `OnSuccessInterface` and `OnFailureInterface` 81 | for manage the async code with the execution of a custom function during the 82 | return of `OnSuccess` and during the execution of `OnFailure`. As default behaviour 83 | the `OnSuccessDefault` and `OnFailureDefault` does not perform any operations. 84 | 85 | ## 8.0.0-RC2 (2022-02-23) 86 | 87 | This RC2 release uses `httplug` v2.3.0 to provide a full retry async mechanism 88 | thanks to PR https://github.com/php-http/httplug/pull/168. 89 | 90 | ## 8.0.0-RC1 (2022-02-17) 91 | 92 | This is the first release candidate for 8.0.0 containing some new 93 | features and changes compared with the previous 7.x Elastic transport. 94 | 95 | ### Changes 96 | 97 | - the `ConnectionPool` namespace has been renamed in `NodePool`, 98 | consequently all the `Connection` classes has been renamed in `Node` 99 | - the previous Apache 2.0 LICENSE has been changed in [MIT](https://opensource.org/licenses/MIT) 100 | 101 | ### New features 102 | 103 | - added the usage of [HTTPlug](http://httplug.io/) library to 104 | autodiscovery [PSR-18](https://www.php-fig.org/psr/psr-18/) client 105 | and `HttpAsyncClient` interface using [Promise](https://docs.php-http.org/en/latest/components/promise.html). 106 | - added the `Trasnport::sendAsyncRequest(RequestInterface $request): Promise` 107 | to send a PSR-7 request using asynchronous request 108 | - added the `Transport::setAsyncClient(HttpAsyncClient $asyncClient)` 109 | and `Transport::getAsyncClient()` functions. If the [PSR-18](https://www.php-fig.org/psr/psr-18/) 110 | client already implements the `HttpAsyncClient` interface you 111 | don't need to use the `setAsyncClient()` function, it will discovered 112 | automatically 113 | - added the `Transport::setRetries()` function to specify the number 114 | of HTTP request retries to apply. If the HTTP failures exceed the 115 | number of retries the client generates a `NoNodeAvailableException` 116 | 117 | ## 7.16.0 (2021-12-14) 118 | 119 | Release created to be compatible with 7.16 Elastic clients 120 | 121 | ## 7.15.0 (2021-12-01) 122 | 123 | Release created to be compatible with 7.15 Elastic clients 124 | 125 | ## 7.14.0 (2021-08-03) 126 | 127 | Release created to be compatible with 7.14 Elastic clients 128 | ## 7.13.0 (2021-05-25) 129 | 130 | Release created to be compatible with 7.13 Elastic clients 131 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Elasticsearch B.V (https://www.elastic.co) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # HTTP transport for Elastic PHP clients 4 | 5 | [![Build status](https://github.com/elastic/elastic-transport-php/workflows/Test/badge.svg)](https://github.com/elastic/elastic-transport-php/actions) 6 | 7 | This is a HTTP transport PHP library for communicate with [Elastic](https://www.elastic.co/) 8 | products, like [Elasticsearch](https://github.com/elastic/elasticsearch). 9 | 10 | It implements [PSR-7](https://www.php-fig.org/psr/psr-7/) standard for managing 11 | HTTP messages and [PSR-18](https://www.php-fig.org/psr/psr-18/) for sending HTTP requests. 12 | Moreover, it uses the [PSR-17](https://www.php-fig.org/psr/psr-17/) for building `PSR-7` objects 13 | like HTTP requests, HTTP responses, URI, etc. 14 | 15 | It uses the [HTTPlug](http://httplug.io/) library to automatic discovery a [PSR-18](https://www.php-fig.org/psr/psr-18/) 16 | client, a [PSR-17](https://www.php-fig.org/psr/psr-17/) factory and the [HttpAsyncClient](https://github.com/php-http/httplug/blob/master/src/HttpAsyncClient.php) 17 | interface with [Promise](https://docs.php-http.org/en/latest/components/promise.html) for 18 | asyncronous HTTP requestes. 19 | 20 | Starting from 9.0.0 version, if no PSR-18 clients are discovered, the library uses a 21 | default [custom HTTP client](src/Client/Curl.php) based on [cURL](https://curl.se/). 22 | This client relies on the [cURL php extension](https://www.php.net/manual/en/book.curl.php) 23 | that must be installed. 24 | Moreover, this client does **not** implement the [HttpAsyncClient](https://github.com/php-http/httplug/blob/master/src/HttpAsyncClient.php) 25 | interface, which means you won't be able to send asynchronous requests. 26 | If you need support for asynchronous requests, consider installing a PST-18 HTTP client like 27 | [Guzzle](https://github.com/guzzle/guzzle): 28 | 29 | ```bash 30 | composer require guzzlehttp/guzzle 31 | ``` 32 | 33 | or [Symfony HTTP Client](https://github.com/symfony/http-client): 34 | 35 | ```bash 36 | composer require symfony/http-client 37 | ``` 38 | 39 | The architecture of the Transport is flexible and customizable, you can configure it 40 | using a [PSR-18](https://www.php-fig.org/psr/psr-18/) client, a [PSR-3](https://www.php-fig.org/psr/psr-3/) 41 | logger and a custom [NodePoolInterface](src/NodePool/NodePoolInterface.php), to manage 42 | a cluster of nodes. 43 | 44 | ## Quick start 45 | 46 | The main component of this library is the [Transport](src/Transport.php) class. 47 | 48 | This class uses 3 components: 49 | 50 | - a PSR-18 client, using [ClientInterface](https://www.php-fig.org/psr/psr-18/#interfaces); 51 | - a Node pool, using [NodePoolInterface](src/NodePool/NodePoolInterface.php); 52 | - a PSR-3 logger, using [LoggerInterface](https://www.php-fig.org/psr/psr-3/#3-psrlogloggerinterface). 53 | 54 | While the `PSR-3` and `PSR-18` are well known standard in the PHP community, the `NodePoolInterface` 55 | is a new interface proposed in this library. The idea of this interface is to provide a class 56 | that is able to select a node for a list of hosts. For instance, using Elasticsearch, that is a 57 | distributed search engine, you need to manage a cluster of nodes. Each node exposes a common 58 | HTTP API and you can send the HTTP requests to one or more nodes. 59 | The `NodePoolInterface` is a component that can be used to manage the routing of the HTTP 60 | requests to the cluster node topology. 61 | 62 | In order to buid a `Transport` instance, you can use the `TransportBuilder` as follows: 63 | 64 | ```php 65 | use Elastic\Transport\TransportBuilder; 66 | 67 | $transport = TransportBuilder::create() 68 | ->setHosts(['localhost:9200']) 69 | ->build(); 70 | ``` 71 | 72 | This example shows how to set the transport to communicate with one node located at `localhost:9200` 73 | (e.g. Elasticsearch default port). 74 | 75 | By default, `TransportBuilder` will use the autodiscovery feature of [HTTPlug](http://httplug.io/) 76 | for the [PSR-18](https://www.php-fig.org/psr/psr-18/) client, the [SimpleNodePool](src/NodePool/SimpleNodePool.php) 77 | as `NodePoolInterface` and the [NullLogger](https://github.com/php-fig/log/blob/master/Psr/Log/NullLogger.php) 78 | as `LoggerInterface`. 79 | 80 | The `Tranport` class itself implements the [PSR-18](https://www.php-fig.org/psr/psr-18/) and the 81 | [HttpAsyncClient](https://github.com/php-http/httplug/blob/master/src/HttpAsyncClient.php) interfaces, 82 | that means you can use it to send any HTTP request using the `Tranport::sendRequest()` function 83 | as follows: 84 | 85 | ```php 86 | use Http\Discovery\Psr17FactoryDiscovery; 87 | 88 | $factory = Psr17FactoryDiscovery::findRequestFactory(); 89 | $request = $factory->createRequest('GET', '/info'); // PSR-7 request 90 | $response = $transport->sendRequest($request); 91 | var_dump($response); // PSR-7 response 92 | ``` 93 | 94 | The `sendRequest` function will use `$request` to send the HTTP request to the `localhost:9200` 95 | node specified in the previous example code. This behaviour can be used to specify only the URL path 96 | in the HTTP request, the host is selected at runtime using the `NodePool` implementation. 97 | 98 | **NOTE**: if you send a `$request` that contains already a host the `Transport` will 99 | use it without using the `NodePool` to select a node specified in `TransportBuilder::setHosts()` 100 | settings. 101 | 102 | For instance, the following example will send the `/info` request to `domain` and not `localhost`. 103 | 104 | ```php 105 | use Elastic\Transport\TransportBuilder; 106 | 107 | $transport = TransportBuilder::create() 108 | ->setHosts(['localhost:9200']) 109 | ->build(); 110 | 111 | $request = new Request('GET', 'https://domain.com/info'); 112 | $response = $transport->sendRequest($request); // the HTTP request will be sent to domain.com 113 | 114 | echo $transport->lastRequest()->getUri()->getHost(); // domain.com 115 | ``` 116 | 117 | ## Asyncronous requests 118 | 119 | You can send an asyncronous HTTP request using the `Transport::sendAsyncRequest()` as follows: 120 | 121 | ```php 122 | use Http\Discovery\Psr17FactoryDiscovery; 123 | 124 | $factory = Psr17FactoryDiscovery::findRequestFactory(); 125 | $request = $factory->createRequest('GET', '/info'); // PSR-7 request 126 | $promise= $transport->sendAsyncRequest($request); 127 | var_dump($promise); // Promise 128 | var_dump($promise->wait()); // PSR-7 response 129 | ``` 130 | 131 | The `$promise` contains a [Promise](https://docs.php-http.org/en/latest/components/promise.html) object. 132 | A promise is an object that does not block the execution of PHP. This means the promise does not 133 | contain the HTTP response. In order to read the HTTP response you need to use the `wait()` function. 134 | 135 | Another approach to use a promise is to specify the functions to be called on success and on faliure 136 | of the HTTP request. This can achieved using the `then()` function as follows: 137 | 138 | ```php 139 | $promise->then(function (ResponseInterface $response) { 140 | // onFulfilled callback, $reponse is PSR-7 141 | echo 'The response is available'; 142 | 143 | return $response; 144 | }, function (Exception $e) { 145 | // onRejected callback 146 | echo 'An error happens'; 147 | 148 | throw $e; 149 | }); 150 | ``` 151 | 152 | For more information about the usage of Promise objetcs you can read the [documentation](https://docs.php-http.org/en/latest/components/promise.html) 153 | from HTTPlug. 154 | 155 | ## Set the number of retries 156 | 157 | You can specify the number of retries for any HTTP requests. This means if the HTTP request will fail 158 | the client will automatically try to perform another request (or more). 159 | 160 | By default, the number of retries is zero (0). If you want you can change it using the `Transport::setRetries()` 161 | function, as follows: 162 | 163 | ```php 164 | use Elastic\Transport\TransportBuilder; 165 | 166 | $transport = TransportBuilder::create() 167 | ->setHosts([ 168 | '10.0.0.10:9200', 169 | '10.0.0.20:9200', 170 | '10.0.0.30:9200' 171 | ]) 172 | ->build(); 173 | 174 | $transport->setRetries(1); 175 | $factory = Psr17FactoryDiscovery::findRequestFactory(); 176 | $request = $factory->createRequest('GET', '/info'); 177 | // If a node is down, the transports retry automatically using another one 178 | $response = $transport->sendRequest($request); 179 | ``` 180 | 181 | This feature can be interesting as retry mechanism especially useful if you have a cluster of nodes. 182 | You can read the following section about `Node Pool` to understand how to configure the selection 183 | of nodes in a cluster environment. 184 | 185 | ## Node Pool 186 | 187 | The `SimpleNodePool` is the default node pool algorithm used by `Tranposrt`. 188 | It uses the following default values: [RoundRobin](src/NodePool/Selector/RoundRobin.php) as 189 | `SelectorInterface` and [NoResurrect](src/NodePool/Resurrect/FalseResurrect.php) as `ResurrectInterface`. 190 | 191 | The [Round-robin](https://en.wikipedia.org/wiki/Round-robin_scheduling) algorithm select the nodes in 192 | order, from the first node in the array to the latest. When arrived to the latest nodes, 193 | it will start again from the first. 194 | 195 | \* **NOTE**: the order of the nodes is randomized at runtime to maximize the usage of all the hosts. 196 | 197 | The [NoResurrect](src/NodePool/Resurrect/FalseResurrect.php) option does not try to resurrect the 198 | node that has been marked as dead. For instance, using `Elasticsearch` you can try to 199 | resurrect a dead node using the `HEAD /` API. If you want to use this behaviour you can use the 200 | [ElasticsearchResurrect](/src/NodePool/Resurrect/ElasticsearchResurrect.php) class. 201 | 202 | ## Use a custom Selector 203 | 204 | You can specify a `SelectorInterface` implementation when you create a `NodePoolInterface` instance. 205 | For instance, imagine you implemented a `CustomSelector` and a custom `CustomResurrect` you can 206 | use it as follows: 207 | 208 | ```php 209 | use Elastic\Transport\NodePool\SimpleNodePool; 210 | use Elastic\Transport\TransportBuilder; 211 | 212 | $nodePool = new SimpleNodePool( 213 | new CustomSelector(), 214 | new CustomResurrect() 215 | ); 216 | 217 | $transport = TransportBuilder::create() 218 | ->setHosts(['localhost:9200']) 219 | ->setNodePool($nodePool) 220 | ->build(); 221 | ``` 222 | 223 | ## Use a custom PSR-3 loggers 224 | 225 | You can specify a PSR-3 `LoggerInterface` implementation using the `TransportBuilder`. 226 | For instance, if you want to use [monolog](https://github.com/Seldaek/monolog) library 227 | you can use the following configuration: 228 | 229 | ```php 230 | use Elastic\Transport\TransportBuilder; 231 | use Monolog\Logger; 232 | use Monolog\Handler\StreamHandler; 233 | 234 | $logger = new Logger('name'); 235 | $logger->pushHandler(new StreamHandler('debug.log', Logger::DEBUG)); 236 | 237 | $transport = TransportBuilder::create() 238 | ->setHosts(['localhost:9200']) 239 | ->setLogger($logger) 240 | ->build(); 241 | ``` 242 | ## Use a custom PSR-18 clients 243 | 244 | You can specify a `PSR-18` client using the `TransportBuilder::setClient()` function. 245 | For instance, if you want to use [Symfony HTTP Client](https://symfony.com/doc/current/http_client.html) 246 | you can use the following configuration: 247 | 248 | ```php 249 | use Elastic\Transport\TransportBuilder; 250 | use Symfony\Component\HttpClient\Psr18Client; 251 | 252 | $transport = TransportBuilder::create() 253 | ->setHosts(['localhost:9200']) 254 | ->setClient(new Psr18Client) 255 | ->build(); 256 | ``` 257 | 258 | As mentioned in the introduction, we use the [HTTPlug](http://httplug.io/) library 259 | to automatic discovery a [PSR-18](https://www.php-fig.org/psr/psr-18/) client. 260 | 261 | You can use the `TransportBuilder::setClient()` to specify the client manually, for 262 | instance if you have multiple HTTP client library installed. 263 | 264 | By default, if the [PSR-18](https://www.php-fig.org/psr/psr-18/) client implements the 265 | [HttpAsyncClient](https://github.com/php-http/httplug/blob/master/src/HttpAsyncClient.php) 266 | it will use it when using `Transport::sendAsyncRequest()`. If you want you can override 267 | this setting using the `Transport::setAsyncClient()` function. That means you can use 268 | a [PSR-18](https://www.php-fig.org/psr/psr-18/) client for the syncronous requests and 269 | a different [HttpAsyncClient](https://github.com/php-http/httplug/blob/master/src/HttpAsyncClient.php) 270 | client for the asyncronous requests. 271 | 272 | ## OpenTelemetry 273 | 274 | Starting from v8.9.0 we introduced the support of OpenTelemetry for the HTTP send request. 275 | Right now the support is only for syncronous HTTP call. 276 | 277 | In order to enable the OpenTelemetry you need to set the ENV variable 278 | `OTEL_PHP_INSTRUMENTATION_ELASTICSEARCH_ENABLED` to true. 279 | 280 | We added the support of OpenTelemetry natively in the `Transport:sendRequest()` function. 281 | By default, the Transport create a span from a Tracer provider (e.g. Global) with the 282 | following attributes: 283 | 284 | ``` 285 | http.request.method 286 | url.full 287 | server.address 288 | server.port 289 | ``` 290 | 291 | We also added a `$opts` array as second optional parameter for the `Transport:sendRequest()` 292 | to pass additional attributes for OTel instrumentation. 293 | 294 | We created an [OpenTelemetry](src/OpenTelemetry.php) class to provide all the configuration. 295 | 296 | ## Copyright and License 297 | 298 | Copyright (c) [Elasticsearch B.V](https://www.elastic.co). 299 | 300 | This software is licensed under the MIT License. 301 | Read the [LICENSE](LICENSE) file for more information. 302 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elastic/transport", 3 | "type": "library", 4 | "description": "HTTP transport PHP library for Elastic products", 5 | "keywords": [ 6 | "http", 7 | "transport", 8 | "elastic", 9 | "PSR-7", 10 | "PSR_17", 11 | "PSR-18" 12 | ], 13 | "license": "MIT", 14 | "require": { 15 | "php": "^8.1", 16 | "psr/http-client": "^1.0", 17 | "psr/http-factory": "^1.0", 18 | "psr/http-message": "^2.0", 19 | "psr/log": "^2.0 || ^3.0", 20 | "php-http/discovery": "^1.14", 21 | "php-http/httplug": "^2.4", 22 | "composer-runtime-api": "^2.0", 23 | "open-telemetry/api": "^1.0", 24 | "nyholm/psr7": "^1.8" 25 | }, 26 | "require-dev": { 27 | "phpunit/phpunit": "^10", 28 | "phpstan/phpstan": "^2.1", 29 | "php-http/mock-client": "^1.5", 30 | "open-telemetry/sdk": "^1.0", 31 | "symfony/http-client": "^6.0" 32 | }, 33 | "autoload": { 34 | "psr-4": { 35 | "Elastic\\Transport\\": "src/" 36 | } 37 | }, 38 | "autoload-dev": { 39 | "psr-4": { 40 | "Elastic\\Transport\\Test\\" : "tests/" 41 | } 42 | }, 43 | "scripts": { 44 | "test": [ 45 | "vendor/bin/phpunit --testdox" 46 | ], 47 | "phpstan": [ 48 | "vendor/bin/phpstan analyse" 49 | ] 50 | }, 51 | "config": { 52 | "allow-plugins": { 53 | "php-http/discovery": true, 54 | "tbachert/spi": true 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Async/OnFailureDefault.php: -------------------------------------------------------------------------------- 1 | 45 | */ 46 | protected array $options; 47 | 48 | /** 49 | * @param array $options 50 | */ 51 | public function __construct(array $options = []) 52 | { 53 | $this->options = $options; 54 | } 55 | 56 | /** 57 | * @param array $options 58 | */ 59 | public function setOptions(array $options): void 60 | { 61 | $this->options = $options; 62 | } 63 | 64 | /** 65 | * @throws CurlException 66 | */ 67 | public function sendRequest(RequestInterface $request): ResponseInterface 68 | { 69 | curl_reset($this->getCurl()); 70 | 71 | $headers = []; 72 | foreach ($request->getHeaders() as $name => $values) { 73 | foreach ($values as $value) { 74 | $headers[] = sprintf("%s: %s", $name, $value); 75 | } 76 | } 77 | $curlOptions = [ 78 | CURLOPT_HTTP_VERSION => $this->getCurlHttpVersion($request->getProtocolVersion()), 79 | CURLOPT_CUSTOMREQUEST => $request->getMethod(), 80 | CURLOPT_CONNECTTIMEOUT => 0, 81 | CURLOPT_URL => (string) $request->getUri(), 82 | CURLOPT_NOBODY => $request->getMethod() === 'HEAD', 83 | CURLOPT_RETURNTRANSFER => true, 84 | CURLOPT_HEADER => true, 85 | CURLOPT_HTTPHEADER => $headers 86 | ]; 87 | 88 | if (!in_array($request->getMethod(), self::BODYLESS_HTTP_METHODS, true)) { 89 | $curlOptions[CURLOPT_POSTFIELDS] = (string) $request->getBody(); 90 | } 91 | 92 | curl_setopt_array($this->getCurl(), array_replace($curlOptions, $this->options)); 93 | $response = curl_exec($this->getCurl()); 94 | 95 | if ($response !== false && curl_errno($this->getCurl()) === 0) { 96 | $parse = $this->parseResponse((string) $response); 97 | return new Response( 98 | $parse['status-code'], 99 | $parse['headers'], 100 | $parse['body'], 101 | $parse['http-version'], 102 | $parse['reason-phrase'] 103 | ); 104 | } 105 | 106 | throw new CurlException(sprintf( 107 | "Error sending with cURL (%d): %s", 108 | curl_errno($this->getCurl()), 109 | curl_error($this->getCurl()) 110 | )); 111 | } 112 | 113 | private function getCurl(): CurlHandle 114 | { 115 | if (empty($this->curl)) { 116 | $init = curl_init(); 117 | if (false === $init) { 118 | throw new CurlException("I cannot execute curl initialization"); 119 | } 120 | $this->curl = $init; 121 | } 122 | return $this->curl; 123 | } 124 | 125 | /** 126 | * Return cURL constant for specified HTTP version. 127 | * 128 | * @throws CurlException If unsupported version requested. 129 | */ 130 | private function getCurlHttpVersion(string $version): int 131 | { 132 | switch ($version) { 133 | case '1.0': 134 | return CURL_HTTP_VERSION_1_0; 135 | case '1.1': 136 | return CURL_HTTP_VERSION_1_1; 137 | case '2.0': 138 | if (defined('CURL_HTTP_VERSION_2_0')) { 139 | return CURL_HTTP_VERSION_2_0; 140 | } 141 | throw new CurlException('libcurl 7.33 needed for HTTP 2.0 support'); 142 | } 143 | 144 | return CURL_HTTP_VERSION_NONE; 145 | } 146 | 147 | /** 148 | * Parses the HTTP response from curl and 149 | * generates the start-line, headers and the body 150 | * 151 | * @see https://datatracker.ietf.org/doc/html/rfc7230#section-3 152 | * 153 | * @return array{ 154 | * http-version: string, // HTTP version (e.g. if HTTP/1.1 http-version is "1.1") 155 | * status-code: int, // The status code of the response (e.g. 200) 156 | * reason-phrase: string, // The reason-phrase (e.g. OK) 157 | * headers: array, // The HTTP headers 158 | * body: string, // The body content (can be empty) 159 | * } 160 | */ 161 | private function parseResponse(string $response): array 162 | { 163 | $lines = explode(self::HTTP_SPEC_CRLF, $response); 164 | $output = [ 165 | 'http-version' => '', 166 | 'status-code' => 200, 167 | 'reason-phrase' => '', 168 | 'headers' => [], 169 | 'body' => '' 170 | ]; 171 | foreach ($lines as $i => $line) { 172 | // status-line 173 | // @see https://datatracker.ietf.org/doc/html/rfc7230#section-3.1.2 174 | if ($i === 0) { 175 | $statusLine = explode(self::HTTP_SPEC_SP, $line, 3); 176 | $output['http-version'] = explode('/', $statusLine[0], 2)[1]; 177 | $output['status-code'] = (int) $statusLine[1]; 178 | $output['reason-phrase'] = $statusLine[2]; 179 | continue; 180 | } 181 | // Empty line, end of headers 182 | if (empty($line)) { 183 | $output['body'] = $lines[$i+1] ?? ''; 184 | break; 185 | } 186 | // Extract header name and values 187 | [$name, $value] = explode(':', $line, 2); 188 | if (!isset($output['headers'][$name])) { 189 | $output['headers'][$name]= [$value]; 190 | } else { 191 | $output['headers'][$name][]= $value; 192 | } 193 | } 194 | return $output; 195 | } 196 | } -------------------------------------------------------------------------------- /src/Exception/CloudIdParseException.php: -------------------------------------------------------------------------------- 1 | uri = Psr17FactoryDiscovery::findUriFactory()->createUri($host); 36 | } 37 | 38 | public function markAlive(bool $alive): void 39 | { 40 | $this->alive = $alive; 41 | $this->failedPings = $alive ? 0 : ($this->failedPings + 1); 42 | $this->lastPing = time(); 43 | } 44 | 45 | public function isAlive(): bool 46 | { 47 | return $this->alive; 48 | } 49 | 50 | public function getUri(): UriInterface 51 | { 52 | return $this->uri; 53 | } 54 | 55 | public function getLastPing(): ?int 56 | { 57 | return $this->lastPing; 58 | } 59 | 60 | public function getFailedPings(): int 61 | { 62 | return $this->failedPings; 63 | } 64 | } -------------------------------------------------------------------------------- /src/NodePool/NodePoolInterface.php: -------------------------------------------------------------------------------- 1 | getRequestFactory()->createRequest("HEAD", $node->getUri()); 32 | try { 33 | $response = $this->getClient()->sendRequest($request); 34 | return $response->getStatusCode() === 200; 35 | } catch (Exception $e) { 36 | return false; 37 | } 38 | } 39 | 40 | public function getClient(): ClientInterface 41 | { 42 | if (empty($this->client)) { 43 | $this->client = Psr18ClientDiscovery::find(); 44 | } 45 | return $this->client; 46 | } 47 | 48 | public function getRequestFactory(): RequestFactoryInterface 49 | { 50 | if (empty($this->requestFactory)) { 51 | $this->requestFactory = Psr17FactoryDiscovery::findRequestFactory(); 52 | } 53 | return $this->requestFactory; 54 | } 55 | } -------------------------------------------------------------------------------- /src/NodePool/Resurrect/NoResurrect.php: -------------------------------------------------------------------------------- 1 | nodes)) { 27 | $className = substr(__CLASS__, strrpos(__CLASS__, '\\') + 1); 28 | throw new NoNodeAvailableException(sprintf( 29 | "No node available. Please use %s::setNodes() before calling %s::nextNode().", 30 | $className, 31 | $className 32 | )); 33 | } 34 | $node = next($this->nodes); 35 | if (false === $node) { 36 | return reset($this->nodes); 37 | } 38 | return $node; 39 | } 40 | } -------------------------------------------------------------------------------- /src/NodePool/Selector/SelectorInterface.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | protected array $nodes = []; 26 | 27 | public function setNodes(array $nodes): void 28 | { 29 | foreach ($nodes as $node) { 30 | if (!$node instanceof Node) { 31 | throw new InvalidArrayException(sprintf( 32 | "The nodes array must contain only %s objects", 33 | Node::class 34 | )); 35 | } 36 | } 37 | $this->nodes = $nodes; 38 | } 39 | 40 | public function getNodes(): array 41 | { 42 | return $this->nodes; 43 | } 44 | } -------------------------------------------------------------------------------- /src/NodePool/SimpleNodePool.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | protected $nodes = []; 31 | 32 | /** 33 | * @var SelectorInterface 34 | */ 35 | protected $selector; 36 | 37 | /** 38 | * @var ResurrectInterface 39 | */ 40 | protected $resurrect; 41 | 42 | public function __construct(SelectorInterface $selector, ResurrectInterface $resurrect) 43 | { 44 | $this->selector = $selector; 45 | $this->resurrect = $resurrect; 46 | } 47 | 48 | public function setHosts(array $hosts): self 49 | { 50 | $this->nodes = []; 51 | foreach ($hosts as $host) { 52 | $this->nodes[] = new Node($host); 53 | } 54 | shuffle($this->nodes); // randomize for use different hosts on each execution 55 | $this->selector->setNodes($this->nodes); 56 | 57 | return $this; 58 | } 59 | 60 | public function nextNode(): Node 61 | { 62 | $totNodes = count($this->nodes); 63 | $dead = 0; 64 | 65 | while ($dead < $totNodes) { 66 | $next = $this->selector->nextNode(); 67 | if ($next->isAlive()) { 68 | return $next; 69 | } 70 | if ($this->resurrect->ping($next)) { 71 | $next->markAlive(true); 72 | return $next; 73 | } 74 | $dead++; 75 | } 76 | 77 | throw new NoNodeAvailableException(sprintf( 78 | 'No alive nodes. All the %d nodes seem to be down.', 79 | $totNodes 80 | )); 81 | } 82 | } -------------------------------------------------------------------------------- /src/OpenTelemetry.php: -------------------------------------------------------------------------------- 1 | getTracer( 94 | self::OTEL_TRACER_NAME, 95 | Transport::VERSION 96 | ); 97 | } 98 | 99 | /** 100 | * @param array $sanitizeKeys 101 | */ 102 | private static function sanitizeBody(string $body, array $sanitizeKeys): string 103 | { 104 | if (empty($body)) { 105 | return ''; 106 | } 107 | $json = json_decode($body, true); 108 | if (!is_array($json)) { 109 | return ''; 110 | } 111 | $patterns = array_merge(self::DEFAULT_SANITIZER_KEY_PATTERNS, $sanitizeKeys); 112 | 113 | // Convert the patterns array into a regex 114 | $regex = sprintf('/%s/', implode('|', $patterns)); 115 | // Recursively traverse the array and redact the specified keys 116 | array_walk_recursive($json, function (&$value, $key) use ($regex) { 117 | if (preg_match($regex, $key, $matches)) { 118 | $value = self::REDACTED_STRING; 119 | } 120 | }); 121 | return JsonSerializer::serialize($json); 122 | } 123 | } -------------------------------------------------------------------------------- /src/Serializer/CsvSerializer.php: -------------------------------------------------------------------------------- 1 | 55 | */ 56 | public static function unserialize(string $data, array $options = []): array 57 | { 58 | $result = []; 59 | foreach (explode("\n", $data) as $row) { 60 | $result[] = str_getcsv(string:$row, escape:"\\"); 61 | } 62 | return $result; 63 | } 64 | } -------------------------------------------------------------------------------- /src/Serializer/JsonSerializer.php: -------------------------------------------------------------------------------- 1 | (bool) enable/disable the removing of 31 | * null values (default is true) 32 | * 33 | * @param mixed $data 34 | */ 35 | public static function serialize($data, array $options = []): string 36 | { 37 | if (empty($data)) { 38 | return '{}'; 39 | } 40 | if (is_string($data)) { 41 | return $data; 42 | } 43 | try { 44 | $removeNull = $options['remove_null'] ?? true; 45 | if ($removeNull) { 46 | Utility::removeNullValue($data); 47 | } 48 | return json_encode($data, JSON_PRESERVE_ZERO_FRACTION + JSON_INVALID_UTF8_SUBSTITUTE + JSON_THROW_ON_ERROR); 49 | } catch (JsonException $e) { 50 | throw new InvalidJsonException(sprintf( 51 | "I cannot serialize to Json: %s", 52 | $e->getMessage() 53 | )); 54 | } 55 | } 56 | 57 | /** 58 | * The available options are: 59 | * 'type' => (string) specify if the output should be an array 60 | * or an object (default is array) 61 | * 62 | * @inheritdoc 63 | */ 64 | public static function unserialize(string $data, array $options = []) 65 | { 66 | try { 67 | $type = $options['type'] ?? 'array'; 68 | if (!in_array($type, ['object', 'array'])) { 69 | throw new UndefinedPropertyException("The unserialize 'type' option must be object or array"); 70 | } 71 | return json_decode($data, $type === 'array', 512, JSON_THROW_ON_ERROR); 72 | } catch (JsonException $e) { 73 | throw new InvalidJsonException(sprintf( 74 | "Not a valid Json: %s", 75 | $e->getMessage() 76 | )); 77 | } 78 | } 79 | } -------------------------------------------------------------------------------- /src/Serializer/NDJsonSerializer.php: -------------------------------------------------------------------------------- 1 | (bool) enable/disable the removing of 31 | * null values (default is true) 32 | * 33 | * @param array $data 34 | */ 35 | public static function serialize($data, array $options = []): string 36 | { 37 | $result = ''; 38 | foreach ($data as $row) { 39 | if (empty($row)) { 40 | $result .= "{}\n"; 41 | continue; 42 | } 43 | $result .= JsonSerializer::serialize($row, $options) . "\n"; 44 | } 45 | return $result; 46 | } 47 | 48 | /** 49 | * The available options are: 50 | * 'type' => (string) specify if the array result should contain object 51 | * or array (default is array) 52 | * 53 | * @inheritdoc 54 | */ 55 | public static function unserialize(string $data, array $options = []) 56 | { 57 | $array = explode(strpos($data, "\r\n") !== false ? "\r\n" : "\n", $data); 58 | $result = []; 59 | foreach ($array as $json) { 60 | if (empty($json)) { 61 | continue; 62 | } 63 | try { 64 | $result[] = JsonSerializer::unserialize($json, $options); 65 | } catch (JsonException $e) { 66 | throw new InvalidJsonException(sprintf( 67 | "Not a valid NDJson: %s", 68 | $e->getMessage() 69 | )); 70 | } 71 | } 72 | $type = $options['type'] ?? 'array'; 73 | return $type === 'array' ? $result : new ArrayObject($result); 74 | } 75 | } -------------------------------------------------------------------------------- /src/Serializer/SerializerInterface.php: -------------------------------------------------------------------------------- 1 | $options 22 | * @return string 23 | */ 24 | public static function serialize($data, array $options = []): string; 25 | 26 | /** 27 | * @param string $data 28 | * @param array $options 29 | * @return mixed 30 | */ 31 | public static function unserialize(string $data, array $options = []); 32 | } -------------------------------------------------------------------------------- /src/Serializer/TextSerializer.php: -------------------------------------------------------------------------------- 1 | &$value) { 41 | if (is_object($value) || is_array($value)) { 42 | self::removeNullValue($value); 43 | } 44 | if (null === $value) { 45 | if (is_array($data)) { 46 | unset($data[$property]); 47 | } 48 | if (is_object($data)) { 49 | unset($data->$property); 50 | } 51 | } 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /src/Serializer/XmlSerializer.php: -------------------------------------------------------------------------------- 1 | asXML(); 32 | return false === $xml ? '' : $xml; 33 | } 34 | throw new InvalidXmlException(sprintf( 35 | "Not a valid SimpleXMLElement: %s", 36 | serialize($data) 37 | )); 38 | } 39 | 40 | /** 41 | * @return SimpleXMLElement 42 | */ 43 | public static function unserialize(string $data, array $options = []): SimpleXMLElement 44 | { 45 | $result = simplexml_load_string($data); 46 | if (false === $result) { 47 | $errors = libxml_get_errors(); 48 | libxml_clear_errors(); 49 | throw new InvalidXmlException(sprintf( 50 | "Not a valid XML: %s", 51 | serialize($errors) 52 | )); 53 | } 54 | return $result; 55 | } 56 | } -------------------------------------------------------------------------------- /src/Transport.php: -------------------------------------------------------------------------------- 1 | 62 | */ 63 | private array $headers = []; 64 | private string $user; 65 | private string $password; 66 | private ?RequestInterface $lastRequest = null; 67 | private ?ResponseInterface $lastResponse = null; 68 | private string $OSVersion; 69 | private int $retries = 0; 70 | private HttpAsyncClient $asyncClient; 71 | private OnSuccessInterface $onAsyncSuccess; 72 | private OnFailureInterface $onAsyncFailure; 73 | private TracerInterface $otelTracer; 74 | 75 | public function __construct( 76 | ClientInterface $client, 77 | NodePoolInterface $nodePool, 78 | LoggerInterface $logger 79 | ) { 80 | $this->client = $client; 81 | $this->nodePool = $nodePool; 82 | $this->logger = $logger; 83 | } 84 | 85 | public function getClient(): ClientInterface 86 | { 87 | return $this->client; 88 | } 89 | 90 | public function getNodePool(): NodePoolInterface 91 | { 92 | return $this->nodePool; 93 | } 94 | 95 | public function getLogger(): LoggerInterface 96 | { 97 | return $this->logger; 98 | } 99 | 100 | public function getOTelTracer(): TracerInterface 101 | { 102 | if (empty($this->otelTracer)) { 103 | $this->otelTracer = OpenTelemetry::getTracer( 104 | Globals::tracerProvider() 105 | ); 106 | } 107 | return $this->otelTracer; 108 | } 109 | 110 | public function setOTelTracer(TracerInterface $tracer): self 111 | { 112 | $this->otelTracer = $tracer; 113 | return $this; 114 | } 115 | 116 | public function setHeader(string $name, string $value): self 117 | { 118 | $this->headers[$name] = $value; 119 | return $this; 120 | } 121 | 122 | /** 123 | * @throws InvalidArgumentException 124 | */ 125 | public function setRetries(int $num): self 126 | { 127 | if ($num < 0) { 128 | throw new InvalidArgumentException('The retries number must be a positive integer'); 129 | } 130 | $this->retries = $num; 131 | return $this; 132 | } 133 | 134 | public function getRetries(): int 135 | { 136 | return $this->retries; 137 | } 138 | 139 | /** 140 | * @return array 141 | */ 142 | public function getHeaders(): array 143 | { 144 | return $this->headers; 145 | } 146 | 147 | public function setUserInfo(string $user, string $password = ''): self 148 | { 149 | $this->user = $user; 150 | $this->password = $password; 151 | return $this; 152 | } 153 | 154 | public function setUserAgent(string $name, string $version): self 155 | { 156 | $this->headers['User-Agent'] = sprintf( 157 | "%s/%s (%s %s; PHP %s)", 158 | $name, 159 | $version, 160 | PHP_OS, 161 | $this->getOSVersion(), 162 | phpversion() 163 | ); 164 | return $this; 165 | } 166 | 167 | /** 168 | * Set the x-elastic-client-meta header 169 | * 170 | * The header format is specified by the following regex: 171 | * ^[a-z]{1,}=[a-z0-9\.\-]{1,}(?:,[a-z]{1,}=[a-z0-9\.\-]+)*$ 172 | */ 173 | public function setElasticMetaHeader(string $clientName, string $clientVersion, bool $async = false): self 174 | { 175 | $phpSemVersion = sprintf("%d.%d.%d", PHP_MAJOR_VERSION, PHP_MINOR_VERSION, PHP_RELEASE_VERSION); 176 | $meta = sprintf( 177 | "%s=%s,php=%s,t=%s,a=%d", 178 | $clientName, 179 | $this->purgePreReleaseTag($clientVersion), 180 | $phpSemVersion, 181 | $this->purgePreReleaseTag(self::VERSION), 182 | $async ? 1 : 0 // 0=syncronous, 1=asynchronous 183 | ); 184 | $lib = $this->getClientLibraryInfo(); 185 | if (!empty($lib)) { 186 | $meta .= sprintf(",%s=%s", $lib[0], $lib[1]); 187 | } 188 | $this->headers[self::ELASTIC_META_HEADER] = $meta; 189 | return $this; 190 | } 191 | 192 | /** 193 | * Remove pre-release suffix with a single 'p' letter 194 | */ 195 | private function purgePreReleaseTag(string $version): string 196 | { 197 | return str_replace(['alpha', 'beta', 'snapshot', 'rc', 'pre'], 'p', strtolower($version)); 198 | } 199 | 200 | public function getLastRequest(): ?RequestInterface 201 | { 202 | return $this->lastRequest; 203 | } 204 | 205 | public function getLastResponse(): ?ResponseInterface 206 | { 207 | return $this->lastResponse; 208 | } 209 | 210 | /** 211 | * Setup the headers, if not already present 212 | */ 213 | private function setupHeaders(RequestInterface $request): RequestInterface 214 | { 215 | foreach ($this->headers as $name => $value) { 216 | if (!$request->hasHeader($name)) { 217 | $request = $request->withHeader($name, $value); 218 | } 219 | } 220 | return $request; 221 | } 222 | 223 | /** 224 | * Setup the user info, if not already present 225 | */ 226 | private function setupUserInfo(RequestInterface $request): RequestInterface 227 | { 228 | $uri = $request->getUri(); 229 | if (empty($uri->getUserInfo())) { 230 | if (isset($this->user)) { 231 | $request = $request->withUri($uri->withUserInfo($this->user, $this->password)); 232 | } 233 | } 234 | return $request; 235 | } 236 | 237 | /** 238 | * Setup the connection Uri 239 | */ 240 | private function setupConnectionUri(Node $node, RequestInterface $request): RequestInterface 241 | { 242 | $uri = $node->getUri(); 243 | $path = $request->getUri()->getPath(); 244 | 245 | $nodePath = $uri->getPath(); 246 | // If the node has a path we need to use it as prefix for the existing path 247 | // @see https://github.com/elastic/elastic-transport-php/pull/20 248 | if (!empty($nodePath)) { 249 | $path = sprintf("%s/%s", rtrim($nodePath, '/'), ltrim($path,'/')); 250 | } 251 | // If the user information is not in the request, we check if it is present in the node uri 252 | // @see https://github.com/elastic/elastic-transport-php/issues/18 253 | if (empty($request->getUri()->getUserInfo()) && !empty($uri->getUserInfo())) { 254 | $userInfo = explode(':', $uri->getUserInfo()); 255 | $request = $request->withUri( 256 | $request->getUri() 257 | ->withUserInfo($userInfo[0], $userInfo[1] ?? null) 258 | ); 259 | } 260 | return $request->withUri( 261 | $request->getUri() 262 | ->withHost($uri->getHost()) 263 | ->withPort($uri->getPort()) 264 | ->withScheme($uri->getScheme()) 265 | ->withPath($path) 266 | ); 267 | } 268 | 269 | private function decorateRequest(RequestInterface $request): RequestInterface 270 | { 271 | $request = $this->setupHeaders($request); 272 | return $this->setupUserInfo($request); 273 | } 274 | 275 | private function logHeaders(MessageInterface $message): void 276 | { 277 | $this->logger->debug(sprintf( 278 | "Headers: %s\nBody: %s", 279 | json_encode($message->getHeaders()), 280 | (string) $message->getBody() 281 | )); 282 | 283 | $message->getBody()->rewind(); 284 | } 285 | 286 | private function logRequest(string $title, RequestInterface $request): void 287 | { 288 | $this->logger->info(sprintf( 289 | "%s: %s %s", 290 | $title, 291 | $request->getMethod(), 292 | (string) $request->getUri() 293 | ), [ 294 | 'request' => $request 295 | ]); 296 | $this->logHeaders($request); 297 | } 298 | 299 | private function logResponse(string $title, ResponseInterface $response, int $retry): void 300 | { 301 | $this->logger->info(sprintf( 302 | "%s (retry %d): %d", 303 | $title, 304 | $retry, 305 | $response->getStatusCode() 306 | ), [ 307 | 'response' => $response, 308 | 'retry' => $retry 309 | ]); 310 | $this->logHeaders($response); 311 | } 312 | 313 | /** 314 | * @throws NoNodeAvailableException 315 | * @throws ClientExceptionInterface 316 | */ 317 | public function sendRequest(RequestInterface $request): ResponseInterface 318 | { 319 | if (empty($request->getUri()->getHost())) { 320 | $node = $this->nodePool->nextNode(); 321 | $request = $this->setupConnectionUri($node, $request); 322 | } 323 | $request = $this->decorateRequest($request); 324 | $this->lastRequest = $request; 325 | $this->logRequest("Request", $request); 326 | 327 | // OpenTelemetry get tracer 328 | if (getenv(OpenTelemetry::ENV_VARIABLE_ENABLED)) { 329 | $tracer = $this->getOTelTracer(); 330 | } 331 | 332 | $lastNetworkException = null; 333 | $count = -1; 334 | while ($count < $this->getRetries()) { 335 | try { 336 | $count++; 337 | // OpenTelemetry span start 338 | if (!empty($tracer)) { 339 | if ($request instanceof ServerRequestInterface) { 340 | $opts = $request->getAttribute(OpenTelemetry::PSR7_OTEL_ATTRIBUTE_NAME, []); 341 | } 342 | $spanName = $opts['db.operation.name'] ?? $request->getUri()->getPath(); 343 | $span = $tracer->spanBuilder($spanName)->startSpan(); 344 | $span->setAttribute('http.request.method', $request->getMethod()); 345 | $span->setAttribute('url.full', $this->getFullUrl($request)); 346 | $span->setAttribute('server.address', $request->getUri()->getHost()); 347 | $span->setAttribute('server.port', $request->getUri()->getPort()); 348 | if (!empty($opts)) { 349 | $span->setAttributes($opts); 350 | } 351 | } 352 | $response = $this->client->sendRequest($request); 353 | 354 | $this->lastResponse = $response; 355 | $this->logResponse("Response", $response, $count); 356 | 357 | return $response; 358 | } catch (NetworkExceptionInterface $e) { 359 | $lastNetworkException = $e; 360 | $this->logger->error(sprintf("Retry %d: %s", $count, $e->getMessage())); 361 | if (!empty($span)) { 362 | $span->setAttribute('error.type', $e->getMessage()); 363 | } 364 | if (isset($node)) { 365 | $node->markAlive(false); 366 | $node = $this->nodePool->nextNode(); 367 | $request = $this->setupConnectionUri($node, $request); 368 | } 369 | } catch (ClientExceptionInterface $e) { 370 | $this->logger->error(sprintf("Retry %d: %s", $count, $e->getMessage())); 371 | if (!empty($span)) { 372 | $span->setAttribute('error.type', $e->getMessage()); 373 | } 374 | throw $e; 375 | } finally { 376 | // OpenTelemetry span end 377 | if (!empty($span)) { 378 | $span->end(); 379 | } 380 | } 381 | } 382 | $exceededMsg = sprintf("Exceeded maximum number of retries (%d)", $this->getRetries()); 383 | $this->logger->error($exceededMsg); 384 | throw new NoNodeAvailableException($exceededMsg, 0, $lastNetworkException); 385 | } 386 | 387 | public function setAsyncClient(HttpAsyncClient $asyncClient): self 388 | { 389 | $this->asyncClient = $asyncClient; 390 | return $this; 391 | } 392 | 393 | /** 394 | * @throws NoAsyncClientException 395 | */ 396 | public function getAsyncClient(): HttpAsyncClient 397 | { 398 | if (!empty($this->asyncClient)) { 399 | return $this->asyncClient; 400 | } 401 | if ($this->client instanceof HttpAsyncClient) { 402 | return $this->client; 403 | } 404 | try { 405 | $this->asyncClient = HttpAsyncClientDiscovery::find(); 406 | } catch (Exception $e) { 407 | throw new NoAsyncClientException(sprintf( 408 | "I did not find any HTTP library with HttpAsyncClient interface. " . 409 | "Make sure to install a package providing \"php-http/async-client-implementation\". " . 410 | "You can also set a specific async library using %s::setAsyncClient()", 411 | self::class 412 | )); 413 | } 414 | return $this->asyncClient; 415 | } 416 | 417 | public function setAsyncOnSuccess(OnSuccessInterface $success): self 418 | { 419 | $this->onAsyncSuccess = $success; 420 | return $this; 421 | } 422 | 423 | public function getAsyncOnSuccess(): OnSuccessInterface 424 | { 425 | if (empty($this->onAsyncSuccess)) { 426 | $this->onAsyncSuccess = new OnSuccessDefault(); 427 | } 428 | return $this->onAsyncSuccess; 429 | } 430 | 431 | public function setAsyncOnFailure(OnFailureInterface $failure): self 432 | { 433 | $this->onAsyncFailure = $failure; 434 | return $this; 435 | } 436 | 437 | public function getAsyncOnFailure(): OnFailureInterface 438 | { 439 | if (empty($this->onAsyncFailure)) { 440 | $this->onAsyncFailure = new OnFailureDefault(); 441 | } 442 | return $this->onAsyncFailure; 443 | } 444 | 445 | /** 446 | * @throws Exception 447 | */ 448 | public function sendAsyncRequest(RequestInterface $request): Promise 449 | { 450 | $client = $this->getAsyncClient(); 451 | $node = null; 452 | if (empty($request->getUri()->getHost())) { 453 | $node = $this->nodePool->nextNode(); 454 | $request = $this->setupConnectionUri($node, $request); 455 | } 456 | $request = $this->decorateRequest($request); 457 | $this->lastRequest = $request; 458 | $this->logRequest("Async Request", $request); 459 | 460 | $count = 0; 461 | $promise = $client->sendAsyncRequest($request); 462 | 463 | // onFulfilled callable 464 | $onFulfilled = function (ResponseInterface $response) use (&$count) { 465 | $this->lastResponse = $response; 466 | $this->logResponse("Async Response", $response, $count); 467 | return $this->getAsyncOnSuccess()->success($response, $count); 468 | }; 469 | 470 | // onRejected callable 471 | $onRejected = function (Exception $e) use ($client, $request, &$count, $node) { 472 | $this->logger->error(sprintf("Retry %d: %s", $count, $e->getMessage())); 473 | $this->getAsyncOnFailure()->failure($e, $request, $count, $node ?? null); 474 | if (isset($node)) { 475 | $node->markAlive(false); 476 | $node = $this->nodePool->nextNode(); 477 | $request = $this->setupConnectionUri($node, $request); 478 | } 479 | $count++; 480 | return $client->sendAsyncRequest($request); 481 | }; 482 | 483 | // Add getRetries() callables using then() 484 | for ($i=0; $i < $this->getRetries(); $i++) { 485 | $promise = $promise->then($onFulfilled, $onRejected); 486 | } 487 | // Add the last getRetries()+1 callable for managing the exceeded error 488 | $promise = $promise->then($onFulfilled, function(Exception $e) use (&$count) { 489 | $exceededMsg = sprintf("Exceeded maximum number of retries (%d)", $this->getRetries()); 490 | $this->logger->error(sprintf("Retry %d: %s", $count, $e->getMessage())); 491 | $this->logger->error($exceededMsg); 492 | throw new NoNodeAvailableException(sprintf("%s: %s", $exceededMsg, $e->getMessage())); 493 | }); 494 | return $promise; 495 | } 496 | 497 | /** 498 | * Get the OS version using php_uname if available 499 | * otherwise it returns an empty string 500 | */ 501 | private function getOSVersion(): string 502 | { 503 | if (!isset($this->OSVersion)) { 504 | $disable_functions = (string) ini_get('disable_functions'); 505 | $this->OSVersion = strpos(strtolower($disable_functions), 'php_uname') !== false 506 | ? '' 507 | : php_uname("r"); 508 | } 509 | return $this->OSVersion; 510 | } 511 | 512 | /** 513 | * Returns the name and the version of the Client HTTP library used 514 | * Here a list of supported libraries: 515 | * gu => guzzlehttp/guzzle 516 | * sy => symfony/http-client 517 | * ec => elastic curl client (Elastic\Transport\Client\Curl) 518 | * 519 | * @return array 520 | */ 521 | private function getClientLibraryInfo(): array 522 | { 523 | $clientClass = get_class($this->client); 524 | if (false !== strpos($clientClass, 'GuzzleHttp\Client')) { 525 | return ['gu', InstalledVersions::getPrettyVersion('guzzlehttp/guzzle')]; 526 | } 527 | if (false !== strpos($clientClass, 'Symfony\Component\HttpClient')) { 528 | return ['sy', InstalledVersions::getPrettyVersion('symfony/http-client')]; 529 | } 530 | if (false !== strpos($clientClass, 'Elastic\Transport\Client\Curl')) { 531 | return ['ec', Transport::VERSION]; 532 | } 533 | return []; 534 | } 535 | 536 | /** 537 | * Return the full URL in the format 538 | * scheme://host:port/path?query_string 539 | */ 540 | private function getFullUrl(RequestInterface $request): string 541 | { 542 | $fullUrl = sprintf( 543 | "%s://%s:%s%s", 544 | $request->getUri()->getScheme(), 545 | $request->getUri()->getHost(), 546 | $request->getUri()->getPort(), 547 | $request->getUri()->getPath() 548 | ); 549 | $queryString = $request->getUri()->getQuery(); 550 | if (!empty($queryString)) { 551 | $fullUrl .= '?' . $queryString; 552 | } 553 | return $fullUrl; 554 | } 555 | } 556 | -------------------------------------------------------------------------------- /src/TransportBuilder.php: -------------------------------------------------------------------------------- 1 | 38 | */ 39 | protected array $hosts = []; 40 | protected TracerInterface $OTelTracer; 41 | 42 | final public function __construct() 43 | { 44 | } 45 | 46 | public static function create(): TransportBuilder 47 | { 48 | return new static(); 49 | } 50 | 51 | public function setClient(ClientInterface $client): self 52 | { 53 | $this->client = $client; 54 | return $this; 55 | } 56 | 57 | public function getClient(): ClientInterface 58 | { 59 | if (empty($this->client)) { 60 | try { 61 | $this->client = Psr18ClientDiscovery::find(); 62 | } catch (NotFoundException $e) { 63 | $this->client = new Curl(); 64 | } 65 | } 66 | return $this->client; 67 | } 68 | 69 | public function setNodePool(NodePoolInterface $nodePool): self 70 | { 71 | $this->nodePool = $nodePool; 72 | return $this; 73 | } 74 | 75 | public function getNodePool(): NodePoolInterface 76 | { 77 | if (empty($this->nodePool)) { 78 | $this->nodePool = new SimpleNodePool( 79 | new RoundRobin(), 80 | new NoResurrect() 81 | ); 82 | } 83 | return $this->nodePool; 84 | } 85 | 86 | public function setLogger(LoggerInterface $logger): self 87 | { 88 | $this->logger = $logger; 89 | return $this; 90 | } 91 | 92 | public function getLogger(): LoggerInterface 93 | { 94 | if (empty($this->logger)) { 95 | $this->logger = new NullLogger(); 96 | } 97 | return $this->logger; 98 | } 99 | 100 | /** 101 | * @param array $hosts 102 | */ 103 | public function setHosts(array $hosts): self 104 | { 105 | $this->hosts = $hosts; 106 | return $this; 107 | } 108 | 109 | /** 110 | * @return array 111 | */ 112 | public function getHosts(): array 113 | { 114 | return $this->hosts; 115 | } 116 | 117 | public function setCloudId(string $cloudId): self 118 | { 119 | $this->hosts = [$this->parseElasticCloudId($cloudId)]; 120 | return $this; 121 | } 122 | 123 | public function build(): Transport 124 | { 125 | return new Transport( 126 | $this->getClient(), 127 | $this->getNodePool()->setHosts($this->hosts), 128 | $this->getLogger() 129 | ); 130 | } 131 | 132 | /** 133 | * Return the URL of Elastic Cloud from the Cloud ID 134 | * 135 | * @throws Exception\CloudIdParseException 136 | */ 137 | private function parseElasticCloudId(string $cloudId): string 138 | { 139 | if (strpos($cloudId, ':') !== false) { 140 | list($name, $encoded) = explode(':', $cloudId, 2); 141 | $base64 = base64_decode($encoded, true); 142 | if ($base64 !== false && strpos($base64, '$') !== false) { 143 | list($uri, $uuids) = explode('$', $base64); 144 | return sprintf("https://%s.%s", $uuids, $uri); 145 | } 146 | } 147 | throw new Exception\CloudIdParseException(sprintf( 148 | 'Cloud ID %s is not valid', 149 | $name ?? '' 150 | )); 151 | } 152 | } --------------------------------------------------------------------------------