Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.46% covered (success)
83.46%
106 / 127
66.67% covered (warning)
66.67%
6 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
CategoryController
83.46% covered (success)
83.46%
106 / 127
66.67% covered (warning)
66.67%
6 / 9
32.80
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
 setCategoryFactory
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCategoryPermissionFactory
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setOrderFactory
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 list
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
1
 create
75.64% covered (warning)
75.64%
59 / 78
0.00% covered (danger)
0.00%
0 / 1
18.25
 createCategory
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 createCategoryPermission
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 createOrder
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
1<?php
2
3/**
4 * The Category 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 JsonException;
23use OpenApi\Attributes as OA;
24use phpMyFAQ\Category;
25use phpMyFAQ\Category\Order;
26use phpMyFAQ\Category\Permission as CategoryPermission;
27use phpMyFAQ\Entity\CategoryEntity;
28use phpMyFAQ\Enums\PermissionType;
29use phpMyFAQ\Filter;
30use phpMyFAQ\Language;
31use phpMyFAQ\User\CurrentUser;
32use Symfony\Component\HttpFoundation\JsonResponse;
33use Symfony\Component\HttpFoundation\Request;
34use Symfony\Component\HttpFoundation\Response;
35use Symfony\Component\Routing\Attribute\Route;
36
37final class CategoryController extends AbstractApiController
38{
39    /** @var null|callable */
40    private $categoryFactory = null;
41
42    /** @var null|callable */
43    private $categoryPermissionFactory = null;
44
45    /** @var null|callable */
46    private $orderFactory = null;
47
48    public function __construct(
49        private readonly Language $language,
50    ) {
51        parent::__construct();
52    }
53
54    public function setCategoryFactory(callable $categoryFactory): void
55    {
56        $this->categoryFactory = $categoryFactory;
57    }
58
59    public function setCategoryPermissionFactory(callable $categoryPermissionFactory): void
60    {
61        $this->categoryPermissionFactory = $categoryPermissionFactory;
62    }
63
64    public function setOrderFactory(callable $orderFactory): void
65    {
66        $this->orderFactory = $orderFactory;
67    }
68
69    /**
70     * @throws \Exception
71     */
72    #[OA\Get(path: '/api/v4.0/categories', operationId: 'getCategories', tags: ['Public Endpoints'])]
73    #[OA\Header(
74        header: 'Accept-Language',
75        description: 'The language code for the categories.',
76        schema: new OA\Schema(type: 'string'),
77    )]
78    #[OA\Parameter(
79        name: 'page',
80        description: 'Page number for pagination (page-based)',
81        in: 'query',
82        required: false,
83        schema: new OA\Schema(type: 'integer', default: 1),
84    )]
85    #[OA\Parameter(
86        name: 'per_page',
87        description: 'Items per page (page-based, max 100)',
88        in: 'query',
89        required: false,
90        schema: new OA\Schema(type: 'integer', default: 25),
91    )]
92    #[OA\Parameter(
93        name: 'limit',
94        description: 'Number of items to return (offset-based, max 100)',
95        in: 'query',
96        required: false,
97        schema: new OA\Schema(type: 'integer', default: 25),
98    )]
99    #[OA\Parameter(
100        name: 'offset',
101        description: 'Starting offset (offset-based)',
102        in: 'query',
103        required: false,
104        schema: new OA\Schema(type: 'integer', default: 0),
105    )]
106    #[OA\Parameter(name: 'sort', description: 'Field to sort by', in: 'query', required: false, schema: new OA\Schema(
107        type: 'string',
108        default: 'id',
109        enum: ['id', 'name', 'parent_id', 'active'],
110    ))]
111    #[OA\Parameter(
112        name: 'order',
113        description: 'Sort direction',
114        in: 'query',
115        required: false,
116        schema: new OA\Schema(type: 'string', default: 'asc', enum: ['asc', 'desc']),
117    )]
118    #[OA\Response(
119        response: 200,
120        description: 'Returns paginated categories for the given language provided by "Accept-Language".',
121        content: new OA\JsonContent(example: [
122            'success' => true,
123            'data' => [[
124                'id' => 1,
125                'lang' => 'en',
126                'parent_id' => 0,
127                'name' => 'Test',
128                'description' => 'Hello, World! Hello, Tests!',
129                'user_id' => 1,
130                'group_id' => 1,
131                'active' => 1,
132                'show_home' => 1,
133                'image' => 'category-1-en.png',
134                'level' => 1,
135            ]],
136            'meta' => [
137                'pagination' => [
138                    'total' => 50,
139                    'count' => 25,
140                    'per_page' => 25,
141                    'current_page' => 1,
142                    'total_pages' => 2,
143                    'links' => [
144                        'first' => '/api/v4.0/categories?page=1&per_page=25',
145                        'last' => '/api/v4.0/categories?page=2&per_page=25',
146                        'prev' => null,
147                        'next' => '/api/v4.0/categories?page=2&per_page=25',
148                    ],
149                ],
150                'sorting' => [
151                    'field' => 'id',
152                    'order' => 'asc',
153                ],
154            ],
155        ]),
156    )]
157    #[Route(path: 'v4.0/categories', name: 'api.categories.list', methods: ['GET'])]
158    public function list(?Request $request = null): JsonResponse
159    {
160        $request ??= Request::createFromGlobals();
161        $currentLanguage = $this->language->setLanguageByAcceptLanguage();
162
163        [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
164
165        $category = $this->createCategory($currentGroups);
166        $category->setUser($currentUser);
167        $category->setGroups($currentGroups);
168        $category->setLanguage($currentLanguage);
169
170        $onlyActive = (bool) $this->configuration->get('api.onlyActiveCategories');
171
172        // Get pagination and sorting parameters
173        $pagination = $this->getPaginationRequest($request);
174        $sort = $this->getSortRequest(
175            $request,
176            allowedFields: ['id', 'name', 'parent_id', 'active'],
177            defaultField: 'id',
178            defaultOrder: 'asc',
179        );
180
181        // Get paginated categories
182        $categories = $category->getCategoriesPaginated(
183            limit: $pagination->limit,
184            offset: $pagination->offset,
185            sortField: $sort->getField() ?? 'id',
186            sortOrder: $sort->getOrderSql(),
187            activeOnly: $onlyActive,
188        );
189
190        // Get total count
191        $total = $category->countCategories(activeOnly: $onlyActive);
192
193        return $this->paginatedResponse(
194            $request,
195            data: array_values($categories),
196            total: $total,
197            pagination: $pagination,
198            options: new PaginatedResponseOptions(sort: $sort),
199        );
200    }
201
202    /**
203     * @throws JsonException
204     * @throws \Exception
205     */
206    #[OA\Post(path: '/api/v4.0/category', operationId: 'createCategory', tags: ['Endpoints with Authentication'])]
207    #[OA\Header(
208        header: 'Accept-Language',
209        description: 'The language code for the login.',
210        schema: new OA\Schema(type: 'string'),
211    )]
212    #[OA\Header(
213        header: 'x-pmf-token',
214        description: 'phpMyFAQ client API Token, generated in admin backend',
215        schema: new OA\Schema(type: 'string'),
216    )]
217    #[OA\RequestBody(
218        description: 'The parent category ID is a required value, the parent category name is optional. '
219        . 'If the parent category name is present and the ID can be mapped, the parent category ID from the name '
220        . 'will be used. If the parent category name cannot be mapped, a 409 error is thrown',
221        required: true,
222        content: new OA\MediaType(
223            mediaType: 'application/json',
224            schema: new OA\Schema(
225                required: [
226                    'language',
227                    'parent-id',
228                    'parent-category-name',
229                    'category-name',
230                    'description',
231                    'user-id',
232                    'group-id',
233                    'is-active',
234                    'show-on-homepage',
235                ],
236                properties: [
237                    new OA\Property(property: 'language', type: 'string'),
238                    new OA\Property(property: 'parent-id', type: 'integer'),
239                    new OA\Property(property: 'parent-category-name', type: 'string'),
240                    new OA\Property(property: 'category-name', type: 'string'),
241                    new OA\Property(property: 'description', type: 'string'),
242                    new OA\Property(property: 'user-id', type: 'integer'),
243                    new OA\Property(property: 'group-id', type: 'integer'),
244                    new OA\Property(property: 'is-active', type: 'boolean'),
245                    new OA\Property(property: 'show-on-homepage', type: 'boolean'),
246                ],
247                type: 'object',
248            ),
249            example: '{
250                "language": "en",
251                "parent-id": 1,
252                "parent-category-name": "Test",
253                "category-name": "Test 2",
254                "description": "Hello, World! Hello, Tests!",
255                "user-id": 1,
256                "group-id": 1,
257                "is-active": true,
258                "show-on-homepage": true
259            }',
260        ),
261    )]
262    #[OA\Response(response: 201, description: 'If all posted data is correct.', content: new OA\JsonContent(example: [
263        'stored' => true,
264    ]))]
265    #[OA\Response(response: 400, description: "If something didn't worked out.", content: new OA\JsonContent(example: [
266        'stored' => false,
267        'error' => 'Cannot add category',
268    ]))]
269    #[OA\Response(
270        response: 409,
271        description: 'If the parent category name cannot be mapped.',
272        content: new OA\JsonContent(example: [
273            'stored' => false,
274            'error' => 'The given parent category name was not found.',
275        ]),
276    )]
277    #[OA\Response(response: 401, description: 'If the user is not authenticated.')]
278    #[Route(path: 'v4.0/category', name: 'api.category.create', methods: ['POST'])]
279    public function create(Request $request): JsonResponse
280    {
281        $this->hasValidToken();
282        $this->userHasPermission(PermissionType::CATEGORY_ADD);
283
284        [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
285
286        $data = json_decode(json: $request->getContent(), associative: false, depth: 512, flags: JSON_THROW_ON_ERROR);
287        if (!$data instanceof \stdClass) {
288            return $this->json([
289                'stored' => false,
290                'error' => 'The request body must be a JSON object.',
291            ], Response::HTTP_BAD_REQUEST);
292        }
293
294        $currentLanguage = $this->configuration->getLanguage()->getLanguage();
295
296        $category = $this->createCategory($currentGroups);
297        $category->setUser($currentUser);
298        $category->setGroups($currentGroups);
299        $category->setLanguage($currentLanguage);
300
301        $categoryPermission = $this->createCategoryPermission();
302
303        $languageCode = Filter::filterVar($data->language ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
304        $parentId = Filter::filterVar($data->{'parent-id'} ?? null, FILTER_VALIDATE_INT);
305        $parentCategoryName = null;
306
307        if (property_exists($data, 'parent-category-name') && $data->{'parent-category-name'} !== null) {
308            $parentCategoryName = Filter::filterVar($data->{'parent-category-name'}, FILTER_SANITIZE_SPECIAL_CHARS);
309        }
310
311        $name = Filter::filterVar($data->{'category-name'} ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
312        $description = Filter::filterVar($data->{'description'} ?? null, FILTER_SANITIZE_SPECIAL_CHARS);
313        $userId = property_exists($data, 'user-id') && $data->{'user-id'} !== null
314            ? Filter::filterVar($data->{'user-id'}, FILTER_VALIDATE_INT)
315            : 1;
316        $groupId = property_exists($data, 'group-id') && $data->{'group-id'} !== null
317            ? Filter::filterVar($data->{'group-id'}, FILTER_VALIDATE_INT)
318            : -1;
319        $active = Filter::filterVar($data->{'is-active'} ?? false, FILTER_VALIDATE_BOOLEAN);
320        $showOnHome = Filter::filterVar($data->{'show-on-homepage'} ?? false, FILTER_VALIDATE_BOOLEAN);
321
322        // Check if the parent category name can be mapped
323        if (!is_null($parentCategoryName)) {
324            $parentCategoryIdFound = $category->getCategoryIdFromName($parentCategoryName);
325            if ($parentCategoryIdFound === false) {
326                $result = [
327                    'stored' => false,
328                    'error' => 'The given parent category name was not found.',
329                ];
330                return $this->json($result, Response::HTTP_CONFLICT);
331            }
332
333            $parentId = $parentCategoryIdFound;
334        }
335
336        if ($parentId === null || $userId === null || $groupId === null) {
337            $result = [
338                'stored' => false,
339                'error' => 'Cannot add category',
340            ];
341            return $this->json($result, Response::HTTP_BAD_REQUEST);
342        }
343
344        $parentId = (int) $parentId;
345
346        $categoryEntity = new CategoryEntity();
347        $categoryEntity
348            ->setLang($languageCode)
349            ->setParentId($parentId)
350            ->setName($name)
351            ->setDescription($description)
352            ->setUserId((int) $userId)
353            ->setGroupId((int) $groupId)
354            ->setActive((bool) $active)
355            ->setImage(image: '')
356            ->setShowHome((bool) $showOnHome);
357
358        $categoryId = $category->create($categoryEntity);
359
360        if ($categoryId === null) {
361            $result = [
362                'stored' => false,
363                'error' => 'Cannot add category',
364            ];
365            return $this->json($result, Response::HTTP_BAD_REQUEST);
366        }
367
368        // Category Order entry
369        $categoryOrder = $this->createOrder();
370        $categoryOrder->add($categoryId, $parentId);
371
372        if ($categoryId) {
373            $categoryPermission->add(CategoryPermission::USER, [$categoryId], [-1]);
374            $categoryPermission->add(CategoryPermission::GROUP, [$categoryId], [-1]);
375
376            $result = [
377                'stored' => true,
378            ];
379            return $this->json($result, Response::HTTP_CREATED);
380        }
381
382        $result = [
383            'stored' => false,
384            'error' => 'Cannot add category',
385        ];
386        return $this->json($result, Response::HTTP_BAD_REQUEST);
387    }
388
389    private function createCategory(array $currentGroups): Category
390    {
391        if (is_callable($this->categoryFactory)) {
392            $category = ($this->categoryFactory)($currentGroups);
393            if ($category instanceof Category) {
394                return $category;
395            }
396        }
397
398        return new Category($this->configuration, $currentGroups, withPermission: true);
399    }
400
401    private function createCategoryPermission(): CategoryPermission
402    {
403        if (is_callable($this->categoryPermissionFactory)) {
404            $categoryPermission = ($this->categoryPermissionFactory)();
405            if ($categoryPermission instanceof CategoryPermission) {
406                return $categoryPermission;
407            }
408        }
409
410        return new CategoryPermission($this->configuration);
411    }
412
413    private function createOrder(): Order
414    {
415        if (is_callable($this->orderFactory)) {
416            $categoryOrder = ($this->orderFactory)();
417            if ($categoryOrder instanceof Order) {
418                return $categoryOrder;
419            }
420        }
421
422        return new Order($this->configuration);
423    }
424}