1<?php 2/** 3 * Elasticsearch PHP client 4 * 5 * @link https://github.com/elastic/elasticsearch-php/ 6 * @copyright Copyright (c) Elasticsearch B.V (https://www.elastic.co) 7 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 8 * @license https://www.gnu.org/licenses/lgpl-2.1.html GNU Lesser General Public License, Version 2.1 9 * 10 * Licensed to Elasticsearch B.V under one or more agreements. 11 * Elasticsearch B.V licenses this file to you under the Apache 2.0 License or 12 * the GNU Lesser General Public License, Version 2.1, at your option. 13 * See the LICENSE file in the project root for more information. 14 */ 15 16 17declare(strict_types = 1); 18 19namespace Elasticsearch; 20 21use Elasticsearch\Common\Exceptions\InvalidArgumentException; 22use Elasticsearch\Common\Exceptions\RuntimeException; 23use Elasticsearch\Common\Exceptions\ElasticCloudIdParseException; 24use Elasticsearch\Common\Exceptions\AuthenticationConfigException; 25use Elasticsearch\ConnectionPool\AbstractConnectionPool; 26use Elasticsearch\ConnectionPool\Selectors\RoundRobinSelector; 27use Elasticsearch\ConnectionPool\Selectors\SelectorInterface; 28use Elasticsearch\ConnectionPool\StaticNoPingConnectionPool; 29use Elasticsearch\Connections\Connection; 30use Elasticsearch\Connections\ConnectionFactory; 31use Elasticsearch\Connections\ConnectionFactoryInterface; 32use Elasticsearch\Namespaces\NamespaceBuilderInterface; 33use Elasticsearch\Serializers\SerializerInterface; 34use Elasticsearch\ConnectionPool\Selectors; 35use Elasticsearch\Serializers\SmartSerializer; 36use GuzzleHttp\Ring\Client\CurlHandler; 37use GuzzleHttp\Ring\Client\CurlMultiHandler; 38use GuzzleHttp\Ring\Client\Middleware; 39use Psr\Log\LoggerInterface; 40use Psr\Log\NullLogger; 41use ReflectionClass; 42 43class ClientBuilder 44{ 45 /** 46 * @var Transport 47 */ 48 private $transport; 49 50 /** 51 * @var callable 52 */ 53 private $endpoint; 54 55 /** 56 * @var NamespaceBuilderInterface[] 57 */ 58 private $registeredNamespacesBuilders = []; 59 60 /** 61 * @var ConnectionFactoryInterface 62 */ 63 private $connectionFactory; 64 65 /** 66 * @var callable 67 */ 68 private $handler; 69 70 /** 71 * @var LoggerInterface 72 */ 73 private $logger; 74 75 /** 76 * @var LoggerInterface 77 */ 78 private $tracer; 79 80 /** 81 * @var string 82 */ 83 private $connectionPool = StaticNoPingConnectionPool::class; 84 85 /** 86 * @var string 87 */ 88 private $serializer = SmartSerializer::class; 89 90 /** 91 * @var string 92 */ 93 private $selector = RoundRobinSelector::class; 94 95 /** 96 * @var array 97 */ 98 private $connectionPoolArgs = [ 99 'randomizeHosts' => true 100 ]; 101 102 /** 103 * @var array 104 */ 105 private $hosts; 106 107 /** 108 * @var array 109 */ 110 private $connectionParams; 111 112 /** 113 * @var int 114 */ 115 private $retries; 116 117 /** 118 * @var bool 119 */ 120 private $sniffOnStart = false; 121 122 /** 123 * @var null|array 124 */ 125 private $sslCert = null; 126 127 /** 128 * @var null|array 129 */ 130 private $sslKey = null; 131 132 /** 133 * @var null|bool|string 134 */ 135 private $sslVerification = null; 136 137 /** 138 * @var bool 139 */ 140 private $elasticMetaHeader = true; 141 142 /** 143 * @var bool 144 */ 145 private $includePortInHostHeader = false; 146 147 /** 148 * Create an instance of ClientBuilder 149 */ 150 public static function create(): ClientBuilder 151 { 152 return new static(); 153 } 154 155 /** 156 * Can supply first parm to Client::__construct() when invoking manually or with dependency injection 157 */ 158 public function getTransport(): Transport 159 { 160 return $this->transport; 161 } 162 163 /** 164 * Can supply second parm to Client::__construct() when invoking manually or with dependency injection 165 */ 166 public function getEndpoint(): callable 167 { 168 return $this->endpoint; 169 } 170 171 /** 172 * Can supply third parm to Client::__construct() when invoking manually or with dependency injection 173 * 174 * @return NamespaceBuilderInterface[] 175 */ 176 public function getRegisteredNamespacesBuilders(): array 177 { 178 return $this->registeredNamespacesBuilders; 179 } 180 181 /** 182 * Build a new client from the provided config. Hash keys 183 * should correspond to the method name e.g. ['connectionPool'] 184 * corresponds to setConnectionPool(). 185 * 186 * Missing keys will use the default for that setting if applicable 187 * 188 * Unknown keys will throw an exception by default, but this can be silenced 189 * by setting `quiet` to true 190 * 191 * @param array $config 192 * @param bool $quiet False if unknown settings throw exception, true to silently 193 * ignore unknown settings 194 * @throws Common\Exceptions\RuntimeException 195 */ 196 public static function fromConfig(array $config, bool $quiet = false): Client 197 { 198 $builder = new static; 199 foreach ($config as $key => $value) { 200 $method = "set$key"; 201 $reflection = new ReflectionClass($builder); 202 if ($reflection->hasMethod($method)) { 203 $func = $reflection->getMethod($method); 204 if ($func->getNumberOfParameters() > 1) { 205 $builder->$method(...$value); 206 } else { 207 $builder->$method($value); 208 } 209 unset($config[$key]); 210 } 211 } 212 213 if ($quiet === false && count($config) > 0) { 214 $unknown = implode(array_keys($config)); 215 throw new RuntimeException("Unknown parameters provided: $unknown"); 216 } 217 return $builder->build(); 218 } 219 220 /** 221 * Get the default handler 222 * 223 * @param array $multiParams 224 * @param array $singleParams 225 * @throws \RuntimeException 226 */ 227 public static function defaultHandler(array $multiParams = [], array $singleParams = []): callable 228 { 229 $future = null; 230 if (extension_loaded('curl')) { 231 $config = array_merge([ 'mh' => curl_multi_init() ], $multiParams); 232 if (function_exists('curl_reset')) { 233 $default = new CurlHandler($singleParams); 234 $future = new CurlMultiHandler($config); 235 } else { 236 $default = new CurlMultiHandler($config); 237 } 238 } else { 239 throw new \RuntimeException('Elasticsearch-PHP requires cURL, or a custom HTTP handler.'); 240 } 241 242 return $future ? Middleware::wrapFuture($default, $future) : $default; 243 } 244 245 /** 246 * Get the multi handler for async (CurlMultiHandler) 247 * 248 * @throws \RuntimeException 249 */ 250 public static function multiHandler(array $params = []): CurlMultiHandler 251 { 252 if (function_exists('curl_multi_init')) { 253 return new CurlMultiHandler(array_merge([ 'mh' => curl_multi_init() ], $params)); 254 } else { 255 throw new \RuntimeException('CurlMulti handler requires cURL.'); 256 } 257 } 258 259 /** 260 * Get the handler instance (CurlHandler) 261 * 262 * @throws \RuntimeException 263 */ 264 public static function singleHandler(): CurlHandler 265 { 266 if (function_exists('curl_reset')) { 267 return new CurlHandler(); 268 } else { 269 throw new \RuntimeException('CurlSingle handler requires cURL.'); 270 } 271 } 272 273 /** 274 * Set connection Factory 275 * 276 * @param ConnectionFactoryInterface $connectionFactory 277 */ 278 public function setConnectionFactory(ConnectionFactoryInterface $connectionFactory): ClientBuilder 279 { 280 $this->connectionFactory = $connectionFactory; 281 282 return $this; 283 } 284 285 /** 286 * Set the connection pool (default is StaticNoPingConnectionPool) 287 * 288 * @param AbstractConnectionPool|string $connectionPool 289 * @param array $args 290 * @throws \InvalidArgumentException 291 */ 292 public function setConnectionPool($connectionPool, array $args = []): ClientBuilder 293 { 294 if (is_string($connectionPool)) { 295 $this->connectionPool = $connectionPool; 296 $this->connectionPoolArgs = $args; 297 } elseif (is_object($connectionPool)) { 298 $this->connectionPool = $connectionPool; 299 } else { 300 throw new InvalidArgumentException("Serializer must be a class path or instantiated object extending AbstractConnectionPool"); 301 } 302 303 return $this; 304 } 305 306 /** 307 * Set the endpoint 308 * 309 * @param callable $endpoint 310 */ 311 public function setEndpoint(callable $endpoint): ClientBuilder 312 { 313 $this->endpoint = $endpoint; 314 315 return $this; 316 } 317 318 /** 319 * Register namespace 320 * 321 * @param NamespaceBuilderInterface $namespaceBuilder 322 */ 323 public function registerNamespace(NamespaceBuilderInterface $namespaceBuilder): ClientBuilder 324 { 325 $this->registeredNamespacesBuilders[] = $namespaceBuilder; 326 327 return $this; 328 } 329 330 /** 331 * Set the transport 332 * 333 * @param Transport $transport 334 */ 335 public function setTransport(Transport $transport): ClientBuilder 336 { 337 $this->transport = $transport; 338 339 return $this; 340 } 341 342 /** 343 * Set the HTTP handler (cURL is default) 344 * 345 * @param mixed $handler 346 */ 347 public function setHandler($handler): ClientBuilder 348 { 349 $this->handler = $handler; 350 351 return $this; 352 } 353 354 /** 355 * Set the PSR-3 Logger 356 * 357 * @param LoggerInterface $logger 358 */ 359 public function setLogger(LoggerInterface $logger): ClientBuilder 360 { 361 $this->logger = $logger; 362 363 return $this; 364 } 365 366 /** 367 * Set the PSR-3 tracer 368 * 369 * @param LoggerInterface $tracer 370 */ 371 public function setTracer(LoggerInterface $tracer): ClientBuilder 372 { 373 $this->tracer = $tracer; 374 375 return $this; 376 } 377 378 /** 379 * Set the serializer 380 * 381 * @param \Elasticsearch\Serializers\SerializerInterface|string $serializer 382 */ 383 public function setSerializer($serializer): ClientBuilder 384 { 385 $this->parseStringOrObject($serializer, $this->serializer, 'SerializerInterface'); 386 387 return $this; 388 } 389 390 /** 391 * Set the hosts (nodes) 392 * 393 * @param array $hosts 394 */ 395 public function setHosts(array $hosts): ClientBuilder 396 { 397 $this->hosts = $hosts; 398 399 return $this; 400 } 401 402 /** 403 * Set the APIKey Pair, consiting of the API Id and the ApiKey of the Response from /_security/api_key 404 * 405 * @throws AuthenticationConfigException 406 */ 407 public function setApiKey(string $id, string $apiKey): ClientBuilder 408 { 409 if (isset($this->connectionParams['client']['curl'][CURLOPT_HTTPAUTH]) === true) { 410 throw new AuthenticationConfigException("You can't use APIKey - and Basic Authenication together."); 411 } 412 413 $this->connectionParams['client']['headers']['Authorization'] = [ 414 'ApiKey ' . base64_encode($id . ':' . $apiKey) 415 ]; 416 417 return $this; 418 } 419 420 /** 421 * Set Basic access authentication 422 * 423 * @see https://en.wikipedia.org/wiki/Basic_access_authentication 424 * @param string $username 425 * @param string $password 426 * 427 * @throws AuthenticationConfigException 428 */ 429 public function setBasicAuthentication(string $username, string $password): ClientBuilder 430 { 431 if (isset($this->connectionParams['client']['headers']['Authorization']) === true) { 432 throw new AuthenticationConfigException("You can't use APIKey - and Basic Authenication together."); 433 } 434 435 if (isset($this->connectionParams['client']['curl']) === false) { 436 $this->connectionParams['client']['curl'] = []; 437 } 438 439 $this->connectionParams['client']['curl'] += [ 440 CURLOPT_HTTPAUTH => CURLAUTH_BASIC, 441 CURLOPT_USERPWD => $username.':'.$password 442 ]; 443 444 return $this; 445 } 446 447 /** 448 * Set Elastic Cloud ID to connect to Elastic Cloud 449 * 450 * @param string $cloudId 451 */ 452 public function setElasticCloudId(string $cloudId): ClientBuilder 453 { 454 // Register the Hosts array 455 $this->setHosts( 456 [ 457 [ 458 'host' => $this->parseElasticCloudId($cloudId), 459 'port' => '', 460 'scheme' => 'https', 461 ] 462 ] 463 ); 464 465 if (!isset($this->connectionParams['client']['curl'][CURLOPT_ENCODING])) { 466 // Merge best practices for the connection (enable gzip) 467 $this->connectionParams['client']['curl'][CURLOPT_ENCODING] = 'gzip'; 468 } 469 470 return $this; 471 } 472 473 /** 474 * Set connection parameters 475 * 476 * @param array $params 477 */ 478 public function setConnectionParams(array $params): ClientBuilder 479 { 480 $this->connectionParams = $params; 481 482 return $this; 483 } 484 485 /** 486 * Set number or retries (default is equal to number of nodes) 487 * 488 * @param int $retries 489 */ 490 public function setRetries(int $retries): ClientBuilder 491 { 492 $this->retries = $retries; 493 494 return $this; 495 } 496 497 /** 498 * Set the selector algorithm 499 * 500 * @param \Elasticsearch\ConnectionPool\Selectors\SelectorInterface|string $selector 501 */ 502 public function setSelector($selector): ClientBuilder 503 { 504 $this->parseStringOrObject($selector, $this->selector, 'SelectorInterface'); 505 506 return $this; 507 } 508 509 /** 510 * Set sniff on start 511 * 512 * @param bool $sniffOnStart enable or disable sniff on start 513 */ 514 515 public function setSniffOnStart(bool $sniffOnStart): ClientBuilder 516 { 517 $this->sniffOnStart = $sniffOnStart; 518 519 return $this; 520 } 521 522 /** 523 * Set SSL certificate 524 * 525 * @param string $cert The name of a file containing a PEM formatted certificate. 526 * @param string $password if the certificate requires a password 527 */ 528 public function setSSLCert(string $cert, string $password = null): ClientBuilder 529 { 530 $this->sslCert = [$cert, $password]; 531 532 return $this; 533 } 534 535 /** 536 * Set SSL key 537 * 538 * @param string $key The name of a file containing a private SSL key 539 * @param string $password if the private key requires a password 540 */ 541 public function setSSLKey(string $key, string $password = null): ClientBuilder 542 { 543 $this->sslKey = [$key, $password]; 544 545 return $this; 546 } 547 548 /** 549 * Set SSL verification 550 * 551 * @param bool|string $value 552 */ 553 public function setSSLVerification($value = true): ClientBuilder 554 { 555 $this->sslVerification = $value; 556 557 return $this; 558 } 559 560 /** 561 * Set or disable the x-elastic-client-meta header 562 */ 563 public function setElasticMetaHeader($value = true): ClientBuilder 564 { 565 $this->elasticMetaHeader = $value; 566 567 return $this; 568 } 569 570 /** 571 * Include the port in Host header 572 * 573 * @see https://github.com/elastic/elasticsearch-php/issues/993 574 */ 575 public function includePortInHostHeader(bool $enable): ClientBuilder 576 { 577 $this->includePortInHostHeader = $enable; 578 579 return $this; 580 } 581 582 /** 583 * Build and returns the Client object 584 */ 585 public function build(): Client 586 { 587 $this->buildLoggers(); 588 589 if (is_null($this->handler)) { 590 $this->handler = ClientBuilder::defaultHandler(); 591 } 592 593 $sslOptions = null; 594 if (isset($this->sslKey)) { 595 $sslOptions['ssl_key'] = $this->sslKey; 596 } 597 if (isset($this->sslCert)) { 598 $sslOptions['cert'] = $this->sslCert; 599 } 600 if (isset($this->sslVerification)) { 601 $sslOptions['verify'] = $this->sslVerification; 602 } 603 604 if (!is_null($sslOptions)) { 605 $sslHandler = function (callable $handler, array $sslOptions) { 606 return function (array $request) use ($handler, $sslOptions) { 607 // Add our custom headers 608 foreach ($sslOptions as $key => $value) { 609 $request['client'][$key] = $value; 610 } 611 612 // Send the request using the handler and return the response. 613 return $handler($request); 614 }; 615 }; 616 $this->handler = $sslHandler($this->handler, $sslOptions); 617 } 618 619 if (is_null($this->serializer)) { 620 $this->serializer = new SmartSerializer(); 621 } elseif (is_string($this->serializer)) { 622 $this->serializer = new $this->serializer; 623 } 624 625 $this->connectionParams['client']['x-elastic-client-meta']= $this->elasticMetaHeader; 626 $this->connectionParams['client']['port_in_header'] = $this->includePortInHostHeader; 627 628 if (is_null($this->connectionFactory)) { 629 if (is_null($this->connectionParams)) { 630 $this->connectionParams = []; 631 } 632 633 // Make sure we are setting Content-Type and Accept (unless the user has explicitly 634 // overridden it 635 if (! isset($this->connectionParams['client']['headers'])) { 636 $this->connectionParams['client']['headers'] = []; 637 } 638 $apiVersioning = getenv('ELASTIC_CLIENT_APIVERSIONING'); 639 if (! isset($this->connectionParams['client']['headers']['Content-Type'])) { 640 if ($apiVersioning === 'true' || $apiVersioning === '1') { 641 $this->connectionParams['client']['headers']['Content-Type'] = ['application/vnd.elasticsearch+json;compatible-with=7']; 642 } else { 643 $this->connectionParams['client']['headers']['Content-Type'] = ['application/json']; 644 } 645 } 646 if (! isset($this->connectionParams['client']['headers']['Accept'])) { 647 if ($apiVersioning === 'true' || $apiVersioning === '1') { 648 $this->connectionParams['client']['headers']['Accept'] = ['application/vnd.elasticsearch+json;compatible-with=7']; 649 } else { 650 $this->connectionParams['client']['headers']['Accept'] = ['application/json']; 651 } 652 } 653 654 $this->connectionFactory = new ConnectionFactory($this->handler, $this->connectionParams, $this->serializer, $this->logger, $this->tracer); 655 } 656 657 if (is_null($this->hosts)) { 658 $this->hosts = $this->getDefaultHost(); 659 } 660 661 if (is_null($this->selector)) { 662 $this->selector = new RoundRobinSelector(); 663 } elseif (is_string($this->selector)) { 664 $this->selector = new $this->selector; 665 } 666 667 $this->buildTransport(); 668 669 if (is_null($this->endpoint)) { 670 $serializer = $this->serializer; 671 672 $this->endpoint = function ($class) use ($serializer) { 673 $fullPath = '\\Elasticsearch\\Endpoints\\' . $class; 674 675 $reflection = new ReflectionClass($fullPath); 676 $constructor = $reflection->getConstructor(); 677 678 if ($constructor && $constructor->getParameters()) { 679 return new $fullPath($serializer); 680 } else { 681 return new $fullPath(); 682 } 683 }; 684 } 685 686 $registeredNamespaces = []; 687 foreach ($this->registeredNamespacesBuilders as $builder) { 688 /** 689 * @var NamespaceBuilderInterface $builder 690*/ 691 $registeredNamespaces[$builder->getName()] = $builder->getObject($this->transport, $this->serializer); 692 } 693 694 return $this->instantiate($this->transport, $this->endpoint, $registeredNamespaces); 695 } 696 697 protected function instantiate(Transport $transport, callable $endpoint, array $registeredNamespaces): Client 698 { 699 return new Client($transport, $endpoint, $registeredNamespaces); 700 } 701 702 private function buildLoggers(): void 703 { 704 if (is_null($this->logger)) { 705 $this->logger = new NullLogger(); 706 } 707 708 if (is_null($this->tracer)) { 709 $this->tracer = new NullLogger(); 710 } 711 } 712 713 private function buildTransport(): void 714 { 715 $connections = $this->buildConnectionsFromHosts($this->hosts); 716 717 if (is_string($this->connectionPool)) { 718 $this->connectionPool = new $this->connectionPool( 719 $connections, 720 $this->selector, 721 $this->connectionFactory, 722 $this->connectionPoolArgs 723 ); 724 } elseif (is_null($this->connectionPool)) { 725 $this->connectionPool = new StaticNoPingConnectionPool( 726 $connections, 727 $this->selector, 728 $this->connectionFactory, 729 $this->connectionPoolArgs 730 ); 731 } 732 733 if (is_null($this->retries)) { 734 $this->retries = count($connections); 735 } 736 737 if (is_null($this->transport)) { 738 $this->transport = new Transport($this->retries, $this->connectionPool, $this->logger, $this->sniffOnStart); 739 } 740 } 741 742 private function parseStringOrObject($arg, &$destination, $interface): void 743 { 744 if (is_string($arg)) { 745 $destination = new $arg; 746 } elseif (is_object($arg)) { 747 $destination = $arg; 748 } else { 749 throw new InvalidArgumentException("Serializer must be a class path or instantiated object implementing $interface"); 750 } 751 } 752 753 private function getDefaultHost(): array 754 { 755 return ['localhost:9200']; 756 } 757 758 /** 759 * @return \Elasticsearch\Connections\Connection[] 760 * @throws RuntimeException 761 */ 762 private function buildConnectionsFromHosts(array $hosts): array 763 { 764 $connections = []; 765 foreach ($hosts as $host) { 766 if (is_string($host)) { 767 $host = $this->prependMissingScheme($host); 768 $host = $this->extractURIParts($host); 769 } elseif (is_array($host)) { 770 $host = $this->normalizeExtendedHost($host); 771 } else { 772 $this->logger->error("Could not parse host: ".print_r($host, true)); 773 throw new RuntimeException("Could not parse host: ".print_r($host, true)); 774 } 775 776 $connections[] = $this->connectionFactory->create($host); 777 } 778 779 return $connections; 780 } 781 782 /** 783 * @throws RuntimeException 784 */ 785 private function normalizeExtendedHost(array $host): array 786 { 787 if (isset($host['host']) === false) { 788 $this->logger->error("Required 'host' was not defined in extended format: ".print_r($host, true)); 789 throw new RuntimeException("Required 'host' was not defined in extended format: ".print_r($host, true)); 790 } 791 792 if (isset($host['scheme']) === false) { 793 $host['scheme'] = 'http'; 794 } 795 if (isset($host['port']) === false) { 796 $host['port'] = 9200; 797 } 798 return $host; 799 } 800 801 /** 802 * @throws InvalidArgumentException 803 */ 804 private function extractURIParts(string $host): array 805 { 806 $parts = parse_url($host); 807 808 if ($parts === false) { 809 throw new InvalidArgumentException(sprintf('Could not parse URI: "%s"', $host)); 810 } 811 812 if (isset($parts['port']) !== true) { 813 $parts['port'] = 9200; 814 } 815 816 return $parts; 817 } 818 819 private function prependMissingScheme(string $host): string 820 { 821 if (!preg_match("/^https?:\/\//", $host)) { 822 $host = 'http://' . $host; 823 } 824 825 return $host; 826 } 827 828 /** 829 * Parse the Elastic Cloud Params from the CloudId 830 * 831 * @param string $cloudId 832 * 833 * @return string 834 * 835 * @throws ElasticCloudIdParseException 836 */ 837 private function parseElasticCloudId(string $cloudId): string 838 { 839 try { 840 list($name, $encoded) = explode(':', $cloudId); 841 list($uri, $uuids) = explode('$', base64_decode($encoded)); 842 list($es,) = explode(':', $uuids); 843 844 return $es . '.' . $uri; 845 } catch (\Throwable $t) { 846 throw new ElasticCloudIdParseException('could not parse the Cloud ID:' . $cloudId); 847 } 848 } 849} 850