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