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