Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.20% covered (success)
91.20%
114 / 125
86.21% covered (success)
86.21%
25 / 29
CRAP
0.00% covered (danger)
0.00%
0 / 1
Data
91.20% covered (success)
91.20%
114 / 125
86.21% covered (success)
86.21%
25 / 29
93.28
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 setData
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 addData
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getData
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 hasData
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 removeData
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 removeAllData
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 setRequest
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getRequest
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasRequest
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDataContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDataContentLength
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 hasDataContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isPrepared
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prepare
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
16.17
 reset
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 prepareUrlEncoded
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
6.03
 prepareJson
76.92% covered (success)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
12.49
 prepareXml
61.54% covered (warning)
61.54%
8 / 13
0.00% covered (danger)
0.00%
0 / 1
11.64
 prepareMultipart
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 getRawData
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
2
 getRawDataLength
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 toArray
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMimeTypes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasMimeType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMimeType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMimeTypeFromFilename
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 getDefaultMimeType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
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\Client;
16
17use Pop\Http\HttpFilterableTrait;
18
19/**
20 * Client request data class
21 *
22 * @category   Pop
23 * @package    Pop\Http
24 * @author     Nick Sagona, III <nick@popphp.org>
25 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
26 * @license    https://www.popphp.org/license     New BSD License
27 * @version    6.0.0
28 */
29class Data
30{
31
32    use HttpFilterableTrait;
33
34    /**
35     * Raw data constant
36     */
37    const POP_CLIENT_REQUEST_RAW_DATA = 'POP_CLIENT_REQUEST_RAW_DATA';
38
39    /**
40     * Data fields (form fields and files)
41     *    $data = [
42     *      'username' => 'admin'
43     *      'file1'     => [
44     *          'filename'    => __DIR__ . '/path/to/file.txt',
45     *          'contentType' => 'text/plain'
46     *      ],
47     *      'file2'     => [
48     *          'filename'    => 'test.pdf',
49     *          'contentType' => 'application/pdf',
50     *          'contents'    => file_get_contents(__DIR__ . '/path/to/test.pdf'
51     *      ]
52     *    ]
53     * @var array
54     */
55    protected array $data = [];
56
57    /**
58     * Data parent request
59     * @var ?Request
60     */
61    protected ?Request $request = null;
62
63    /**
64     * Data content
65     * @var ?string
66     */
67    protected ?string $dataContent = null;
68
69    /**
70     * Data content prepared flag
71     * @var bool
72     */
73    protected bool $prepared = false;
74
75    /**
76     * Common mime types
77     * @var array
78     */
79    protected static array $mimeTypes = [
80        'bmp'    => 'image/x-ms-bmp',
81        'bz2'    => 'application/bzip2',
82        'csv'    => 'text/csv',
83        'doc'    => 'application/msword',
84        'docx'   => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
85        'gif'    => 'image/gif',
86        'gz'     => 'application/gzip',
87        'jpe'    => 'image/jpeg',
88        'jpg'    => 'image/jpeg',
89        'jpeg'   => 'image/jpeg',
90        'json'   => 'application/json',
91        'log'    => 'text/plain',
92        'pdf'    => 'application/pdf',
93        'png'    => 'image/png',
94        'ppt'    => 'application/msword',
95        'pptx'   => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
96        'psd'    => 'image/x-photoshop',
97        'svg'    => 'image/svg+xml',
98        'tar'    => 'application/x-tar',
99        'tbz'    => 'application/bzip2',
100        'tbz2'   => 'application/bzip2',
101        'tgz'    => 'application/gzip',
102        'tif'    => 'image/tiff',
103        'tiff'   => 'image/tiff',
104        'tsv'    => 'text/tsv',
105        'txt'    => 'text/plain',
106        'xls'    => 'application/msword',
107        'xlsx'   => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
108        'xml'    => 'application/xml',
109        'zip'    => 'application/zip'
110    ];
111
112    /**
113     * Default mime type
114     * @var string
115     */
116    protected static string $defaultMimeType = 'application/octet-stream';
117
118    /**
119     * Constructor
120     *
121     * Instantiate the request data object
122     *
123     * @param array|string $data
124     * @param mixed        $filters
125     * @param ?Request     $request
126     */
127    public function __construct(array|string $data = [], mixed $filters = null, ?Request $request = null)
128    {
129        if ($filters !== null) {
130            if (is_array($filters)) {
131                $this->addFilters($filters);
132            } else {
133                $this->addFilter($filters);
134            }
135        }
136
137        if ($request !== null) {
138            $this->setRequest($request);
139        }
140
141        if (!empty($data)) {
142            $this->setData($data);
143        }
144    }
145
146    /**
147     * Set data
148     *
149     * @param  array|string $data
150     * @return Data
151     */
152    public function setData(array|string $data): Data
153    {
154        if (is_string($data)) {
155            $this->data = [self::POP_CLIENT_REQUEST_RAW_DATA => $data];
156        } else if ((count($data) == 1) && isset($data[0]) && is_string($data[0])) {
157            $this->data = [self::POP_CLIENT_REQUEST_RAW_DATA => $data[0]];
158        } else {
159            $this->data = $data;
160            $this->prepare();
161        }
162
163        return $this;
164    }
165
166    /**
167     * Add data
168     *
169     * @param  array|string $data
170     * @param  mixed        $value
171     * @return Data
172     */
173    public function addData(array|string $data, mixed $value = null): Data
174    {
175        if (is_string($data) && ($value !== null)) {
176            $this->data[$data] = $value;
177        } else if (is_array($data)) {
178            $this->data = array_merge($this->data, $data);
179        }
180
181        // Marked unprepared rather than eagerly re-serialized: prepare() re-encodes the
182        // *entire* accumulated data array (http_build_query()/json_encode()) from scratch,
183        // which is wasted work when called key-by-key (e.g. Client::syncRequestFromOptions())
184        // and gets redone anyway - every handler already checks isPrepared() before send.
185        $this->reset();
186
187        return $this;
188    }
189
190    /**
191     * Get data
192     *
193     * @param  ?string $key
194     * @return mixed
195     */
196    public function getData(?string $key = null): mixed
197    {
198        if ($key !== null) {
199            return $this->data[$key] ?? null;
200        } else {
201            return $this->data;
202        }
203    }
204
205    /**
206     * Has data
207     *
208     * @param  ?string $key
209     * @return bool
210     */
211    public function hasData(?string $key = null): bool
212    {
213        if ($key !== null) {
214            return (isset($this->data[$key]));
215        } else {
216            return !empty($this->data);
217        }
218    }
219
220    /**
221     * Remove data
222     *
223     * @param  string $key
224     * @return Data
225     */
226    public function removeData(string $key): Data
227    {
228        if (isset($this->data[$key])) {
229            unset($this->data[$key]);
230        }
231
232        // See addData() - defer re-serialization until the handler actually needs it.
233        $this->reset();
234
235        return $this;
236    }
237
238    /**
239     * Remove all data
240     *
241     * @return Data
242     */
243    public function removeAllData(): Data
244    {
245        $this->data        = [];
246        $this->dataContent = null;
247
248        return $this;
249    }
250
251    /**
252     * Set data parent request
253     *
254     * @param  Request $request
255     * @return Data
256     */
257    public function setRequest(Request $request): Data
258    {
259        $this->request = $request;
260        return $this;
261    }
262
263    /**
264     * Get data parent request
265     *
266     * @return ?Request
267     */
268    public function getRequest(): ?Request
269    {
270        return $this->request;
271    }
272
273    /**
274     * Has data parent request
275     *
276     * @return bool
277     */
278    public function hasRequest(): bool
279    {
280        return !empty($this->request);
281    }
282
283    /**
284     * Get prepared data content
285     *
286     * @return ?string
287     */
288    public function getDataContent(): ?string
289    {
290        return $this->dataContent;
291    }
292
293    /**
294     * Get data content length
295     *
296     * @param  bool $mb
297     * @return int
298     */
299    public function getDataContentLength(bool $mb = false): int
300    {
301        return ($mb) ? mb_strlen($this->dataContent) : strlen($this->dataContent);
302    }
303
304    /**
305     * Check if the data content has been prepared
306     *
307     * @return bool
308     */
309    public function hasDataContent(): bool
310    {
311        return !empty($this->dataContent);
312    }
313
314    /**
315     * Check if the data content has been prepared (alias to hasDataContent)
316     *
317     * @return bool
318     */
319    public function isPrepared(): bool
320    {
321        return $this->prepared;
322    }
323
324    /**
325     * Prepare data
326     *
327     * @param  bool $mb
328     * @return Data
329     */
330    public function prepare(bool $mb = false): Data
331    {
332        $type = $this->request?->getRequestType();
333        switch ($type) {
334            case Request::JSON:
335                $this->prepareJson();
336                break;
337            case Request::XML:
338                $this->prepareXml();
339                break;
340            case Request::URLENCODED:
341                $this->prepareUrlEncoded();
342                break;
343            case Request::MULTIPART:
344                $this->prepareMultipart();
345                break;
346            default:
347                // Custom types
348                if (($type !== null) && (strrpos($type, 'json') !== false)) {
349                    $this->prepareJson();
350                } else if (($type !== null) && (strrpos($type, 'xml') !== false)) {
351                    $this->prepareXml();
352                } else {
353                    if ($this->hasRawData()) {
354                        $this->dataContent = $this->getRawData();
355                    } else if ($this->hasData()) {
356                        $this->prepareUrlEncoded();
357                    }
358                }
359
360        }
361
362        if (!empty($this->dataContent) && ($this->hasRequest()) && ($this->request->getMethod() != 'GET')) {
363            if ($this->request->hasHeader('Content-Length')) {
364                $this->request->removeHeader('Content-Length');
365            }
366            $this->request->addHeader('Content-Length', (string)strlen($this->dataContent));
367        }
368
369        $this->prepared = true;
370
371        return $this;
372    }
373
374    /**
375     * Reset data
376     *
377     * @return Data
378     */
379    public function reset(): Data
380    {
381        $this->dataContent = null;
382        $this->prepared    = false;
383        return $this;
384    }
385
386    /**
387     * Method to prepare URL-encoded data content
388     *
389     * @return Data
390     */
391    public function prepareUrlEncoded(): Data
392    {
393        if (!array_key_exists(self::POP_CLIENT_REQUEST_RAW_DATA, $this->data) && !empty($this->data)) {
394            $data = $this->data;
395            if ($this->hasFilters()) {
396                $data = $this->filter($data);
397            }
398            $this->dataContent = http_build_query($data);
399        } else {
400            $this->dataContent = null;
401        }
402
403        if ($this->hasRequest()) {
404            if ($this->request->hasHeader('Content-Type')) {
405                $this->request->removeHeader('Content-Type');
406            }
407            $this->request->addHeader('Content-Type', Request::URLENCODED);
408        }
409
410        return $this;
411    }
412
413    /**
414     * Method to prepare JSON data content
415     *
416     * @return Data
417     */
418    public function prepareJson(): Data
419    {
420        if ($this->hasRawData()) {
421            $jsonContent = $this->getRawData();
422        } else {
423            $jsonContent = $this->data;
424        }
425
426        if ($this->hasRequest()) {
427            if ($this->request->hasHeader('Content-Type') && !str_contains(strtolower((string)$this->request->getHeaderObject('Content-Type')), 'json')) {
428                $this->request->removeHeader('Content-Type');
429            }
430            if (!$this->request->hasHeader('Content-Type')) {
431                $type = $this->request?->getRequestType();
432                $this->request->addHeader('Content-Type', (!empty($type) && (strrpos($type, 'json') !== false)) ? $type : Request::JSON);
433            }
434        }
435
436        // useRawData() is the explicit, documented way to send pre-built content
437        // as-is; anything else is always encoded, regardless of its shape.
438        $this->dataContent = ($this->hasRequest() && $this->request->useRawData() && is_string($jsonContent))
439            ? $jsonContent
440            : json_encode($jsonContent, JSON_PRETTY_PRINT);
441
442        return $this;
443    }
444
445    /**
446     * Method to prepare XML data content
447     *
448     * @return Data
449     */
450    public function prepareXml(): Data
451    {
452        if ($this->hasRawData()) {
453            $xmlContent = $this->getRawData();
454        } else {
455            // Only scalar values are meaningful as XML content to concatenate -
456            // non-scalar (e.g. array-shaped) entries are filtered out rather than
457            // string-cast, to avoid an "Array to string conversion" warning. No
458            // filename/contentType-shaped file detection here - this is not a
459            // reintroduction of the removed heuristic, just a defensive type guard.
460            $xmlContent = implode('', array_filter($this->data, 'is_scalar'));
461        }
462
463        if ($this->hasRequest()) {
464            if ($this->request->hasHeader('Content-Type') && !str_contains(strtolower((string)$this->request->getHeaderObject('Content-Type')), 'xml')) {
465                $this->request->removeHeader('Content-Type');
466            }
467            if (!$this->request->hasHeader('Content-Type')) {
468                $type = $this->request?->getRequestType();
469                if (!empty($type) && (strrpos($type, 'xml') !== false)) {
470                    $this->request->addHeader('Content-Type', $type);
471                } else {
472                    $this->request->addHeader('Content-Type', Request::XML);
473                }
474            }
475        }
476
477        $this->dataContent = $xmlContent;
478
479        return $this;
480    }
481
482    /**
483     * Method to prepare multi-part data content
484     *
485     * Multipart preparation is deliberately lazy/zero-copy: it only mints the boundary and
486     * declares it on the Content-Type header. The body itself is NEVER rendered here, because
487     * nothing consumes $dataContent for a multipart request - Curl gets the curl-native array
488     * shape (scalars + CURLFile) from AbstractHandler::resolveRequestBody() and streams files
489     * straight off disk, and Stream renders the string exactly once itself in Stream::prepare(),
490     * reusing the boundary declared below. Rendering here would buffer every uploaded file into
491     * memory only to throw the result away (and, for Stream, render the whole body twice).
492     *
493     * Leaving $dataContent empty also (correctly) skips the generic Content-Length block in
494     * prepare(): curl computes its own Content-Length for an array CURLOPT_POSTFIELDS since it
495     * builds the multipart framing itself, and PHP's http:// stream wrapper computes one from
496     * the 'content' it is given. Both verified empirically; neither needs an explicit header.
497     *
498     * @return Data
499     */
500    public function prepareMultipart(): Data
501    {
502        $boundary = \Pop\Http\Body\Multipart::generateBoundary();
503
504        // Explicitly empty - a prior prepareXxx() on this same Data may have left a rendered
505        // string (and its Content-Length) behind, and neither is valid for a multipart body.
506        $this->dataContent = null;
507
508        if ($this->hasRequest()) {
509            if ($this->request->hasHeader('Content-Type')) {
510                $this->request->removeHeader('Content-Type');
511            }
512            $this->request->addHeader('Content-Type', 'multipart/form-data; boundary=' . $boundary);
513
514            // The transport computes the multipart Content-Length (curl builds the framing itself
515            // for an array POSTFIELDS; PHP's stream wrapper derives it from 'content'), so any
516            // Content-Length left over from an earlier preparation can only be wrong here.
517            if ($this->request->hasHeader('Content-Length')) {
518                $this->request->removeHeader('Content-Length');
519            }
520        }
521
522        return $this;
523    }
524
525    /**
526     * Get raw data
527     *
528     * @return ?string
529     */
530    public function getRawData(): ?string
531    {
532        return $this->data[self::POP_CLIENT_REQUEST_RAW_DATA] ?? null;
533    }
534
535    /**
536     * Has raw data
537     *
538     * @return bool
539     */
540    public function hasRawData(): bool
541    {
542        return (count($this->data) == 1) && isset($this->data[self::POP_CLIENT_REQUEST_RAW_DATA]);
543    }
544
545    /**
546     * Get raw data length
547     *
548     * @param  bool $mb
549     * @return int
550     */
551    public function getRawDataLength(bool $mb = false): int
552    {
553        return ($mb) ? mb_strlen((string)$this->getRawData()) : strlen((string)$this->getRawData());
554    }
555
556    /**
557     * Get data array
558     *
559     * @return array
560     */
561    public function toArray(): array
562    {
563        return $this->data;
564    }
565
566    /**
567     * Get common mime types
568     *
569     * @return array
570     */
571    public static function getMimeTypes(): array
572    {
573        return static::$mimeTypes;
574    }
575
576    /**
577     * Has mime type
578     *
579     * @param  string $ext
580     * @return bool
581     */
582    public static function hasMimeType(string $ext): bool
583    {
584        return isset(static::$mimeTypes[$ext]);
585    }
586
587    /**
588     * Get mime type
589     *
590     * @param  string $ext
591     * @return ?string
592     */
593    public static function getMimeType(string $ext): ?string
594    {
595        return static::$mimeTypes[$ext] ?? null;
596    }
597
598    /**
599     * Get mime type
600     *
601     * @param  string $filename
602     * @return string
603     */
604    public static function getMimeTypeFromFilename(string $filename): string
605    {
606        $info = pathinfo($filename);
607
608        return (isset($info['extension']) && isset(self::$mimeTypes[$info['extension']])) ?
609            self::$mimeTypes[$info['extension']] : self::$defaultMimeType;
610    }
611
612    /**
613     * Get default mime type
614     *
615     * @return string
616     */
617    public static function getDefaultMimeType(): string
618    {
619        return static::$defaultMimeType;
620    }
621
622}