Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
8 / 8
CRAP
100.00% covered (success)
100.00%
1 / 1
AbstractApiController
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
8 / 8
11
100.00% covered (success)
100.00%
1 / 1
 initializeFromContainer
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getPaginationRequest
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getSortRequest
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFilterRequest
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 paginatedResponse
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
3
 apiResponse
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 errorResponse
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 createResponseEtag
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Abstract API 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 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     2026-01-11
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Api;
21
22use Exception;
23use Override;
24use phpMyFAQ\Api\Filtering\FilterRequest;
25use phpMyFAQ\Api\Pagination\PaginationMetadata;
26use phpMyFAQ\Api\Pagination\PaginationRequest;
27use phpMyFAQ\Api\Response\ApiResponse;
28use phpMyFAQ\Api\Sorting\SortRequest;
29use phpMyFAQ\Controller\AbstractController;
30use Symfony\Component\HttpFoundation\JsonResponse;
31use Symfony\Component\HttpFoundation\Request;
32use Symfony\Component\HttpFoundation\Response;
33use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
34
35/**
36 * Class AbstractApiController
37 *
38 * Base controller for all API endpoints providing standardized pagination,
39 * sorting, filtering, and response formatting.
40 */
41abstract class AbstractApiController extends AbstractController
42{
43    protected const int DEFAULT_PER_PAGE = 25;
44    protected const int MAX_PER_PAGE = 100;
45
46    /**
47     * Initializes API controller and verifies API access is enabled.
48     *
49     * @throws Exception
50     */
51    #[Override]
52    protected function initializeFromContainer(): void
53    {
54        parent::initializeFromContainer();
55
56        if (!$this->isApiEnabled()) {
57            throw new UnauthorizedHttpException(challenge: 'API is not enabled');
58        }
59    }
60
61    /**
62     * Parses pagination parameters from the request
63     * Supports both page-based (page + per_page) and offset-based (limit + offset) pagination.
64     *
65     * @param Request  $request
66     * @param int      $defaultPerPage Default items per page
67     * @param int|null $maxPerPage Maximum items per page (uses class constant if null)
68     * @return PaginationRequest
69     */
70    protected function getPaginationRequest(
71        Request $request,
72        int $defaultPerPage = self::DEFAULT_PER_PAGE,
73        ?int $maxPerPage = null,
74    ): PaginationRequest {
75        $maxPerPage ??= self::MAX_PER_PAGE;
76
77        return PaginationRequest::fromRequest($request, $defaultPerPage, $maxPerPage);
78    }
79
80    /**
81     * Parses sorting parameters from the request
82     * Validates sort field against whitelist to prevent SQL injection.
83     *
84     * @param Request     $request
85     * @param array       $allowedFields Whitelist of allowed sort fields
86     * @param string|null $defaultField Default sort field if none specified
87     * @param string      $defaultOrder Default sort order (asc or desc)
88     * @return SortRequest
89     */
90    protected function getSortRequest(
91        Request $request,
92        array $allowedFields,
93        ?string $defaultField = null,
94        string $defaultOrder = 'asc',
95    ): SortRequest {
96        return SortRequest::fromRequest($request, $allowedFields, $defaultField, $defaultOrder);
97    }
98
99    /**
100     * Parses filter parameters from the request
101     *
102     * Validates filters against allowed filters configuration.
103     *
104     * @param array $allowedFilters Configuration of allowed filters with their types
105     * @return FilterRequest
106     *
107     * Example $allowedFilters:
108     * [
109     *     'active' => 'bool',
110     *     'language' => 'string',
111     *     'category_id' => 'int',
112     *     'created_from' => 'date',
113     * ]
114     */
115    protected function getFilterRequest(Request $request, array $allowedFilters): FilterRequest
116    {
117        return FilterRequest::fromRequest($request, $allowedFilters);
118    }
119
120    /**
121     * Creates a paginated API response with standardized envelope format
122     *
123     * @param Request                       $request
124     * @param array                         $data The response data
125     * @param int                           $total Total number of items across all pages
126     * @param PaginationRequest             $pagination Pagination parameters
127     * @param PaginatedResponseOptions|null $options
128     * @return JsonResponse
129     */
130    protected function paginatedResponse(
131        Request $request,
132        array $data,
133        int $total,
134        PaginationRequest $pagination,
135        ?PaginatedResponseOptions $options = null,
136    ): JsonResponse {
137        $options ??= new PaginatedResponseOptions();
138
139        // Build base URL for pagination links
140        $baseUrl = $request->getPathInfo();
141        $queryString = $request->getQueryString();
142        if ($queryString !== null && $queryString !== '') {
143            $baseUrl .= '?' . $queryString;
144        }
145
146        // Generate pagination metadata
147        $paginationMetadata = new PaginationMetadata(
148            total: $total,
149            request: $pagination,
150            baseUrl: $baseUrl,
151            actualCount: count($data),
152        );
153
154        // Build response with envelope
155        $responseData = ApiResponse::success(
156            data: $data,
157            pagination: $paginationMetadata,
158            sort: $options->sort,
159            filters: $options->filters,
160        );
161
162        $response = new JsonResponse($responseData, $options->status);
163        $response->setPublic();
164        $response->setMaxAge(0);
165        $response->headers->addCacheControlDirective('must-revalidate');
166        $response->setVary(['Accept-Language'], false);
167        $response->setEtag($this->createResponseEtag($responseData));
168        $response->isNotModified($request);
169
170        return $response;
171    }
172
173    /**
174     * Creates a simple API response with standardized envelope format (no pagination)
175     *
176     * Use this for non-paginated endpoints or single-item responses.
177     *
178     * @param array|object $data The response data
179     * @param int $status HTTP status code
180     * @return JsonResponse
181     */
182    protected function apiResponse(array|object $data, int $status = Response::HTTP_OK): JsonResponse
183    {
184        $responseData = ApiResponse::success(data: $data);
185
186        return new JsonResponse($responseData, $status);
187    }
188
189    /**
190     * Creates an error response with standardized format
191     *
192     * @param string $message Error message
193     * @param string $code Error code (e.g., 'INVALID_PARAMETER', 'NOT_FOUND')
194     * @param int $status HTTP status code
195     * @param array|null $details Optional error details
196     * @return JsonResponse
197     */
198    protected function errorResponse(
199        string $message,
200        string $code = 'ERROR',
201        int $status = Response::HTTP_BAD_REQUEST,
202        ?array $details = null,
203    ): JsonResponse {
204        $responseData = ApiResponse::error(message: $message, code: $code, details: $details);
205
206        return new JsonResponse($responseData, $status);
207    }
208
209    /**
210     * Creates a stable ETag for a JSON API response payload.
211     *
212     * @param array $responseData
213     * @return string
214     */
215    private function createResponseEtag(array $responseData): string
216    {
217        return hash('sha256', json_encode($responseData, JSON_THROW_ON_ERROR));
218    }
219}