Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.44% covered (success)
97.44%
38 / 39
50.00% covered (danger)
50.00%
1 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
OpenQuestionController
97.44% covered (success)
97.44%
38 / 39
50.00% covered (danger)
50.00%
1 / 2
7
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
 list
97.37% covered (success)
97.37%
37 / 38
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3/**
4 * The Open Questions 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 OpenApi\Attributes as OA;
23use phpMyFAQ\Entity\QuestionEntity;
24use phpMyFAQ\Question;
25use Symfony\Component\HttpFoundation\JsonResponse;
26use Symfony\Component\HttpFoundation\Request;
27use Symfony\Component\Routing\Attribute\Route;
28
29final class OpenQuestionController extends AbstractApiController
30{
31    public function __construct(
32        private readonly Question $question,
33    ) {
34        parent::__construct();
35    }
36
37    /**
38     * @throws \Exception
39     */
40    #[OA\Get(
41        path: '/api/v4.0/open-questions',
42        operationId: 'getOpenQuestions',
43        description: 'Returns paginated open questions.',
44        tags: ['Public Endpoints'],
45    )]
46    #[OA\Header(
47        header: 'Accept-Language',
48        description: 'The language code for the open questions.',
49        schema: new OA\Schema(type: 'string'),
50    )]
51    #[OA\Parameter(
52        name: 'page',
53        description: 'Page number for pagination (page-based)',
54        in: 'query',
55        required: false,
56        schema: new OA\Schema(type: 'integer', default: 1),
57    )]
58    #[OA\Parameter(
59        name: 'per_page',
60        description: 'Items per page (page-based, max 100)',
61        in: 'query',
62        required: false,
63        schema: new OA\Schema(type: 'integer', default: 25),
64    )]
65    #[OA\Parameter(
66        name: 'limit',
67        description: 'Number of items to return (offset-based, max 100)',
68        in: 'query',
69        required: false,
70        schema: new OA\Schema(type: 'integer', default: 25),
71    )]
72    #[OA\Parameter(
73        name: 'offset',
74        description: 'Starting offset (offset-based)',
75        in: 'query',
76        required: false,
77        schema: new OA\Schema(type: 'integer', default: 0),
78    )]
79    #[OA\Parameter(name: 'sort', description: 'Field to sort by', in: 'query', required: false, schema: new OA\Schema(
80        type: 'string',
81        default: 'id',
82        enum: ['id', 'username', 'created', 'categoryId'],
83    ))]
84    #[OA\Parameter(
85        name: 'order',
86        description: 'Sort direction',
87        in: 'query',
88        required: false,
89        schema: new OA\Schema(type: 'string', default: 'asc', enum: ['asc', 'desc']),
90    )]
91    #[OA\Response(
92        response: 200,
93        description: 'Returns paginated open questions.',
94        content: new OA\JsonContent(example: [
95            'success' => true,
96            'data' => [[
97                'id' => 1,
98                'lang' => 'en',
99                'username' => 'phpMyFAQ User',
100                'email' => 'user@example.org',
101                'categoryId' => 3,
102                'question' => 'Foo? Bar? Baz?',
103                'created' => '20190106180429',
104                'answerId' => 0,
105                'isVisible' => 'N',
106            ]],
107            'meta' => [
108                'pagination' => [
109                    'total' => 50,
110                    'count' => 25,
111                    'per_page' => 25,
112                    'current_page' => 1,
113                    'total_pages' => 2,
114                    'links' => [
115                        'first' => '/api/v4.0/open-questions?page=1&per_page=25',
116                        'last' => '/api/v4.0/open-questions?page=2&per_page=25',
117                        'prev' => null,
118                        'next' => '/api/v4.0/open-questions?page=2&per_page=25',
119                    ],
120                ],
121                'sorting' => [
122                    'field' => 'id',
123                    'order' => 'asc',
124                ],
125            ],
126        ]),
127    )]
128    #[Route(path: 'v4.0/open-questions', name: 'api.open-questions.list', methods: ['GET'])]
129    public function list(?Request $request = null): JsonResponse
130    {
131        $request ??= Request::createFromGlobals();
132        $onlyPublic = (bool) $this->configuration->get('api.onlyPublicQuestions');
133
134        // Get pagination and sorting parameters
135        $pagination = $this->getPaginationRequest($request);
136        $sort = $this->getSortRequest(
137            $request,
138            allowedFields: ['id', 'username', 'created', 'categoryId'],
139            defaultField: 'id',
140            defaultOrder: 'asc',
141        );
142
143        // Get all open questions as serializable rows; entities would otherwise
144        // encode as empty JSON objects and cannot be sorted by array key.
145        $allQuestions = array_map(static fn(QuestionEntity $questionEntity): array => [
146            'id' => $questionEntity->getId(),
147            'username' => $questionEntity->getUsername(),
148            'created' => $questionEntity->getCreated(),
149            'categoryId' => $questionEntity->getCategoryId(),
150            'question' => $questionEntity->getQuestion(),
151            'language' => $questionEntity->getLanguage(),
152            'answerId' => $questionEntity->getAnswerId(),
153            'isVisible' => $questionEntity->isVisible(),
154        ], $this->question->getAll($onlyPublic));
155        $total = count($allQuestions);
156
157        // Apply sorting if needed
158        $sortField = $sort->getField();
159        if ($sortField !== null && $sortField !== '') {
160            usort($allQuestions, static function (array $a, array $b) use ($sort, $sortField): int {
161                $aVal = $a[$sortField] ?? '';
162                $bVal = $b[$sortField] ?? '';
163                $result = is_numeric($aVal) && is_numeric($bVal)
164                    ? (float) $aVal <=> (float) $bVal
165                    : (string) $aVal <=> (string) $bVal;
166                return $sort->getOrderSql() === 'DESC' ? -$result : $result;
167            });
168        }
169
170        // Apply pagination
171        $result = array_slice($allQuestions, $pagination->offset, $pagination->limit);
172
173        return $this->paginatedResponse(
174            $request,
175            data: array_values($result),
176            total: $total,
177            pagination: $pagination,
178            options: new PaginatedResponseOptions(sort: $sort),
179        );
180    }
181}