Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.26% covered (success)
89.26%
108 / 121
86.36% covered (success)
86.36%
19 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
Data
89.26% covered (success)
89.26%
108 / 121
86.36% covered (success)
86.36%
19 / 22
92.75
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
8
 hasFiles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getQuery
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getPost
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getFiles
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getPut
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getPatch
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getDelete
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getQueryData
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getParsedData
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getRawData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasQueryData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasParsedData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasRawData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isStreamToFile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStreamToFileLocation
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 processStreamToFile
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 processData
84.38% covered (success)
84.38%
27 / 32
0.00% covered (danger)
0.00%
0 / 1
21.53
 applyFilters
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 prepareStreamToFile
88.24% covered (success)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
10.16
 clearStreamToFile
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 __get
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
10
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Http\Server;
16
17use Pop\Http\Parser;
18use Pop\Http\HttpFilterableTrait;
19
20/**
21 * HTTP server request data class
22 *
23 * @category   Pop
24 * @package    Pop\Http
25 * @author     Nick Sagona, III <nick@popphp.org>
26 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
27 * @license    https://www.popphp.org/license     New BSD License
28 * @version    6.0.0
29 * @property-read array             $get
30 * @property-read array             $post
31 * @property-read array             $files
32 * @property-read array             $put
33 * @property-read array             $patch
34 * @property-read array             $delete
35 * @property-read string|array|null $parsed
36 * @property-read string|null       $raw
37 */
38class Data
39{
40
41    use HttpFilterableTrait;
42
43    /**
44     * GET array
45     * @var array
46     */
47    protected array $get = [];
48
49    /**
50     * POST array
51     * @var array
52     */
53    protected array $post = [];
54
55    /**
56     * FILES array
57     * @var array
58     */
59    protected array $files = [];
60
61    /**
62     * PUT array
63     * @var array
64     */
65    protected array $put = [];
66
67    /**
68     * PATCH array
69     * @var array
70     */
71    protected array $patch = [];
72
73    /**
74     * DELETE array
75     * @var array
76     */
77    protected array $delete = [];
78
79    /**
80     * Query data
81     * @var mixed
82     *
83     * Permanently unset (always null) now that the QUERY_STRING re-parse that populated it has been
84     * removed; kept only so the deprecated getQueryData()/hasQueryData() accessors below have something
85     * to return. Use getQuery() instead, which reads directly from PHP's native $_GET.
86     */
87    protected mixed $queryData = null;
88
89    /**
90     * Parsed data
91     * @var mixed
92     */
93    protected mixed $parsedData = null;
94
95    /**
96     * Raw data
97     * @var mixed
98     */
99    protected mixed $rawData = null;
100
101    /**
102     * Stream to file
103     * @var bool
104     */
105    protected bool $streamToFile = false;
106
107    /**
108     * Stream to file
109     * @var ?string
110     */
111    protected ?string $streamToFileLocation = null;
112
113    /**
114     * Constructor
115     *
116     * Instantiate the request data object
117     *
118     * @param  ?string $contentType
119     * @param  ?string $encoding
120     * @param  mixed $filters
121     * @param  mixed $streamToFile
122     * @param  bool $populateFromGlobals
123     * @throws Exception
124     */
125    public function __construct(
126        ?string $contentType = null, ?string $encoding = null, mixed $filters = null,
127        mixed $streamToFile = null, bool $populateFromGlobals = true
128    )
129    {
130        if ($filters !== null) {
131            if (is_array($filters)) {
132                $this->addFilters($filters);
133            } else {
134                $this->addFilter($filters);
135            }
136        }
137
138        if ($populateFromGlobals) {
139            $this->get   = array_key_exists('_GET', $GLOBALS)   ? $_GET   : [];
140            $this->post  = array_key_exists('_POST', $GLOBALS)  ? $_POST  : [];
141            $this->files = array_key_exists('_FILES', $GLOBALS) ? $_FILES : [];
142
143            if (isset($_SERVER['REQUEST_METHOD'])) {
144                $this->processData($contentType, $encoding, $streamToFile);
145            }
146        }
147    }
148
149    /**
150     * Return whether or not the request has FILES
151     *
152     * @return bool
153     */
154    public function hasFiles(): bool
155    {
156        return (count($this->files) > 0);
157    }
158
159    /**
160     * Get a value from $_GET, or the whole array
161     *
162     * @param  ?string $key
163     * @return string|array|null
164     */
165    public function getQuery(?string $key = null): string|array|null
166    {
167        if ($key === null) {
168            return $this->get;
169        } else {
170            return $this->get[$key] ?? null;
171        }
172    }
173
174    /**
175     * Get a value from $_POST, or the whole array
176     *
177     * @param  ?string $key
178     * @return string|array|null
179     */
180    public function getPost(?string $key = null): string|array|null
181    {
182        if ($key === null) {
183            return $this->post;
184        } else {
185            return $this->post[$key] ?? null;
186        }
187    }
188
189    /**
190     * Get a value from $_FILES, or the whole array
191     *
192     * @param  ?string $key
193     * @return string|array|null
194     */
195    public function getFiles(?string $key = null): string|array|null
196    {
197        if ($key === null) {
198            return $this->files;
199        } else {
200            return $this->files[$key] ?? null;
201        }
202    }
203
204    /**
205     * Get a value from PUT query data, or the whole array
206     *
207     * @param  ?string $key
208     * @return string|array|null
209     */
210    public function getPut(?string $key = null): string|array|null
211    {
212        if ($key === null) {
213            return $this->put;
214        } else {
215            return $this->put[$key] ?? null;
216        }
217    }
218
219    /**
220     * Get a value from PATCH query data, or the whole array
221     *
222     * @param  ?string $key
223     * @return string|array|null
224     */
225    public function getPatch(?string $key = null): string|array|null
226    {
227        if ($key === null) {
228            return $this->patch;
229        } else {
230            return $this->patch[$key] ?? null;
231        }
232    }
233
234    /**
235     * Get a value from DELETE query data, or the whole array
236     *
237     * @param  ?string $key
238     * @return string|array|null
239     */
240    public function getDelete(?string $key = null): string|array|null
241    {
242        if ($key === null) {
243            return $this->delete;
244        } else {
245            return $this->delete[$key] ?? null;
246        }
247    }
248
249    /**
250     * Get a value from query data, or the whole array
251     *
252     * @param  ?string $key
253     * @return string|array|null
254     * @deprecated This always returns null now: the QUERY_STRING re-parse that used to populate
255     *             $queryData was removed. Use getQuery() instead, which reads directly from PHP's
256     *             native $_GET.
257     */
258    public function getQueryData(?string $key = null): string|array|null
259    {
260        $result = null;
261
262        if (is_array($this->queryData)) {
263            if ($key === null) {
264                $result = $this->queryData;
265            } else {
266                $result = $this->queryData[$key] ?? null;
267            }
268        }
269
270        return $result;
271    }
272
273    /**
274     * Get a value from parsed data, or the whole array
275     *
276     * @param  ?string $key
277     * @return string|array|null
278     */
279    public function getParsedData(?string $key = null): string|array|null
280    {
281        $result = null;
282
283        if (is_array($this->parsedData)) {
284            if ($key === null) {
285                $result = $this->parsedData;
286            } else {
287                $result = $this->parsedData[$key] ?? null;
288            }
289        }
290
291        return $result;
292    }
293
294    /**
295     * Get the raw data
296     *
297     * @return string|null
298     */
299    public function getRawData(): string|null
300    {
301        return $this->rawData;
302    }
303
304    /**
305     * Has query data
306     *
307     * @return bool
308     * @deprecated This always returns false now: the QUERY_STRING re-parse that used to populate
309     *             $queryData was removed. Check getQuery() instead (e.g. !empty($this->getQuery())),
310     *             which reads directly from PHP's native $_GET.
311     */
312    public function hasQueryData(): bool
313    {
314        return !empty($this->queryData);
315    }
316
317    /**
318     * Has parsed data
319     *
320     * @return bool
321     */
322    public function hasParsedData(): bool
323    {
324        return !empty($this->parsedData);
325    }
326
327    /**
328     * Has raw data
329     *
330     * @return bool
331     */
332    public function hasRawData(): bool
333    {
334        return !empty($this->rawData);
335    }
336
337    /**
338     * Is the request stream to file
339     *
340     * @return bool
341     */
342    public function isStreamToFile(): bool
343    {
344        return $this->streamToFile;
345    }
346
347    /**
348     * Get stream to file location
349     *
350     * @return string
351     */
352    public function getStreamToFileLocation(): string
353    {
354        return $this->streamToFileLocation;
355    }
356
357    /**
358     * Process stream to file
359     *
360     * @param  ?string $contentType
361     * @param  ?string $contentEncoding
362     * @return Data
363     */
364    public function processStreamToFile(?string $contentType = null, ?string $contentEncoding = null): Data
365    {
366        if (($this->streamToFile) && file_exists($this->streamToFileLocation)) {
367            $this->rawData    = file_get_contents($this->streamToFileLocation);
368            $this->parsedData = Parser::parseDataByContentType($this->rawData, $contentType, $contentEncoding);
369        }
370
371        return $this;
372    }
373
374    /**
375     * Process any data that came with the request
376     *
377     * @param  ?string $contentType
378     * @param  ?string $encoding
379     * @param  mixed  $streamToFile
380     * @throws Exception
381     * @return void
382     */
383    public function processData(?string $contentType = null, ?string $encoding = null, mixed $streamToFile = null)
384    {
385        $method   = strtoupper($_SERVER['REQUEST_METHOD']);
386        $isMultipart = ($contentType !== null) && (stripos($contentType, 'multipart/form-data') !== false);
387
388        // Multipart POST requests: PHP has already correctly parsed $_POST/$_FILES natively.
389        // php://input is documented to be empty for this content type, so there is
390        // nothing useful to re-parse - trust PHP's own parse directly. Multipart bodies on
391        // other methods (PUT/PATCH/DELETE) are NOT natively parsed by PHP into any
392        // superglobal, so those still need the manual raw-body parse below.
393        if ($isMultipart && $method === 'POST') {
394            if ($streamToFile !== null) {
395                $this->prepareStreamToFile($streamToFile);
396            }
397            $this->parsedData = $this->post;
398            $this->applyFilters();
399            return;
400        }
401
402        // Stream raw data to file location
403        if ($streamToFile !== null) {
404            $this->prepareStreamToFile($streamToFile);
405        } else {
406            /**
407             * $_SERVER['X_POP_HTTP_RAW_DATA'] is for testing purposes only
408             */
409            $this->rawData = (isset($_SERVER['X_POP_HTTP_RAW_DATA'])) ?
410                $_SERVER['X_POP_HTTP_RAW_DATA'] : file_get_contents('php://input');
411        }
412
413        // GET and POST with a body PHP natively parses (url-encoded, or no content type
414        // at all): trust PHP's own native $_GET/$_POST parse directly, no redundant
415        // re-parse of QUERY_STRING/raw input needed for these.
416        $isNativelyParsedPost = ($method === 'POST') &&
417            (($contentType === null) || (stripos($contentType, 'application/x-www-form-urlencoded') !== false));
418
419        if ($method === 'GET') {
420            $this->parsedData = $this->get;
421            // A GET request carries its data in the query string, so that IS its raw data.
422            // php://input (read above) is empty for GET, so fall back to QUERY_STRING - but only
423            // if nothing was already captured above (streamToFile, or the X_POP_HTTP_RAW_DATA
424            // test override), which must not be clobbered. This is a straight raw-value copy,
425            // not a re-parse: $this->get is still PHP's own native parse of the query string.
426            if (empty($this->rawData)) {
427                $this->rawData = $_SERVER['QUERY_STRING'] ?? '';
428            }
429        } else if ($isNativelyParsedPost) {
430            $this->parsedData = $this->post;
431        } else if (($contentType !== null) && ($this->rawData !== null)) {
432            // PUT/PATCH/DELETE, POST bodies PHP doesn't natively parse (JSON/XML/multipart),
433            // and any other method: a manual parse against the raw body is required.
434            $this->parsedData = Parser::parseDataByContentType($this->rawData, $contentType, $encoding);
435        }
436
437        $this->applyFilters();
438
439        switch ($method) {
440            case 'PUT':
441                $this->put = (!empty($this->parsedData)) ? $this->parsedData : [];
442                break;
443            case 'PATCH':
444                $this->patch = (!empty($this->parsedData)) ? $this->parsedData : [];
445                break;
446            case 'DELETE':
447                $this->delete = (!empty($this->parsedData)) ? $this->parsedData : [];
448                break;
449        }
450    }
451
452    /**
453     * Apply any configured filters to the parsed, POST and GET data arrays
454     *
455     * Shared by both processData() exit paths (the multipart-POST early return and the
456     * general path) so filtering behavior can't drift between them.
457     *
458     * @return void
459     */
460    private function applyFilters(): void
461    {
462        if ($this->hasFilters()) {
463            if (!empty($this->parsedData)) {
464                $this->parsedData = $this->filter($this->parsedData);
465            }
466            if (!empty($this->post)) {
467                $this->post = $this->filter($this->post);
468            }
469            if (!empty($this->get)) {
470                $this->get = $this->filter($this->get);
471            }
472        }
473    }
474
475    /**
476     * Prepare stream to file
477     *
478     * @param  mixed $streamToFile
479     * @throws Exception
480     * @return void
481     */
482    public function prepareStreamToFile(mixed $streamToFile): void
483    {
484        $this->streamToFile = true;
485
486        // Stream raw data to system temp folder with auto-generated filename
487        if ($streamToFile === true) {
488            $this->streamToFileLocation = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'pop-http-' . uniqid();
489            // Else, stream raw data to user-specified file location
490        } else if (!is_dir($streamToFile) && is_dir(dirname($streamToFile)) && is_writable(dirname($streamToFile))) {
491            $this->streamToFileLocation = $streamToFile;
492            // Else, stream raw data to user-specified direction with auto-generated filename
493        } else if (is_dir($streamToFile) && is_writable($streamToFile)) {
494            $filename = 'pop-http-' . uniqid();
495            $this->streamToFileLocation = $streamToFile .
496                ((substr($streamToFile, -1) == DIRECTORY_SEPARATOR) ? $filename : DIRECTORY_SEPARATOR . $filename);
497        } else {
498            throw new Exception('Error: Unable to determine an acceptable file location in which to stream the data.');
499        }
500
501        /**
502         * $_SERVER['X_POP_HTTP_RAW_DATA'] is for testing purposes only
503         */
504        if (!empty($this->streamToFileLocation)) {
505            $this->rawData = ($_SERVER['X_POP_HTTP_RAW_DATA'] ?? file_get_contents('php://input'));
506            file_put_contents($this->streamToFileLocation, $this->rawData);
507
508            clearstatcache();
509
510            // Clear out if no raw data was stored
511            if (filesize($this->streamToFileLocation) == 0) {
512                unlink($this->streamToFileLocation);
513                $this->streamToFileLocation = null;
514            }
515        }
516    }
517
518    /**
519     * Clear stream to file
520     *
521     * @return Data
522     */
523    public function clearStreamToFile(): Data
524    {
525        if (file_exists($this->streamToFileLocation) && is_writable($this->streamToFileLocation)) {
526            unlink($this->streamToFileLocation);
527        }
528        return $this;
529    }
530
531    /**
532     * Magic method to get a value from one of the data arrays
533     *
534     * @param  string $name
535     * @return mixed
536     */
537    public function __get(string $name): mixed
538    {
539        return match ($name) {
540            'get'    => $this->get,
541            'post'   => $this->post,
542            'files'  => $this->files,
543            'put'    => $this->put,
544            'patch'  => $this->patch,
545            'delete' => $this->delete,
546            'parsed' => $this->parsedData,
547            'raw'    => $this->rawData,
548            default  => null,
549        };
550    }
551
552}