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