Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.24% covered (success)
90.24%
37 / 41
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
SearchClientFactory
90.24% covered (success)
90.24%
37 / 41
33.33% covered (danger)
33.33%
1 / 3
13.16
0.00% covered (danger)
0.00%
0 / 1
 waitForHealthy
88.24% covered (success)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
8.10
 configureElasticsearch
85.71% covered (success)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
4.05
 configureOpenSearch
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Search engine client creation and health-check logic for phpMyFAQ bootstrap
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2012-2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-02-08
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Bootstrap;
21
22use Elastic\Elasticsearch\Client;
23use Elastic\Elasticsearch\ClientBuilder;
24use Elastic\Elasticsearch\Exception\AuthenticationException;
25use OpenSearch\SymfonyClientFactory;
26use phpMyFAQ\Configuration;
27use phpMyFAQ\Configuration\ElasticsearchConfiguration;
28use phpMyFAQ\Configuration\OpenSearchConfiguration;
29use Symfony\Component\HttpClient\HttpClient;
30use Symfony\Contracts\HttpClient\HttpClientInterface;
31use Throwable;
32
33class SearchClientFactory
34{
35    /**
36     * Polls the search engine health endpoint until it responds with a 2xx–4xx status.
37     */
38    public static function waitForHealthy(
39        string $baseUri,
40        int $timeoutSeconds = 15,
41        ?HttpClientInterface $httpClient = null,
42        ?callable $httpClientFactory = null,
43    ): void {
44        try {
45            $http = $httpClient;
46            if ($http === null && $httpClientFactory !== null) {
47                $createdHttpClient = $httpClientFactory();
48                if ($createdHttpClient instanceof HttpClientInterface) {
49                    $http = $createdHttpClient;
50                }
51            }
52
53            $http ??= HttpClient::create(['verify_peer' => false]);
54            $deadline = time() + $timeoutSeconds;
55            do {
56                try {
57                    $res = $http->request('GET', rtrim($baseUri, characters: '/') . '/_cluster/health');
58                    $code = $res->getStatusCode();
59                    if ($code >= 200 && $code < 500) {
60                        break;
61                    }
62                } catch (Throwable $exception) {
63                    unset($exception);
64                }
65                usleep(500_000);
66            } while (time() < $deadline);
67        } catch (Throwable $exception) {
68            unset($exception);
69        }
70    }
71
72    /**
73     * Configures the Elasticsearch client and attaches it to the Configuration.
74     */
75    public static function configureElasticsearch(
76        Configuration $faqConfig,
77        string $configDir,
78        ?HttpClientInterface $httpClient = null,
79        ?callable $clientFactory = null,
80    ): void {
81        require $configDir . '/constants_elasticsearch.php';
82        $esConfig = new ElasticsearchConfiguration($configDir . '/elasticsearch.php');
83
84        $esBaseUri = $_ENV['ELASTICSEARCH_BASE_URI'] ?? $esConfig->getHosts()[0];
85
86        self::waitForHealthy($esBaseUri, (int) ($_ENV['SEARCH_WAIT_TIMEOUT'] ?? 15), $httpClient);
87
88        try {
89            $esClient = null;
90            if ($clientFactory !== null) {
91                $createdClient = $clientFactory($esBaseUri);
92                if ($createdClient instanceof Client) {
93                    $esClient = $createdClient;
94                }
95            }
96
97            $esClient ??= ClientBuilder::create()->setHosts([$esBaseUri])->build();
98            $faqConfig->setElasticsearch($esClient);
99            $faqConfig->setElasticsearchConfig($esConfig);
100        } catch (AuthenticationException $exception) {
101            unset($exception);
102        }
103    }
104
105    /**
106     * Configures the OpenSearch client and attaches it to the Configuration.
107     */
108    public static function configureOpenSearch(
109        Configuration $faqConfig,
110        string $configDir,
111        ?HttpClientInterface $httpClient = null,
112    ): void {
113        require $configDir . '/constants_opensearch.php';
114        $openSearchConfig = new OpenSearchConfiguration($configDir . '/opensearch.php');
115
116        $baseUri = $_ENV['OPENSEARCH_BASE_URI'] ?? $openSearchConfig->getHosts()[0];
117
118        self::waitForHealthy($baseUri, (int) ($_ENV['SEARCH_WAIT_TIMEOUT'] ?? 15), $httpClient);
119
120        $client = new SymfonyClientFactory()->create([
121            'base_uri' => $baseUri,
122            'verify_peer' => false,
123        ]);
124        $faqConfig->setOpenSearch($client);
125        $faqConfig->setOpenSearchConfig($openSearchConfig);
126    }
127}