Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.03% covered (success)
96.03%
121 / 126
88.89% covered (success)
88.89%
24 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
Stream
96.03% covered (success)
96.03%
121 / 126
88.89% covered (success)
88.89%
24 / 27
69
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 create
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 setMethod
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 stream
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createContext
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 addContextOption
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
6.17
 addContextParam
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setContextOptions
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setContextParams
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setMode
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setVerifyPeer
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 allowSelfSigned
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getContext
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getContextOptions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getContextOption
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasContextOption
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getContextParams
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getContextParam
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasContextParam
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isVerifyPeer
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 isAllowSelfSigned
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 prepare
96.88% covered (success)
96.88%
31 / 32
0.00% covered (danger)
0.00%
0 / 1
16
 send
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 parseResponse
86.96% covered (success)
86.96%
20 / 23
0.00% covered (danger)
0.00%
0 / 1
9.18
 reset
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 disconnect
100.00% covered (success)
100.00%
6 / 6
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\Handler;
16
17use Pop\Http\Auth;
18use Pop\Http\Parser;
19use Pop\Http\Client\Request;
20use Pop\Http\Client\Response;
21
22/**
23 * HTTP client stream handler class
24 *
25 * @category   Pop
26 * @package    Pop\Http
27 * @author     Nick Sagona, III <nick@popphp.org>
28 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
29 * @license    https://www.popphp.org/license     New BSD License
30 * @version    6.0.0
31 */
32class Stream extends AbstractHandler
33{
34
35    /**
36     * Stream context
37     * @var mixed
38     */
39    protected mixed $context = null;
40
41    /**
42     * Stream context options
43     * @var array
44     */
45    protected array $contextOptions = [];
46
47    /**
48     * Stream context parameters
49     * @var array
50     */
51    protected array $contextParams = [];
52
53    /**
54     * Stream mode
55     * @var string
56     */
57    protected string $mode = 'r';
58
59    /**
60     * HTTP response headers
61     * @var ?array
62     */
63    protected ?array $httpResponseHeaders = null;
64
65    /**
66     * Constructor
67     *
68     * Instantiate the stream handler object
69     *
70     * @param string $mode
71     * @param array  $options
72     * @param array  $params
73     */
74    public function __construct(string $mode = 'r', array $options = [], array $params = [])
75    {
76        $this->setMode($mode);
77
78        if (count($options) > 0) {
79            $this->setContextOptions($options);
80        }
81        if (count($params) > 0) {
82            $this->setContextParams($params);
83        }
84    }
85
86    /**
87     * Factory method to create a Curl client
88     *
89     * @param  string $mode
90     * @param  array  $options
91     * @param  array  $params
92     * @return Stream
93     */
94    public static function create(string $method = 'GET', string $mode = 'r', array $options = [], array $params = []): Stream
95    {
96        $handler = new self($mode, $options, $params);
97        $handler->setMethod($method);
98        return $handler;
99    }
100
101    /**
102     * Set the method
103     *
104     * @param  string $method
105     * @return Stream
106     */
107    public function setMethod(string $method): Stream
108    {
109        if (!isset($this->contextOptions['http'])) {
110            $this->contextOptions['http'] = [];
111        }
112
113        $this->contextOptions['http']['method'] = $method;
114
115        return $this;
116    }
117
118    /**
119     * Return stream resource (alias to $this->getResource())
120     *
121     * @return mixed
122     */
123    public function stream(): mixed
124    {
125        return $this->resource;
126    }
127
128    /**
129     * Create stream context
130     *
131     * @return Stream
132     */
133    public function createContext(): Stream
134    {
135        if ((count($this->contextOptions) > 0) && (count($this->contextParams) > 0)) {
136            $this->context = stream_context_create($this->contextOptions, $this->contextParams);
137        } else if (count($this->contextOptions) > 0) {
138            $this->context = stream_context_create($this->contextOptions);
139        } else {
140            $this->context = stream_context_create();
141        }
142
143        return $this;
144    }
145
146    /**
147     * Add a context options
148     *
149     * @param  string $name
150     * @param  mixed  $option
151     * @return Stream
152     */
153    public function addContextOption(string $name, mixed $option): Stream
154    {
155        if (isset($this->contextOptions[$name]) && is_array($this->contextOptions[$name]) && is_array($option)) {
156            $this->contextOptions[$name] = array_merge($this->contextOptions[$name], $option);
157        } else {
158            $this->contextOptions[$name] = $option;
159        }
160
161        if (isset($this->contextOptions['http']) && isset($this->contextOptions['http']['protocol_version'])) {
162            $this->httpVersion = $this->contextOptions['http']['protocol_version'];
163        }
164
165        return $this;
166    }
167
168    /**
169     * Add a context parameter
170     *
171     * @param  string $name
172     * @param  mixed  $param
173     * @return Stream
174     */
175    public function addContextParam(string $name, mixed $param): Stream
176    {
177        $this->contextParams[$name] = $param;
178        return $this;
179    }
180
181    /**
182     * Set the context options
183     *
184     * @param  array $options
185     * @return Stream
186     */
187    public function setContextOptions(array $options): Stream
188    {
189        foreach ($options as $name => $option) {
190            $this->addContextOption($name, $option);
191        }
192        return $this;
193    }
194
195    /**
196     * Set the context parameters
197     *
198     * @param  array $params
199     * @return Stream
200     */
201    public function setContextParams(array $params): Stream
202    {
203        foreach ($params as $name => $param) {
204            $this->addContextParam($name, $param);
205        }
206        return $this;
207    }
208
209    /**
210     * Set the mode
211     *
212     * @param  string $mode
213     * @return Stream
214     */
215    public function setMode(string $mode): Stream
216    {
217        $this->mode = $mode;
218        return $this;
219    }
220
221    /**
222     * Set Stream context option to set verify peer (verifies the domain's SSL cert)
223     *
224     * @param  bool $verify
225     * @return Stream
226     */
227    public function setVerifyPeer(bool $verify = true): Stream
228    {
229        $this->addContextOption('ssl', ['verify_peer' => (bool)$verify]);
230        return $this;
231    }
232
233    /**
234     * Set Stream context option to set to allow self-signed certs (verify host)
235     *
236     * @param  bool $allow
237     * @return Stream
238     */
239    public function allowSelfSigned(bool $allow = true): Stream
240    {
241        $this->addContextOption('ssl', ['allow_self_signed' => (bool)$allow]);
242        if (!$allow) {
243            $this->addContextOption('ssl', ['verify_peer' => false]);
244        }
245        return $this;
246    }
247
248    /**
249     * Get the context resource
250     *
251     * @return mixed
252     */
253    public function getContext(): mixed
254    {
255        return $this->context;
256    }
257
258    /**
259     * Get the context options
260     *
261     * @return array
262     */
263    public function getContextOptions(): array
264    {
265        return $this->contextOptions;
266    }
267
268    /**
269     * Get a context option
270     *
271     * @param  string $name
272     * @return mixed
273     */
274    public function getContextOption(string $name): mixed
275    {
276        return $this->contextOptions[$name] ?? null;
277    }
278
279    /**
280     * Determine if a context option has been set
281     *
282     * @param  string $name
283     * @return bool
284     */
285    public function hasContextOption(string $name): bool
286    {
287        return (isset($this->contextOptions[$name]));
288    }
289
290    /**
291     * Get the context parameters
292     *
293     * @return array
294     */
295    public function getContextParams(): array
296    {
297        return $this->contextParams;
298    }
299
300    /**
301     * Get a context parameter
302     *
303     * @param  string $name
304     * @return mixed
305     */
306    public function getContextParam(string $name): mixed
307    {
308        return $this->contextParams[$name] ?? null;
309    }
310
311    /**
312     * Determine if a context parameter has been set
313     *
314     * @param  string $name
315     * @return bool
316     */
317    public function hasContextParam(string $name): bool
318    {
319        return (isset($this->contextParams[$name]));
320    }
321
322    /**
323     * Get the mode
324     *
325     * @return string
326     */
327    public function getMode(): string
328    {
329        return $this->mode;
330    }
331
332    /**
333     * Check if Stream is set to verify peer
334     *
335     * @return bool
336     */
337    public function isVerifyPeer(): bool
338    {
339        return (isset($this->contextOptions['ssl']) && isset($this->contextOptions['ssl']['verify_peer']) &&
340            ($this->contextOptions['ssl']['verify_peer'] == true));
341    }
342
343    /**
344     * Check if Stream is set to allow self-signed certs
345     *
346     * @return bool
347     */
348    public function isAllowSelfSigned(): bool
349    {
350        return (isset($this->contextOptions['ssl']['allow_self_signed']) && $this->contextOptions['ssl']['allow_self_signed'] == true);
351    }
352
353    /**
354     * Method to prepare the handler
355     *
356     * @param  Request $request
357     * @param  ?Auth    $auth
358     * @param  bool     $clear
359     * @throws \Pop\Http\Exception
360     * @return Stream
361     */
362    public function prepare(Request $request, ?Auth $auth = null, bool $clear = true): Stream
363    {
364        $this->request = $request;
365
366        $this->setMethod($request->getMethod());
367
368        // Clear headers for a fresh request based on the headers in the request object,
369        // else fall back to pre-defined headers in the stream context
370        if (($clear) && isset($this->contextOptions['http']['header'])) {
371            $this->contextOptions['http']['header'] = null;
372        }
373
374        // Add auth header
375        if ($auth !== null) {
376            $request->addHeader($auth->createAuthHeader());
377        }
378
379        // Prepare data and data headers
380        if (($request->hasData()) && (!$request->getData()->isPrepared())) {
381            $request->prepareData();
382        }
383
384        $headers = $this->collectRequestHeaders($request);
385        if (!empty($headers)) {
386            if (!empty($this->contextOptions['http']['header'])) {
387                $this->contextOptions['http']['header'] .= "\r\n" . implode("\r\n", $headers) . "\r\n";
388            } else {
389                $this->contextOptions['http']['header'] = implode("\r\n", $headers) . "\r\n";
390            }
391        }
392
393        ['queryString' => $queryString, 'body' => $body] = $this->resolveRequestBody($request);
394
395        if (is_array($body)) {
396            // Stream can't use curl's native multipart array - render it to a string,
397            // reusing the boundary already declared in the Content-Type header (set by
398            // prepareData() above) so the header and the rendered body agree.
399            $boundary    = null;
400            $contentType = $request->getHeaderValueAsString('Content-Type');
401            if ($contentType !== null) {
402                $boundary = Parser::parseMediaType($contentType)['params']['boundary'] ?? null;
403            }
404
405            $this->contextOptions['http']['content'] = \Pop\Http\Body\Multipart::build(
406                $request->getData()->getData(), $boundary
407            )->getContent();
408        } else if ($body !== null) {
409            $this->contextOptions['http']['content'] = $body;
410        // A reused handler must not carry a previous request's body forward - a stale 'content'
411        // would be sent as the body of a subsequent body-less request. Gated on $clear for the
412        // same reason the header handling above is: with $clear = false the caller is explicitly
413        // asking to fall back to whatever was pre-defined in the stream context.
414        } else if ($clear) {
415            unset($this->contextOptions['http']['content']);
416        }
417
418        if ((count($this->contextOptions) > 0) || (count($this->contextParams) > 0)) {
419            $this->createContext();
420        }
421
422        $this->uri = $request->getUriAsString();
423        if (!empty($queryString) && !str_contains($this->uri, '?')) {
424            $this->uri .= $queryString;
425        }
426
427        return $this;
428    }
429
430    /**
431     * Method to send the request
432     *
433     * @throws Exception
434     * @return Response
435     */
436    public function send(): Response
437    {
438        if ($this->uri === null) {
439            throw new Exception('Error: The request handler has not been prepared.', 0, null, 0, $this->request);
440        }
441        $http_response_header = null;
442
443        $this->resource = ($this->context !== null) ?
444            @fopen($this->uri, $this->mode, false, $this->context) : @fopen($this->uri, $this->mode);
445
446        $this->uri = null;
447        $this->httpResponseHeaders = $http_response_header;
448
449        return $this->parseResponse();
450    }
451
452    /**
453     * Parse the response
454     *
455     * @return Response
456     */
457    public function parseResponse(): Response
458    {
459        $response = new Response();
460        $headers  = [];
461        $body     = null;
462
463        if ($this->resource !== false) {
464            $meta    = stream_get_meta_data($this->resource);
465            $headers = $meta['wrapper_data'];
466            $body    = stream_get_contents($this->resource);
467        } else if ($this->httpResponseHeaders !== null) {
468            $headers = $this->httpResponseHeaders;
469        }
470
471        // Parse response headers
472        $parsedHeaders = Parser::parseHeaders($headers);
473        if (!empty($parsedHeaders['version'])) {
474            $response->setVersion($parsedHeaders['version']);
475        }
476        if (!empty($parsedHeaders['code'])) {
477            $response->setCode((int)$parsedHeaders['code']);
478        }
479        if (!empty($parsedHeaders['message'])) {
480            $response->setMessage($parsedHeaders['message']);
481        }
482        if (!empty($parsedHeaders['headers'])) {
483            $response->addHeaders($parsedHeaders['headers']);
484        }
485        if ($body !== null) {
486            $response->setBody($body);
487        }
488
489        if ($response->hasHeader('Content-Encoding')) {
490            $response->decodeBodyContent();
491        }
492
493        return $response;
494    }
495
496    /**
497     * Method to reset the handler
498     *
499     * @return Stream
500     */
501    public function reset(): Stream
502    {
503        $this->context             = null;
504        $this->contextOptions      = [];
505        $this->contextParams       = [];
506        $this->httpResponseHeaders = null;
507        return $this;
508    }
509
510    /**
511     * Close the handler connection
512     *
513     * @return void
514     */
515    public function disconnect(): void
516    {
517        $this->uri                 = null;
518        $this->resource            = null;
519        $this->context             = null;
520        $this->contextOptions      = [];
521        $this->contextParams       = [];
522        $this->httpResponseHeaders = null;
523    }
524
525}