Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.05% covered (success)
86.05%
111 / 129
71.05% covered (success)
71.05%
27 / 38
CRAP
0.00% covered (danger)
0.00%
0 / 1
Body
86.05% covered (success)
86.05%
111 / 129
71.05% covered (success)
71.05%
27 / 38
104.63
0.00% covered (danger)
0.00%
0 / 1
 __construct
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 setContent
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getContent
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 hasContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setContentFromFile
83.33% covered (success)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
5.12
 setContentFromStream
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getStream
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 close
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 detach
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getSize
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 tell
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 eof
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isSeekable
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 seek
50.00% covered (warning)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
6.00
 rewind
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isWritable
75.00% covered (success)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
6.56
 write
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 isReadable
75.00% covered (success)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 read
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 getContents
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMetadata
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 __clone
57.14% covered (warning)
57.14%
8 / 14
0.00% covered (danger)
0.00%
0 / 1
6.97
 setEncoding
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
5
 getEncoding
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 hasEncoding
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isBase64Encoding
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isQuotedEncoding
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isUrlEncoding
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isRawUrlEncoding
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setSplit
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getSplit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasSplit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setAsEncoded
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isEncoded
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setAsFile
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isFile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 render
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
8
 __toString
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 <dev@noladev.com>
8 * @copyright  Copyright (c) 2009-2027 NOLA Interactive, LLC.
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Http;
16
17use Psr\Http\Message\StreamInterface;
18
19/**
20 * HTTP body class
21 *
22 * @category   Pop
23 * @package    Pop\Http
24 * @author     Nick Sagona, III <dev@noladev.com>
25 * @copyright  Copyright (c) 2009-2027 NOLA Interactive, LLC.
26 * @license    https://www.popphp.org/license     New BSD License
27 * @version    6.0.0
28 */
29class Body implements StreamInterface
30{
31
32    /**
33     * Encoding constants
34     * @var string
35     */
36    const BASE64  = 'BASE64';
37    const QUOTED  = 'QUOTED';
38    const URL     = 'URL';
39    const RAW_URL = 'RAW_URL';
40
41    /**
42     * Underlying content stream. All content - string-set or file-backed - lives here.
43     * @var resource|null
44     */
45    protected mixed $stream = null;
46
47    /**
48     * Encoding
49     * @var ?string
50     */
51    protected ?string $encoding = null;
52
53    /**
54     * Chunk split
55     * @var int|bool|null
56     */
57    protected int|bool|null $split = null;
58
59    /**
60     * Is file flag
61     * @var bool
62     */
63    protected bool $isFile = false;
64
65    /**
66     * Is encoded flag
67     * @var bool
68     */
69    protected bool $isEncoded = false;
70
71    /**
72     * Constructor
73     *
74     * Instantiate the body object
75     *
76     * @param ?string       $content
77     * @param ?string       $encoding
78     * @param int|bool|null $split
79     */
80    public function __construct(?string $content = null, ?string $encoding = null, int|bool|null $split = null)
81    {
82        if ($content !== null) {
83            $this->setContent($content);
84        }
85        if ($encoding !== null) {
86            $this->setEncoding($encoding);
87        }
88        if ($split !== null) {
89            $this->setSplit($split);
90        }
91    }
92
93    /**
94     * Set the body content from a string
95     *
96     * @param  string $content
97     * @return Body
98     */
99    public function setContent(string $content): Body
100    {
101        $this->stream = fopen('php://temp', 'r+');
102        fwrite($this->stream, $content);
103        rewind($this->stream);
104        $this->isFile = false;
105
106        return $this;
107    }
108
109    /**
110     * Get the body content as a string
111     *
112     * Reads the entire underlying stream and restores its read position
113     * afterward, so repeated calls return the same content.
114     *
115     * @return string
116     */
117    public function getContent(): string
118    {
119        if ($this->stream === null) {
120            return '';
121        }
122
123        $position = ftell($this->stream);
124        rewind($this->stream);
125        $content = stream_get_contents($this->stream);
126        fseek($this->stream, $position);
127
128        return $content;
129    }
130
131    /**
132     * Has body content
133     *
134     * @return bool
135     */
136    public function hasContent(): bool
137    {
138        return ($this->stream !== null);
139    }
140
141    /**
142     * Set the body content from a file, without buffering it into memory
143     *
144     * @param  string        $file
145     * @param  ?string       $encoding
146     * @param  int|bool|null $split
147     * @throws Exception
148     * @return Body
149     */
150    public function setContentFromFile(string $file, ?string $encoding = null, int|bool|null $split = null): Body
151    {
152        if (!file_exists($file)) {
153            throw new Exception("Error: The file '" . $file . "' does not exist.");
154        }
155
156        $stream = @fopen($file, 'rb');
157        if ($stream === false) {
158            throw new Exception("Error: Unable to open the file '" . $file . "' for reading.");
159        }
160
161        $this->stream = $stream;
162        $this->isFile = true;
163
164        if ($encoding !== null) {
165            $this->setEncoding($encoding);
166        }
167        if ($split !== null) {
168            $this->setSplit($split);
169        }
170
171        return $this;
172    }
173
174    /**
175     * Set the body content directly from an existing stream resource
176     *
177     * If the given stream is known to be backed by a real file (e.g. a handle returned
178     * from fopen() on a local file), chain ->setAsFile(true) afterward to flag it as such,
179     * since this method has no reliable way to detect that on its own.
180     *
181     * @param  mixed $stream
182     * @return Body
183     */
184    public function setContentFromStream(mixed $stream): Body
185    {
186        $this->stream = $stream;
187        return $this;
188    }
189
190    /**
191     * Get the raw underlying stream resource, for a consumer that wants to
192     * copy bytes directly (e.g. to the network) without materializing the
193     * whole content as a PHP string.
194     *
195     * @return mixed
196     */
197    public function getStream(): mixed
198    {
199        return $this->stream;
200    }
201
202    /**
203     * Close the underlying stream
204     *
205     * @return void
206     */
207    public function close(): void
208    {
209        if ($this->stream !== null) {
210            fclose($this->stream);
211            $this->stream = null;
212        }
213    }
214
215    /**
216     * Detach the underlying stream resource, leaving the Body empty
217     *
218     * @return mixed
219     */
220    public function detach(): mixed
221    {
222        $stream       = $this->stream;
223        $this->stream = null;
224        return $stream;
225    }
226
227    /**
228     * Get the content size in bytes, without reading file-backed content
229     * into memory to compute it. Per PSR-7, returns null if the stream size
230     * is not statable (e.g. php://output).
231     *
232     * @return int|null
233     */
234    public function getSize(): ?int
235    {
236        if ($this->stream === null) {
237            return 0;
238        }
239
240        // Not every stream is statable (php://output, some sockets/pipes); fstat() returns false
241        // for those, and per StreamInterface that means "size unknown", not zero.
242        $stat = @fstat($this->stream);
243
244        return ($stat === false) ? null : $stat['size'];
245    }
246
247    /**
248     * Get the current position of the stream read/write pointer
249     *
250     * @throws Exception
251     * @return int
252     */
253    public function tell(): int
254    {
255        if ($this->stream === null) {
256            throw new Exception('Error: No stream is available.');
257        }
258
259        $position = ftell($this->stream);
260        if ($position === false) {
261            throw new Exception('Error: Unable to determine the stream position.');
262        }
263
264        return $position;
265    }
266
267    /**
268     * Determine if the stream is at end-of-file
269     *
270     * @return bool
271     */
272    public function eof(): bool
273    {
274        return ($this->stream === null) || feof($this->stream);
275    }
276
277    /**
278     * Determine if the stream is seekable
279     *
280     * @return bool
281     */
282    public function isSeekable(): bool
283    {
284        if ($this->stream === null) {
285            return false;
286        }
287
288        return (bool)stream_get_meta_data($this->stream)['seekable'];
289    }
290
291    /**
292     * Seek to a position in the stream
293     *
294     * @param  int $offset
295     * @param  int $whence
296     * @throws Exception
297     * @return void
298     */
299    public function seek(int $offset, int $whence = SEEK_SET): void
300    {
301        if (($this->stream === null) || (!$this->isSeekable()) || (fseek($this->stream, $offset, $whence) === -1)) {
302            throw new Exception('Error: Unable to seek the stream.');
303        }
304    }
305
306    /**
307     * Seek to the beginning of the stream
308     *
309     * @throws Exception
310     * @return void
311     */
312    public function rewind(): void
313    {
314        $this->seek(0);
315    }
316
317    /**
318     * Determine if the stream is writable
319     *
320     * @return bool
321     */
322    public function isWritable(): bool
323    {
324        if ($this->stream === null) {
325            return false;
326        }
327
328        $mode = stream_get_meta_data($this->stream)['mode'];
329
330        return (str_contains($mode, 'w') || str_contains($mode, 'a') || str_contains($mode, '+') || str_contains($mode, 'x') || str_contains($mode, 'c'));
331    }
332
333    /**
334     * Write data to the stream
335     *
336     * @param  string $string
337     * @throws Exception
338     * @return int
339     */
340    public function write(string $string): int
341    {
342        if ($this->stream === null) {
343            throw new Exception('Error: No stream is available.');
344        }
345
346        $result = fwrite($this->stream, $string);
347        if ($result === false) {
348            throw new Exception('Error: Unable to write to the stream.');
349        }
350
351        return $result;
352    }
353
354    /**
355     * Determine if the stream is readable
356     *
357     * @return bool
358     */
359    public function isReadable(): bool
360    {
361        if ($this->stream === null) {
362            return false;
363        }
364
365        $mode = stream_get_meta_data($this->stream)['mode'];
366
367        return (str_contains($mode, 'r') || str_contains($mode, '+'));
368    }
369
370    /**
371     * Read up to $length bytes from the stream
372     *
373     * @param  int $length
374     * @throws Exception
375     * @return string
376     */
377    public function read(int $length): string
378    {
379        if ($this->stream === null) {
380            throw new Exception('Error: No stream is available.');
381        }
382
383        $result = fread($this->stream, $length);
384        if ($result === false) {
385            throw new Exception('Error: Unable to read from the stream.');
386        }
387
388        return $result;
389    }
390
391    /**
392     * Get the remaining raw stream contents, per PSR-7 - bypasses render()'s
393     * encoding/split transforms, unlike __toString()
394     *
395     * @throws Exception
396     * @return string
397     */
398    public function getContents(): string
399    {
400        return $this->getContent();
401    }
402
403    /**
404     * Get stream metadata
405     *
406     * @param  ?string $key
407     * @return mixed
408     */
409    public function getMetadata(?string $key = null): mixed
410    {
411        if ($this->stream === null) {
412            return ($key === null) ? [] : null;
413        }
414
415        $meta = stream_get_meta_data($this->stream);
416
417        return ($key === null) ? $meta : ($meta[$key] ?? null);
418    }
419
420    /**
421     * Deep-duplicate the underlying stream resource so a clone never shares
422     * a read/write position with the instance it was cloned from
423     *
424     * @return void
425     */
426    public function __clone(): void
427    {
428        if ($this->stream !== null) {
429            $position = ftell($this->stream);
430
431            // File-backed content is duplicated by reopening the same file handle
432            // rather than copying its bytes, preserving setContentFromFile()'s intent
433            // of never buffering large files into memory (or a php://temp fallback).
434            if ($this->isFile) {
435                $meta      = stream_get_meta_data($this->stream);
436                $duplicate = (!empty($meta['uri'])) ? @fopen($meta['uri'], 'rb') : false;
437                if ($duplicate !== false) {
438                    fseek($duplicate, $position);
439                    $this->stream = $duplicate;
440                    return;
441                }
442            }
443
444            $duplicate = fopen('php://temp', 'r+');
445            rewind($this->stream);
446            stream_copy_to_stream($this->stream, $duplicate);
447            fseek($this->stream, $position);
448            // Note: duplicate is left at the end by stream_copy_to_stream(), not reset
449            $this->stream = $duplicate;
450        }
451    }
452
453    /**
454     * Set the encoding
455     *
456     * @param  string $encoding
457     * @return Body
458     */
459    public function setEncoding(string $encoding): Body
460    {
461        switch ($encoding) {
462            case self::BASE64:
463            case self::QUOTED:
464            case self::URL:
465            case self::RAW_URL:
466                $this->encoding = $encoding;
467        }
468        return $this;
469    }
470
471    /**
472     * Get the encoding
473     *
474     * @return string|null
475     */
476    public function getEncoding(): string|null
477    {
478        return $this->encoding;
479    }
480
481    /**
482     * Has encoding
483     *
484     * @return bool
485     */
486    public function hasEncoding(): bool
487    {
488        return ($this->encoding !== null);
489    }
490
491    /**
492     * Is encoding base64
493     *
494     * @return bool
495     */
496    public function isBase64Encoding(): bool
497    {
498        return ($this->encoding == self::BASE64);
499    }
500
501    /**
502     * Is encoding quoted-printable
503     *
504     * @return bool
505     */
506    public function isQuotedEncoding(): bool
507    {
508        return ($this->encoding == self::QUOTED);
509    }
510
511    /**
512     * Is encoding URL
513     *
514     * @return bool
515     */
516    public function isUrlEncoding(): bool
517    {
518        return ($this->encoding == self::URL);
519    }
520
521    /**
522     * Is encoding raw URL
523     *
524     * @return bool
525     */
526    public function isRawUrlEncoding(): bool
527    {
528        return ($this->encoding == self::RAW_URL);
529    }
530
531    /**
532     * Set the split
533     *
534     * @param  int|bool $split
535     * @return Body
536     */
537    public function setSplit(int|bool $split): Body
538    {
539        $this->split = $split;
540        return $this;
541    }
542
543    /**
544     * Get the split
545     *
546     * @return int|bool|null
547     */
548    public function getSplit(): int|bool|null
549    {
550        return $this->split;
551    }
552
553    /**
554     * Has split
555     *
556     * @return bool
557     */
558    public function hasSplit(): bool
559    {
560        return ($this->split !== null);
561    }
562
563    /**
564     * Set as encoded
565     *
566     * @param  bool $isEncoded
567     * @return Body
568     */
569    public function setAsEncoded(bool $isEncoded): Body
570    {
571        $this->isEncoded = $isEncoded;
572        return $this;
573    }
574
575    /**
576     * Is encoded
577     *
578     * @return bool
579     */
580    public function isEncoded(): bool
581    {
582        return $this->isEncoded;
583    }
584
585    /**
586     * Set as file
587     *
588     * @param  bool $isFile
589     * @return Body
590     */
591    public function setAsFile(bool $isFile): Body
592    {
593        $this->isFile = $isFile;
594        return $this;
595    }
596
597    /**
598     * Is file
599     *
600     * @return bool
601     */
602    public function isFile(): bool
603    {
604        return $this->isFile;
605    }
606
607    /**
608     * Render the body
609     *
610     * @return string
611     */
612    public function render(): string
613    {
614        $content = $this->getContent();
615
616        if (!$this->isEncoded) {
617            switch ($this->encoding) {
618                case self::BASE64:
619                    $content = base64_encode($content);
620                    break;
621                case self::QUOTED:
622                    $content = quoted_printable_encode($content);
623                    break;
624                case self::URL:
625                    $content = urlencode($content);
626                    break;
627                case self::RAW_URL:
628                    $content = rawurlencode($content);
629                    break;
630            }
631        }
632
633        if ($this->split !== null) {
634            $content = ($this->split === true) ? chunk_split($content) : chunk_split($content, (int)$this->split);
635        }
636
637        return (string)$content;
638    }
639
640    /**
641     * Render the body
642     *
643     * @return string
644     */
645    public function __toString(): string
646    {
647        return $this->render();
648    }
649
650}