1<?php 2 3declare(strict_types = 1); 4 5namespace Elasticsearch; 6 7use Elasticsearch\Common\Exceptions\InvalidArgumentException; 8use Elasticsearch\Common\Exceptions\RuntimeException; 9use Elasticsearch\ConnectionPool\AbstractConnectionPool; 10use Elasticsearch\ConnectionPool\Selectors\RoundRobinSelector; 11use Elasticsearch\ConnectionPool\Selectors\SelectorInterface; 12use Elasticsearch\ConnectionPool\StaticNoPingConnectionPool; 13use Elasticsearch\Connections\Connection; 14use Elasticsearch\Connections\ConnectionFactory; 15use Elasticsearch\Connections\ConnectionFactoryInterface; 16use Elasticsearch\Namespaces\NamespaceBuilderInterface; 17use Elasticsearch\Serializers\SerializerInterface; 18use Elasticsearch\ConnectionPool\Selectors; 19use Elasticsearch\Serializers\SmartSerializer; 20use GuzzleHttp\Ring\Client\CurlHandler; 21use GuzzleHttp\Ring\Client\CurlMultiHandler; 22use GuzzleHttp\Ring\Client\Middleware; 23use Psr\Log\LoggerInterface; 24use Psr\Log\NullLogger; 25use Monolog\Logger; 26use Monolog\Handler\StreamHandler; 27use Monolog\Processor\IntrospectionProcessor; 28 29/** 30 * Class ClientBuilder 31 * 32 * @category Elasticsearch 33 * @package Elasticsearch\Common\Exceptions 34 * @author Zachary Tong <zach@elastic.co> 35 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache2 36 * @link http://elastic.co 37 */ 38class ClientBuilder 39{ 40 /** 41 * @var Transport 42 */ 43 private $transport; 44 45 /** 46 * @var callable 47 */ 48 private $endpoint; 49 50 /** 51 * @var NamespaceBuilderInterface[] 52 */ 53 private $registeredNamespacesBuilders = []; 54 55 /** 56 * @var ConnectionFactoryInterface 57 */ 58 private $connectionFactory; 59 60 private $handler; 61 62 /** 63 * @var LoggerInterface 64 */ 65 private $logger; 66 67 /** 68 * @var LoggerInterface 69 */ 70 private $tracer; 71 72 /** 73 * @var string 74 */ 75 private $connectionPool = StaticNoPingConnectionPool::class; 76 77 /** 78 * @var string 79 */ 80 private $serializer = SmartSerializer::class; 81 82 /** 83 * @var string 84 */ 85 private $selector = RoundRobinSelector::class; 86 87 /** 88 * @var array 89 */ 90 private $connectionPoolArgs = [ 91 'randomizeHosts' => true 92 ]; 93 94 /** 95 * @var array 96 */ 97 private $hosts; 98 99 /** 100 * @var array 101 */ 102 private $connectionParams; 103 104 /** 105 * @var int 106 */ 107 private $retries; 108 109 /** 110 * @var bool 111 */ 112 private $sniffOnStart = false; 113 114 /** 115 * @var null|array 116 */ 117 private $sslCert = null; 118 119 /** 120 * @var null|array 121 */ 122 private $sslKey = null; 123 124 /** 125 * @var null|bool|string 126 */ 127 private $sslVerification = null; 128 129 public static function create(): ClientBuilder 130 { 131 return new static(); 132 } 133 134 /** 135 * Can supply first parm to Client::__construct() when invoking manually or with dependency injection 136 */ 137 public function getTransport(): Transport 138 { 139 return $this->transport; 140 } 141 142 /** 143 * Can supply second parm to Client::__construct() when invoking manually or with dependency injection 144 */ 145 public function getEndpoint(): callable 146 { 147 return $this->endpoint; 148 } 149 150 /** 151 * Can supply third parm to Client::__construct() when invoking manually or with dependency injection 152 * 153 * @return NamespaceBuilderInterface[] 154 */ 155 public function getRegisteredNamespacesBuilders(): array 156 { 157 return $this->registeredNamespacesBuilders; 158 } 159 160 /** 161 * Build a new client from the provided config. Hash keys 162 * should correspond to the method name e.g. ['connectionPool'] 163 * corresponds to setConnectionPool(). 164 * 165 * Missing keys will use the default for that setting if applicable 166 * 167 * Unknown keys will throw an exception by default, but this can be silenced 168 * by setting `quiet` to true 169 * 170 * @param bool $quiet False if unknown settings throw exception, true to silently 171 * ignore unknown settings 172 * @throws Common\Exceptions\RuntimeException 173 */ 174 public static function fromConfig(array $config, bool $quiet = false): Client 175 { 176 $builder = new self; 177 foreach ($config as $key => $value) { 178 $method = "set$key"; 179 if (method_exists($builder, $method)) { 180 $builder->$method($value); 181 unset($config[$key]); 182 } 183 } 184 185 if ($quiet === false && count($config) > 0) { 186 $unknown = implode(array_keys($config)); 187 throw new RuntimeException("Unknown parameters provided: $unknown"); 188 } 189 return $builder->build(); 190 } 191 192 /** 193 * @throws \RuntimeException 194 */ 195 public static function defaultHandler(array $multiParams = [], array $singleParams = []): callable 196 { 197 $future = null; 198 if (extension_loaded('curl')) { 199 $config = array_merge([ 'mh' => curl_multi_init() ], $multiParams); 200 if (function_exists('curl_reset')) { 201 $default = new CurlHandler($singleParams); 202 $future = new CurlMultiHandler($config); 203 } else { 204 $default = new CurlMultiHandler($config); 205 } 206 } else { 207 throw new \RuntimeException('Elasticsearch-PHP requires cURL, or a custom HTTP handler.'); 208 } 209 210 return $future ? Middleware::wrapFuture($default, $future) : $default; 211 } 212 213 /** 214 * @throws \RuntimeException 215 */ 216 public static function multiHandler(array $params = []): CurlMultiHandler 217 { 218 if (function_exists('curl_multi_init')) { 219 return new CurlMultiHandler(array_merge([ 'mh' => curl_multi_init() ], $params)); 220 } else { 221 throw new \RuntimeException('CurlMulti handler requires cURL.'); 222 } 223 } 224 225 /** 226 * @throws \RuntimeException 227 */ 228 public static function singleHandler(): CurlHandler 229 { 230 if (function_exists('curl_reset')) { 231 return new CurlHandler(); 232 } else { 233 throw new \RuntimeException('CurlSingle handler requires cURL.'); 234 } 235 } 236 237 public function setConnectionFactory(ConnectionFactoryInterface $connectionFactory): ClientBuilder 238 { 239 $this->connectionFactory = $connectionFactory; 240 241 return $this; 242 } 243 244 /** 245 * @param AbstractConnectionPool|string $connectionPool 246 * @throws \InvalidArgumentException 247 */ 248 public function setConnectionPool($connectionPool, array $args = []): ClientBuilder 249 { 250 if (is_string($connectionPool)) { 251 $this->connectionPool = $connectionPool; 252 $this->connectionPoolArgs = $args; 253 } elseif (is_object($connectionPool)) { 254 $this->connectionPool = $connectionPool; 255 } else { 256 throw new InvalidArgumentException("Serializer must be a class path or instantiated object extending AbstractConnectionPool"); 257 } 258 259 return $this; 260 } 261 262 public function setEndpoint(callable $endpoint): ClientBuilder 263 { 264 $this->endpoint = $endpoint; 265 266 return $this; 267 } 268 269 public function registerNamespace(NamespaceBuilderInterface $namespaceBuilder): ClientBuilder 270 { 271 $this->registeredNamespacesBuilders[] = $namespaceBuilder; 272 273 return $this; 274 } 275 276 public function setTransport(Transport $transport): ClientBuilder 277 { 278 $this->transport = $transport; 279 280 return $this; 281 } 282 283 /** 284 * @param mixed $handler 285 * @return $this 286 */ 287 public function setHandler($handler): ClientBuilder 288 { 289 $this->handler = $handler; 290 291 return $this; 292 } 293 294 public function setLogger(LoggerInterface $logger): ClientBuilder 295 { 296 if (!$logger instanceof LoggerInterface) { 297 throw new InvalidArgumentException('$logger must implement \Psr\Log\LoggerInterface!'); 298 } 299 300 $this->logger = $logger; 301 302 return $this; 303 } 304 305 public function setTracer(LoggerInterface $tracer): ClientBuilder 306 { 307 if (!$tracer instanceof LoggerInterface) { 308 throw new InvalidArgumentException('$tracer must implement \Psr\Log\LoggerInterface!'); 309 } 310 311 $this->tracer = $tracer; 312 313 return $this; 314 } 315 316 /** 317 * @param \Elasticsearch\Serializers\SerializerInterface|string $serializer 318 */ 319 public function setSerializer($serializer): ClientBuilder 320 { 321 $this->parseStringOrObject($serializer, $this->serializer, 'SerializerInterface'); 322 323 return $this; 324 } 325 326 public function setHosts(array $hosts): ClientBuilder 327 { 328 $this->hosts = $hosts; 329 330 return $this; 331 } 332 333 public function setConnectionParams(array $params): ClientBuilder 334 { 335 $this->connectionParams = $params; 336 337 return $this; 338 } 339 340 public function setRetries(int $retries): ClientBuilder 341 { 342 $this->retries = $retries; 343 344 return $this; 345 } 346 347 /** 348 * @param \Elasticsearch\ConnectionPool\Selectors\SelectorInterface|string $selector 349 */ 350 public function setSelector($selector): ClientBuilder 351 { 352 $this->parseStringOrObject($selector, $this->selector, 'SelectorInterface'); 353 354 return $this; 355 } 356 357 public function setSniffOnStart(bool $sniffOnStart): ClientBuilder 358 { 359 $this->sniffOnStart = $sniffOnStart; 360 361 return $this; 362 } 363 364 /** 365 * @param string $cert The name of a file containing a PEM formatted certificate. 366 */ 367 public function setSSLCert(string $cert, string $password = null): ClientBuilder 368 { 369 $this->sslCert = [$cert, $password]; 370 371 return $this; 372 } 373 374 /** 375 * @param string $key The name of a file containing a private SSL key. 376 */ 377 public function setSSLKey(string $key, string $password = null): ClientBuilder 378 { 379 $this->sslKey = [$key, $password]; 380 381 return $this; 382 } 383 384 public function setSSLVerification(bool $value = true): ClientBuilder 385 { 386 $this->sslVerification = $value; 387 388 return $this; 389 } 390 391 public function build(): Client 392 { 393 $this->buildLoggers(); 394 395 if (is_null($this->handler)) { 396 $this->handler = ClientBuilder::defaultHandler(); 397 } 398 399 $sslOptions = null; 400 if (isset($this->sslKey)) { 401 $sslOptions['ssl_key'] = $this->sslKey; 402 } 403 if (isset($this->sslCert)) { 404 $sslOptions['cert'] = $this->sslCert; 405 } 406 if (isset($this->sslVerification)) { 407 $sslOptions['verify'] = $this->sslVerification; 408 } 409 410 if (!is_null($sslOptions)) { 411 $sslHandler = function (callable $handler, array $sslOptions) { 412 return function (array $request) use ($handler, $sslOptions) { 413 // Add our custom headers 414 foreach ($sslOptions as $key => $value) { 415 $request['client'][$key] = $value; 416 } 417 418 // Send the request using the handler and return the response. 419 return $handler($request); 420 }; 421 }; 422 $this->handler = $sslHandler($this->handler, $sslOptions); 423 } 424 425 if (is_null($this->serializer)) { 426 $this->serializer = new SmartSerializer(); 427 } elseif (is_string($this->serializer)) { 428 $this->serializer = new $this->serializer; 429 } 430 431 if (is_null($this->connectionFactory)) { 432 if (is_null($this->connectionParams)) { 433 $this->connectionParams = []; 434 } 435 436 // Make sure we are setting Content-Type and Accept (unless the user has explicitly 437 // overridden it 438 if (! isset($this->connectionParams['client']['headers'])) { 439 $this->connectionParams['client']['headers'] = []; 440 } 441 if (! isset($this->connectionParams['client']['headers']['Content-Type'])) { 442 $this->connectionParams['client']['headers']['Content-Type'] = ['application/json']; 443 } 444 if (! isset($this->connectionParams['client']['headers']['Accept'])) { 445 $this->connectionParams['client']['headers']['Accept'] = ['application/json']; 446 } 447 448 $this->connectionFactory = new ConnectionFactory($this->handler, $this->connectionParams, $this->serializer, $this->logger, $this->tracer); 449 } 450 451 if (is_null($this->hosts)) { 452 $this->hosts = $this->getDefaultHost(); 453 } 454 455 if (is_null($this->selector)) { 456 $this->selector = new RoundRobinSelector(); 457 } elseif (is_string($this->selector)) { 458 $this->selector = new $this->selector; 459 } 460 461 $this->buildTransport(); 462 463 if (is_null($this->endpoint)) { 464 $serializer = $this->serializer; 465 466 $this->endpoint = function ($class) use ($serializer) { 467 $fullPath = '\\Elasticsearch\\Endpoints\\' . $class; 468 if ($class === 'Bulk' || $class === 'Msearch' || $class === 'MsearchTemplate' || $class === 'MPercolate') { 469 return new $fullPath($serializer); 470 } else { 471 return new $fullPath(); 472 } 473 }; 474 } 475 476 $registeredNamespaces = []; 477 foreach ($this->registeredNamespacesBuilders as $builder) { 478 /** 479 * @var NamespaceBuilderInterface $builder 480*/ 481 $registeredNamespaces[$builder->getName()] = $builder->getObject($this->transport, $this->serializer); 482 } 483 484 return $this->instantiate($this->transport, $this->endpoint, $registeredNamespaces); 485 } 486 487 protected function instantiate(Transport $transport, callable $endpoint, array $registeredNamespaces): Client 488 { 489 return new Client($transport, $endpoint, $registeredNamespaces); 490 } 491 492 private function buildLoggers(): void 493 { 494 if (is_null($this->logger)) { 495 $this->logger = new NullLogger(); 496 } 497 498 if (is_null($this->tracer)) { 499 $this->tracer = new NullLogger(); 500 } 501 } 502 503 private function buildTransport(): void 504 { 505 $connections = $this->buildConnectionsFromHosts($this->hosts); 506 507 if (is_string($this->connectionPool)) { 508 $this->connectionPool = new $this->connectionPool( 509 $connections, 510 $this->selector, 511 $this->connectionFactory, 512 $this->connectionPoolArgs 513 ); 514 } elseif (is_null($this->connectionPool)) { 515 $this->connectionPool = new StaticNoPingConnectionPool( 516 $connections, 517 $this->selector, 518 $this->connectionFactory, 519 $this->connectionPoolArgs 520 ); 521 } 522 523 if (is_null($this->retries)) { 524 $this->retries = count($connections); 525 } 526 527 if (is_null($this->transport)) { 528 $this->transport = new Transport($this->retries, $this->connectionPool, $this->logger, $this->sniffOnStart); 529 } 530 } 531 532 private function parseStringOrObject($arg, &$destination, $interface): void 533 { 534 if (is_string($arg)) { 535 $destination = new $arg; 536 } elseif (is_object($arg)) { 537 $destination = $arg; 538 } else { 539 throw new InvalidArgumentException("Serializer must be a class path or instantiated object implementing $interface"); 540 } 541 } 542 543 private function getDefaultHost(): array 544 { 545 return ['localhost:9200']; 546 } 547 548 /** 549 * @return \Elasticsearch\Connections\Connection[] 550 * @throws RuntimeException 551 */ 552 private function buildConnectionsFromHosts(array $hosts): array 553 { 554 $connections = []; 555 foreach ($hosts as $host) { 556 if (is_string($host)) { 557 $host = $this->prependMissingScheme($host); 558 $host = $this->extractURIParts($host); 559 } elseif (is_array($host)) { 560 $host = $this->normalizeExtendedHost($host); 561 } else { 562 $this->logger->error("Could not parse host: ".print_r($host, true)); 563 throw new RuntimeException("Could not parse host: ".print_r($host, true)); 564 } 565 $connections[] = $this->connectionFactory->create($host); 566 } 567 568 return $connections; 569 } 570 571 /** 572 * @throws RuntimeException 573 */ 574 private function normalizeExtendedHost(array $host): array 575 { 576 if (isset($host['host']) === false) { 577 $this->logger->error("Required 'host' was not defined in extended format: ".print_r($host, true)); 578 throw new RuntimeException("Required 'host' was not defined in extended format: ".print_r($host, true)); 579 } 580 581 if (isset($host['scheme']) === false) { 582 $host['scheme'] = 'http'; 583 } 584 if (isset($host['port']) === false) { 585 $host['port'] = 9200; 586 } 587 return $host; 588 } 589 590 /** 591 * @throws InvalidArgumentException 592 */ 593 private function extractURIParts(string $host): array 594 { 595 $parts = parse_url($host); 596 597 if ($parts === false) { 598 throw new InvalidArgumentException("Could not parse URI"); 599 } 600 601 if (isset($parts['port']) !== true) { 602 $parts['port'] = 9200; 603 } 604 605 return $parts; 606 } 607 608 private function prependMissingScheme(string $host): string 609 { 610 if (!filter_var($host, FILTER_VALIDATE_URL)) { 611 $host = 'http://' . $host; 612 } 613 614 return $host; 615 } 616} 617