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