Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.66% covered (success)
89.66%
52 / 58
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
SearchController
89.66% covered (success)
89.66%
52 / 58
66.67% covered (warning)
66.67%
2 / 3
12.16
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
 search
88.68% covered (success)
88.68%
47 / 53
0.00% covered (danger)
0.00%
0 / 1
8.09
 popular
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3/**
4 * The Search Controller for the REST API
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-07-29
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Api;
21
22use Exception;
23use OpenApi\Attributes as OA;
24use phpMyFAQ\Category;
25use phpMyFAQ\Faq\Permission;
26use phpMyFAQ\Filter;
27use phpMyFAQ\Link\Util\TitleSlugifier;
28use phpMyFAQ\Search;
29use phpMyFAQ\Search\SearchResultSet;
30use phpMyFAQ\Utils;
31use Symfony\Component\HttpFoundation\JsonResponse;
32use Symfony\Component\HttpFoundation\Request;
33use Symfony\Component\HttpFoundation\Response;
34use Symfony\Component\Routing\Attribute\Route;
35
36final class SearchController extends AbstractApiController
37{
38    public function __construct(
39        private readonly Search $search,
40    ) {
41        parent::__construct();
42    }
43
44    /**
45     * @throws Exception
46     */
47    #[OA\Get(
48        path: '/api/v4.0/search',
49        operationId: 'getSearch',
50        description: 'Returns paginated search results.',
51        tags: ['Public Endpoints'],
52    )]
53    #[OA\Parameter(
54        name: 'q',
55        description: 'The search term',
56        in: 'query',
57        required: true,
58        schema: new OA\Schema(type: 'string'),
59    )]
60    #[OA\Parameter(
61        name: 'page',
62        description: 'Page number for pagination (page-based)',
63        in: 'query',
64        required: false,
65        schema: new OA\Schema(type: 'integer', default: 1),
66    )]
67    #[OA\Parameter(
68        name: 'per_page',
69        description: 'Items per page (page-based, max 100)',
70        in: 'query',
71        required: false,
72        schema: new OA\Schema(type: 'integer', default: 25),
73    )]
74    #[OA\Parameter(
75        name: 'limit',
76        description: 'Number of items to return (offset-based, max 100)',
77        in: 'query',
78        required: false,
79        schema: new OA\Schema(type: 'integer', default: 25),
80    )]
81    #[OA\Parameter(
82        name: 'offset',
83        description: 'Starting offset (offset-based)',
84        in: 'query',
85        required: false,
86        schema: new OA\Schema(type: 'integer', default: 0),
87    )]
88    #[OA\Parameter(
89        name: 'sort',
90        description: 'Field to sort by',
91        in: 'query',
92        required: false,
93        schema: new OA\Schema(type: 'string', default: 'id', enum: ['id', 'question', 'category_id']),
94    )]
95    #[OA\Parameter(
96        name: 'order',
97        description: 'Sort direction',
98        in: 'query',
99        required: false,
100        schema: new OA\Schema(type: 'string', default: 'asc', enum: ['asc', 'desc']),
101    )]
102    #[OA\Response(
103        response: 200,
104        description: 'Returns paginated search results.',
105        content: new OA\JsonContent(example: [
106            'success' => true,
107            'data' => [[
108                'id' => '1',
109                'lang' => 'en',
110                'category_id' => '15',
111                'question' => 'Why are you using phpMyFAQ?',
112                'answer' => 'Because it is cool!',
113                'link' => 'https://www.example.org/content/15/1/en/why-are-you-using-phpmyfaq.html',
114            ]],
115            'meta' => [
116                'pagination' => [
117                    'total' => 50,
118                    'count' => 25,
119                    'per_page' => 25,
120                    'current_page' => 1,
121                    'total_pages' => 2,
122                    'links' => [
123                        'first' => '/api/v4.0/search?q=test&page=1&per_page=25',
124                        'last' => '/api/v4.0/search?q=test&page=2&per_page=25',
125                        'prev' => null,
126                        'next' => '/api/v4.0/search?q=test&page=2&per_page=25',
127                    ],
128                ],
129                'sorting' => [
130                    'field' => 'id',
131                    'order' => 'asc',
132                ],
133            ],
134        ]),
135    )]
136    #[Route(path: 'v4.0/search', name: 'api.search', methods: ['GET'])]
137    public function search(Request $request): JsonResponse
138    {
139        $this->search->setCategory(new Category($this->configuration));
140
141        $faqPermission = new Permission($this->configuration);
142        $searchResultSet = new SearchResultSet($this->currentUser, $faqPermission, $this->configuration);
143
144        $searchString = Filter::filterVar($request->query->get(key: 'q'), FILTER_SANITIZE_SPECIAL_CHARS, '');
145        $searchResults = $this->search->search(searchTerm: $searchString, allLanguages: false);
146        $searchResultSet->reviewResultSet($searchResults);
147
148        // Get pagination and sorting parameters
149        $pagination = $this->getPaginationRequest($request);
150        $sort = $this->getSortRequest(
151            $request,
152            allowedFields: ['id', 'question', 'category_id'],
153            defaultField: 'id',
154            defaultOrder: 'asc',
155        );
156
157        if ($searchResultSet->getNumberOfResults() > 0) {
158            $allResults = [];
159            foreach ($searchResultSet->getResultSet() as $data) {
160                $data->answer = strip_tags((string) $data->answer);
161                $data->answer = Utils::makeShorterText(string: $data->answer, characters: 12);
162                $data->link = sprintf(
163                    '%sfaq/%d/%d/%s/%s.html',
164                    $this->configuration->getDefaultUrl(),
165                    (int) $data->category_id,
166                    (int) $data->id,
167                    (string) $data->lang,
168                    TitleSlugifier::slug((string) $data->question),
169                );
170                $allResults[] = $data;
171            }
172
173            $total = count($allResults);
174
175            // Apply sorting if needed
176            $sortField = $sort->getField();
177            if ($sortField !== null && $sortField !== '') {
178                usort($allResults, static function (object $a, object $b) use ($sort, $sortField): int {
179                    $aVal = $a->{$sortField} ?? '';
180                    $bVal = $b->{$sortField} ?? '';
181                    $result = is_numeric($aVal) && is_numeric($bVal)
182                        ? (float) $aVal <=> (float) $bVal
183                        : (string) $aVal <=> (string) $bVal;
184                    return $sort->getOrderSql() === 'DESC' ? -$result : $result;
185                });
186            }
187
188            // Apply pagination
189            $result = array_slice($allResults, $pagination->offset, $pagination->limit);
190
191            return $this->paginatedResponse(
192                $request,
193                data: array_values($result),
194                total: $total,
195                pagination: $pagination,
196                options: new PaginatedResponseOptions(sort: $sort),
197            );
198        }
199
200        return $this->paginatedResponse(
201            $request,
202            data: [],
203            total: 0,
204            pagination: $pagination,
205            options: new PaginatedResponseOptions(sort: $sort),
206        );
207    }
208
209    /**
210     * @throws Exception
211     */
212    #[OA\Get(path: '/api/v4.0/searches/popular', operationId: 'getPopularSearch', tags: ['Public Endpoints'])]
213    #[OA\Header(
214        header: 'Accept-Language',
215        description: 'The language code for the login.',
216        schema: new OA\Schema(type: 'string'),
217    )]
218    #[OA\Response(
219        response: 200,
220        description: 'Returns the popular search terms for the given language provided by "Accept-Language"',
221        content: new OA\JsonContent(example: [
222            [
223                'id' => 3,
224                'searchterm' => 'mac',
225                'number' => '18',
226                'lang' => 'en',
227            ],
228            [
229                'id' => 7,
230                'searchterm' => 'test',
231                'number' => 9,
232                'lang' => 'en',
233            ],
234        ]),
235    )]
236    #[OA\Response(
237        response: 404,
238        description: 'If the popular search returns no results.',
239        content: new OA\JsonContent(example: []),
240    )]
241    #[Route(path: 'v4.0/searches/popular', name: 'api.search.popular', methods: ['GET'])]
242    public function popular(): JsonResponse
243    {
244        $result = $this->search->getMostPopularSearches(numResults: 7, withLang: true);
245
246        if ((is_countable($result) ? count($result) : 0) === 0) {
247            return $this->json([], Response::HTTP_NOT_FOUND);
248        }
249
250        return $this->json($result, Response::HTTP_OK);
251    }
252}