Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.45% covered (success)
85.45%
47 / 55
72.73% covered (warning)
72.73%
8 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
FilterRequest
85.45% covered (success)
85.45%
47 / 55
72.73% covered (warning)
72.73%
8 / 11
39.99
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 fromRequest
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 parseFilterValue
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
8
 parseBoolValue
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
9.36
 parseDateValue
87.50% covered (success)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 parseDateTimeValue
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
3.71
 has
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFilters
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasFilters
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toArray
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * Filter Request Parser
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\Api\Filtering;
21
22use DateTime;
23use Exception;
24use phpMyFAQ\Filter;
25use Symfony\Component\HttpFoundation\Request;
26
27/**
28 * Class FilterRequest
29 *
30 * Parses and validates filtering query parameters from HTTP requests.
31 * Supports multiple filter value types (string, int, bool, date).
32 */
33class FilterRequest
34{
35    private array $filters = [] {
36        get {
37            return $this->filters;
38        }
39    }
40
41    private array $allowedFilters;
42
43    /**
44     * Constructor
45     *
46     * @param array $filters Parsed and validated filters
47     * @param array $allowedFilters Configuration of allowed filters
48     */
49    private function __construct(array $filters, array $allowedFilters)
50    {
51        $this->filters = $filters;
52        $this->allowedFilters = $allowedFilters;
53    }
54
55    /**
56     * Creates a FilterRequest from a Symfony Request object
57     *
58     * @param Request $request The HTTP request
59     * @param array $allowedFilters Configuration of allowed filters with their types
60     * @return self
61     *
62     * Example $allowedFilters format:
63     * [
64     *     'active' => 'bool',
65     *     'language' => 'string',
66     *     'category_id' => 'int',
67     *     'created_from' => 'date',
68     *     'author' => 'string',
69     * ]
70     */
71    public static function fromRequest(Request $request, array $allowedFilters): self
72    {
73        $filters = [];
74        $queryParams = $request->query->all();
75
76        foreach ($allowedFilters as $filterName => $filterType) {
77            $value = null;
78
79            // Check filter array parameter first (e.g., ?filter[category_id]=5)
80            $filterParams = $queryParams['filter'] ?? null;
81            if (is_array($filterParams) && array_key_exists($filterName, $filterParams)) {
82                $value = self::parseFilterValue($filterParams[$filterName], (string) $filterType);
83            }
84
85            // Check direct parameter - this takes precedence (e.g., ?active=true)
86            if (array_key_exists($filterName, $queryParams)) {
87                $directValue = self::parseFilterValue($queryParams[$filterName], (string) $filterType);
88                if ($directValue !== null) {
89                    $value = $directValue;
90                }
91            }
92
93            if ($value !== null) {
94                $filters[$filterName] = $value;
95            }
96        }
97
98        return new self($filters, $allowedFilters);
99    }
100
101    /**
102     * Parses and validates a filter value based on its type
103     *
104     * @param mixed $value The raw value from query parameters
105     * @param string $type The expected type (bool, int, string, date)
106     * @return mixed|null The parsed value or null if invalid
107     */
108    private static function parseFilterValue(mixed $value, string $type): mixed
109    {
110        return match ($type) {
111            'bool', 'boolean' => self::parseBoolValue($value),
112            'int', 'integer' => Filter::filterVar($value, FILTER_VALIDATE_INT),
113            'float', 'double' => Filter::filterVar($value, FILTER_VALIDATE_FLOAT),
114            'email' => Filter::filterVar($value, FILTER_VALIDATE_EMAIL),
115            'date' => self::parseDateValue($value),
116            'datetime' => self::parseDateTimeValue($value),
117            default => Filter::filterVar($value, FILTER_SANITIZE_SPECIAL_CHARS),
118        };
119    }
120
121    /**
122     * Parses a boolean value from various string representations
123     *
124     * @param mixed $value The value to parse
125     * @return bool|null
126     */
127    private static function parseBoolValue(mixed $value): ?bool
128    {
129        if (is_bool($value)) {
130            return $value;
131        }
132
133        if (is_string($value)) {
134            $valueLower = strtolower(trim($value));
135            return match ($valueLower) {
136                'true', '1', 'yes', 'on' => true,
137                'false', '0', 'no', 'off', '' => false,
138                default => null,
139            };
140        }
141
142        if (is_numeric($value)) {
143            return (bool) $value;
144        }
145
146        return null;
147    }
148
149    /**
150     * Parses a date value (YYYY-MM-DD format)
151     *
152     * @param mixed $value The value to parse
153     * @return string|null Date in YYYY-MM-DD format or null if invalid
154     */
155    private static function parseDateValue(mixed $value): ?string
156    {
157        if (!is_string($value)) {
158            return null;
159        }
160
161        $value = (string) Filter::filterVar($value, FILTER_SANITIZE_SPECIAL_CHARS);
162
163        // Validate date format YYYY-MM-DD
164        if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
165            // Verify it's a valid date
166            $parts = explode('-', $value);
167            if (checkdate((int) $parts[1], (int) $parts[2], (int) $parts[0])) {
168                return $value;
169            }
170        }
171
172        return null;
173    }
174
175    /**
176     * Parses a datetime value (ISO 8601 format)
177     *
178     * @param mixed $value The value to parse
179     * @return string|null Datetime in ISO 8601 format or null if invalid
180     */
181    private static function parseDateTimeValue(mixed $value): ?string
182    {
183        if (!is_string($value)) {
184            return null;
185        }
186
187        $value = (string) Filter::filterVar($value, FILTER_SANITIZE_SPECIAL_CHARS);
188
189        // Try to parse as datetime
190        try {
191            $dateTime = new DateTime($value);
192            return $dateTime->format('Y-m-d H:i:s');
193        } catch (Exception) {
194            return null;
195        }
196    }
197
198    /**
199     * Checks if a specific filter is set
200     *
201     * @param string $filterName The filter name
202     * @return bool
203     */
204    public function has(string $filterName): bool
205    {
206        return array_key_exists($filterName, $this->filters);
207    }
208
209    /**
210     * Gets a specific filter value
211     *
212     * @param string $filterName The filter name
213     * @param mixed $default Default value if filter is not set
214     * @return mixed
215     */
216    public function get(string $filterName, mixed $default = null): mixed
217    {
218        return $this->filters[$filterName] ?? $default;
219    }
220
221    /**
222     * Gets all filters
223     *
224     * @return array
225     */
226    public function getFilters(): array
227    {
228        return $this->filters;
229    }
230
231    /**
232     * Checks if any filters are active
233     *
234     * @return bool
235     */
236    public function hasFilters(): bool
237    {
238        return count($this->filters) > 0;
239    }
240
241    /**
242     * Converts filter request to array format for API response metadata
243     *
244     * @return array|null
245     */
246    public function toArray(): ?array
247    {
248        return $this->hasFilters() ? $this->filters : null;
249    }
250}