Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.08% covered (warning)
77.08%
37 / 48
83.33% covered (success)
83.33%
5 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
ElasticsearchController
77.08% covered (warning)
77.08%
37 / 48
83.33% covered (success)
83.33%
5 / 6
16.36
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 create
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 drop
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 import
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 statistics
8.33% covered (danger)
8.33%
1 / 12
0.00% covered (danger)
0.00%
0 / 1
9.93
 healthcheck
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3/**
4 * The Admin Elasticsearch Controller
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 2023-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     2023-10-26
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Administration\Api;
21
22use Elastic\Elasticsearch\Exception\ClientResponseException;
23use Elastic\Elasticsearch\Exception\ServerResponseException;
24use Elastic\Elasticsearch\Response\Elasticsearch as ElasticsearchResponse;
25use phpMyFAQ\Controller\AbstractController;
26use phpMyFAQ\Core\Exception;
27use phpMyFAQ\CustomPage;
28use phpMyFAQ\Enums\PermissionType;
29use phpMyFAQ\Faq;
30use phpMyFAQ\Instance\Search\Elasticsearch;
31use phpMyFAQ\Translation;
32use Symfony\Component\HttpFoundation\JsonResponse;
33use Symfony\Component\HttpFoundation\Response;
34use Symfony\Component\Routing\Attribute\Route;
35
36final class ElasticsearchController extends AbstractController
37{
38    public function __construct(
39        private readonly Elasticsearch $elasticsearch,
40        private readonly Faq $faq,
41        private readonly CustomPage $customPage,
42    ) {
43        parent::__construct();
44    }
45
46    /**
47     * @throws \Exception
48     */
49    #[Route(path: 'elasticsearch/create', name: 'admin.api.elasticsearch.create', methods: ['POST'])]
50    public function create(): JsonResponse
51    {
52        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
53
54        try {
55            $this->elasticsearch->createIndex();
56            return $this->json([
57                'success' => Translation::get('msgAdminElasticsearchCreateIndex_success'),
58            ], Response::HTTP_OK);
59        } catch (Exception $exception) {
60            return $this->json(['error' => $exception->getMessage()], Response::HTTP_CONFLICT);
61        }
62    }
63
64    /**
65     * @throws \Exception
66     */
67    #[Route(path: 'elasticsearch/drop', name: 'admin.api.elasticsearch.drop', methods: ['DELETE'])]
68    public function drop(): JsonResponse
69    {
70        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
71
72        try {
73            $this->elasticsearch->dropIndex();
74            return $this->json([
75                'success' => Translation::get('msgAdminElasticsearchDropIndex_success'),
76            ], Response::HTTP_OK);
77        } catch (Exception $exception) {
78            return $this->json(['error' => $exception->getMessage()], Response::HTTP_CONFLICT);
79        }
80    }
81
82    /**
83     * @throws \Exception
84     */
85    #[Route(path: 'elasticsearch/import', name: 'admin.api.elasticsearch.import', methods: ['POST'])]
86    public function import(): JsonResponse
87    {
88        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
89
90        $this->faq->getAllFaqs();
91
92        // Index FAQs
93        $bulkIndexResult = $this->elasticsearch->bulkIndex($this->faq->faqRecords);
94        if (!array_key_exists('success', $bulkIndexResult)) {
95            return $this->json(['error' => $bulkIndexResult], Response::HTTP_BAD_REQUEST);
96        }
97
98        // Index custom pages
99        $pages = $this->customPage->getAllPages();
100
101        $bulkIndexPagesResult = $this->elasticsearch->bulkIndexCustomPages($pages);
102        if (!array_key_exists('success', $bulkIndexPagesResult)) {
103            return $this->json([
104                'error' => 'FAQs indexed but custom pages failed: ' . (string) json_encode($bulkIndexPagesResult),
105            ], Response::HTTP_BAD_REQUEST);
106        }
107
108        return $this->json(['success' => Translation::get(key: 'ad_es_create_import_success')], Response::HTTP_OK);
109    }
110
111    /**
112     * @throws Exception
113     */
114    #[Route(path: 'elasticsearch/statistics', name: 'admin.api.elasticsearch.statistics', methods: ['GET'])]
115    public function statistics(): JsonResponse
116    {
117        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
118
119        $elasticsearchConfiguration = $this->configuration->getElasticsearchConfig();
120
121        $indexName = $elasticsearchConfiguration->getIndex();
122        try {
123            $statsResponse = $this->configuration->getElasticsearch()->indices()->stats(['index' => $indexName]);
124            if (!$statsResponse instanceof ElasticsearchResponse) {
125                throw new \RuntimeException('Unexpected asynchronous Elasticsearch response.');
126            }
127
128            return $this->json([
129                'index' => $indexName,
130                'stats' => $statsResponse->asArray(),
131            ], Response::HTTP_OK);
132        } catch (ClientResponseException|ServerResponseException $e) {
133            return $this->json(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST);
134        }
135    }
136
137    #[Route(path: 'elasticsearch/healthcheck', name: 'admin.api.elasticsearch.healthcheck', methods: ['GET'])]
138    public function healthcheck(): JsonResponse
139    {
140        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
141
142        $isAvailable = $this->elasticsearch->isAvailable();
143
144        return $this->json(
145            [
146                'available' => $isAvailable,
147                'status' => $isAvailable ? 'healthy' : 'unavailable',
148            ],
149            $isAvailable ? Response::HTTP_OK : Response::HTTP_SERVICE_UNAVAILABLE,
150        );
151    }
152}