• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

voku / httpful / 25187302842

30 Apr 2026 08:18PM UTC coverage: 90.48% (+0.6%) from 89.902%
25187302842

Pull #26

github

web-flow
Merge ae9ff2536 into 836887e5c
Pull Request #26: [+]: PHP 8.0+ rework

366 of 403 new or added lines in 7 files covered. (90.82%)

47 existing lines in 1 file now uncovered.

2547 of 2815 relevant lines covered (90.48%)

49.02 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.83
/src/Httpful/Response.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Httpful;
6

7
use Httpful\Exception\ResponseException;
8
use Psr\Http\Message\RequestInterface;
9
use Psr\Http\Message\ResponseInterface;
10
use Psr\Http\Message\StreamInterface;
11
use voku\helper\UTF8;
12

13
class Response implements ResponseInterface
14
{
15
    /**
16
     * @var StreamInterface
17
     */
18
    private $body;
19

20
    /**
21
     * @var mixed|null
22
     */
23
    private $raw_body;
24

25
    /**
26
     * @var Headers
27
     */
28
    private $headers;
29

30
    /**
31
     * @var mixed|null
32
     */
33
    private $raw_headers;
34

35
    /**
36
     * @var RequestInterface|null
37
     */
38
    private $request;
39

40
    /**
41
     * @var int
42
     */
43
    private $code;
44

45
    /**
46
     * @var string
47
     */
48
    private $reason;
49

50
    /**
51
     * @var string
52
     */
53
    private $content_type = '';
54

55
    /**
56
     * Parent / Generic type (e.g. xml for application/vnd.github.message+xml)
57
     *
58
     * @var string
59
     */
60
    private $parent_type = '';
61

62
    /**
63
     * @var string
64
     */
65
    private $charset = '';
66

67
    /**
68
     * @var array<string, mixed>
69
     */
70
    private $meta_data;
71

72
    /**
73
     * @var bool
74
     */
75
    private $is_mime_vendor_specific = false;
76

77
    /**
78
     * @var bool
79
     */
80
    private $is_mime_personal = false;
81

82
    /**
83
     * @param StreamInterface|string|null                 $body
84
     * @param array<string, string|string[]>|string|null $headers
85
     * @param RequestInterface|null                       $request
86
     * @param array<string, mixed>                       $meta_data
87
     *                                               <p>e.g. [protocol_version] = '1.1'</p>
88
     */
89
    public function __construct(
90
        $body = null,
91
        $headers = null,
92
        ?RequestInterface $request = null,
93
        array $meta_data = []
94
    ) {
95
        $bodyWasStream = $body instanceof StreamInterface;
129✔
96
        if (!$bodyWasStream) {
129✔
97
            $this->raw_body = $body;
128✔
98
        }
99

100
        $body = Stream::createNotNull($body);
129✔
101

102
        $this->request = $request;
129✔
103
        $this->raw_headers = $headers;
129✔
104
        $this->meta_data = $meta_data;
129✔
105

106
        if (!isset($this->meta_data['protocol_version'])) {
129✔
107
            $this->meta_data['protocol_version'] = '1.1';
99✔
108
        }
109

110
        if (
111
            \is_string($headers)
129✔
112
            &&
113
            $headers !== ''
129✔
114
        ) {
115
            $this->code = $this->_getResponseCodeFromHeaderString($headers);
83✔
116
            $this->reason = Http::reason($this->code);
83✔
117
            $this->headers = Headers::fromString($headers);
83✔
118
        } elseif (
119
            \is_array($headers)
46✔
120
            &&
121
            \count($headers) > 0
46✔
122
        ) {
123
            $this->code = 200;
9✔
124
            $this->reason = Http::reason($this->code);
9✔
125
            $this->headers = new Headers($headers);
9✔
126
        } else {
127
            $this->code = 200;
37✔
128
            $this->reason = Http::reason($this->code);
37✔
129
            $this->headers = new Headers();
37✔
130
        }
131

132
        $this->_interpretHeaders();
129✔
133

134
        $preserveOriginalBodyStream = $bodyWasStream
129✔
135
            && $this->request === null
129✔
136
            && (
129✔
137
                $this->raw_headers === null
129✔
138
                || $this->raw_headers === ''
129✔
139
                || $this->raw_headers === []
129✔
140
            );
129✔
141

142
        $bodyParsed = $preserveOriginalBodyStream ? $body : $this->_parse($body);
129✔
143
        $this->body = Stream::createNotNull($bodyParsed);
129✔
144
        $this->raw_body = $bodyParsed;
129✔
145
    }
146

147
    /**
148
     * @return void
149
     */
150
    public function __clone()
151
    {
152
        $this->headers = clone $this->headers;
44✔
153
    }
154

155
    /**
156
     * @return string
157
     */
158
    public function __toString()
159
    {
160
        if (
161
            $this->body->getSize() > 0
15✔
162
            &&
163
            !(
15✔
164
                $this->raw_body
15✔
165
                &&
15✔
166
                UTF8::is_serialized((string) $this->body)
15✔
167
            )
15✔
168
        ) {
169
            return (string) $this->body;
10✔
170
        }
171

172
        if (\is_string($this->raw_body)) {
5✔
173
            return (string) $this->raw_body;
1✔
174
        }
175

176
        return (string) \json_encode($this->raw_body);
4✔
177
    }
178

179
    /**
180
     * @param string $headers
181
     *
182
     * @throws ResponseException if we are unable to parse response code from HTTP response
183
     *
184
     * @return int
185
     *
186
     * @internal
187
     */
188
    public function _getResponseCodeFromHeaderString($headers): int
189
    {
190
        // If there was a redirect, we will get headers from one then one request,
191
        // but will are only interested in the last request.
192
        $headersTmp = \explode("\r\n\r\n", $headers);
83✔
193
        $headersTmpCount = \count($headersTmp);
83✔
194
        if ($headersTmpCount >= 2) {
83✔
195
            $headers = $headersTmp[$headersTmpCount - 2];
67✔
196
        }
197

198
        $end = \strpos($headers, "\r\n");
83✔
199
        if ($end === false) {
83✔
200
            $end = \strlen($headers);
19✔
201
        }
202

203
        $parts = \explode(' ', \substr($headers, 0, $end));
83✔
204

205
        if (
206
            \count($parts) < 2
83✔
207
            ||
208
            !\is_numeric($parts[1])
83✔
209
        ) {
210
            throw new ResponseException('Unable to parse response code from HTTP response due to malformed response: "' . \print_r($headers, true) . '"');
1✔
211
        }
212

213
        return (int) $parts[1];
83✔
214
    }
215

216
    /**
217
     * @return StreamInterface
218
     */
219
    public function getBody(): StreamInterface
220
    {
221
        return $this->body;
16✔
222
    }
223

224
    /**
225
     * Retrieves a message header value by the given case-insensitive name.
226
     *
227
     * This method returns an array of all the header values of the given
228
     * case-insensitive header name.
229
     *
230
     * If the header does not appear in the message, this method MUST return an
231
     * empty array.
232
     *
233
     * @param string $name case-insensitive header field name
234
     *
235
     * @return string[] An array of string values as provided for the given
236
     *                  header. If the header does not appear in the message, this method MUST
237
     *                  return an empty array.
238
     */
239
    public function getHeader($name): array
240
    {
241
        if ($this->headers->offsetExists($name)) {
18✔
242
            /** @var mixed $value */
243
            $value = $this->headers->offsetGet($name);
18✔
244

245
            if (!\is_array($value)) {
18✔
246
                return [\trim((string) $value, " \t")];
1✔
247
            }
248

249
            foreach ($value as $keyInner => $valueInner) {
17✔
250
                $value[$keyInner] = \trim($valueInner, " \t");
17✔
251
            }
252

253
            return $value;
17✔
254
        }
255

256
        return [];
2✔
257
    }
258

259
    /**
260
     * Retrieves a comma-separated string of the values for a single header.
261
     *
262
     * This method returns all of the header values of the given
263
     * case-insensitive header name as a string concatenated together using
264
     * a comma.
265
     *
266
     * NOTE: Not all header values may be appropriately represented using
267
     * comma concatenation. For such headers, use getHeader() instead
268
     * and supply your own delimiter when concatenating.
269
     *
270
     * If the header does not appear in the message, this method MUST return
271
     * an empty string.
272
     *
273
     * @param string $name case-insensitive header field name
274
     *
275
     * @return string A string of values as provided for the given header
276
     *                concatenated together using a comma. If the header does not appear in
277
     *                the message, this method MUST return an empty string.
278
     */
279
    public function getHeaderLine($name): string
280
    {
281
        return \implode(', ', $this->getHeader($name));
14✔
282
    }
283

284
    /**
285
     * @return array<string, string[]>
286
     */
287
    public function getHeaders(): array
288
    {
289
        return $this->headers->toArray();
19✔
290
    }
291

292
    /**
293
     * Retrieves the HTTP protocol version as a string.
294
     *
295
     * The string MUST contain only the HTTP version number (e.g., "1.1", "1.0").
296
     *
297
     * @return string HTTP protocol version
298
     */
299
    public function getProtocolVersion(): string
300
    {
301
        if (isset($this->meta_data['protocol_version'])) {
14✔
302
            return (string) $this->meta_data['protocol_version'];
13✔
303
        }
304

305
        return '1.1';
1✔
306
    }
307

308
    /**
309
     * Gets the response reason phrase associated with the status code.
310
     *
311
     * Because a reason phrase is not a required element in a response
312
     * status line, the reason phrase value MAY be null. Implementations MAY
313
     * choose to return the default RFC 7231 recommended reason phrase (or those
314
     * listed in the IANA HTTP Status Code Registry) for the response's
315
     * status code.
316
     *
317
     * @see http://tools.ietf.org/html/rfc7231#section-6
318
     * @see http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
319
     *
320
     * @return string reason phrase; must return an empty string if none present
321
     */
322
    public function getReasonPhrase(): string
323
    {
324
        return $this->reason;
10✔
325
    }
326

327
    /**
328
     * Gets the response status code.
329
     *
330
     * The status code is a 3-digit integer result code of the server's attempt
331
     * to understand and satisfy the request.
332
     *
333
     * @return int status code
334
     */
335
    public function getStatusCode(): int
336
    {
337
        return $this->code;
25✔
338
    }
339

340
    /**
341
     * Checks if a header exists by the given case-insensitive name.
342
     *
343
     * @param string $name case-insensitive header field name
344
     *
345
     * @return bool Returns true if any header names match the given header
346
     *              name using a case-insensitive string comparison. Returns false if
347
     *              no matching header name is found in the message.
348
     */
349
    public function hasHeader($name): bool
350
    {
351
        return $this->headers->offsetExists($name);
6✔
352
    }
353

354
    /**
355
     * Return an instance with the specified header appended with the given value.
356
     *
357
     * Existing values for the specified header will be maintained. The new
358
     * value(s) will be appended to the existing list. If the header did not
359
     * exist previously, it will be added.
360
     *
361
     * This method MUST be implemented in such a way as to retain the
362
     * immutability of the message, and MUST return an instance that has the
363
     * new header and/or value.
364
     *
365
     * @param string          $name  case-insensitive header field name to add
366
     * @param string|string[] $value header value(s)
367
     *
368
     * @throws \InvalidArgumentException for invalid header names or values
369
     *
370
     * @return static
371
     */
372
    public function withAddedHeader($name, $value): \Psr\Http\Message\MessageInterface
373
    {
374
        $new = clone $this;
4✔
375

376
        if (!\is_array($value)) {
4✔
377
            $value = [$value];
3✔
378
        }
379

380
        if ($new->headers->offsetExists($name)) {
4✔
381
            /** @var mixed $currentValues */
382
            $currentValues = $new->headers->offsetGet($name);
3✔
383
            if (!\is_array($currentValues)) {
3✔
NEW
384
                $currentValues = [$currentValues];
×
385
            }
386
            $new->headers->forceSet($name, \array_merge_recursive($currentValues, $value));
3✔
387
        } else {
388
            $new->headers->forceSet($name, $value);
1✔
389
        }
390

391
        return $new;
4✔
392
    }
393

394
    /**
395
     * Return an instance with the specified message body.
396
     *
397
     * The body MUST be a StreamInterface object.
398
     *
399
     * This method MUST be implemented in such a way as to retain the
400
     * immutability of the message, and MUST return a new instance that has the
401
     * new body stream.
402
     *
403
     * @param StreamInterface $body body
404
     *
405
     * @throws \InvalidArgumentException when the body is not valid
406
     *
407
     * @return static
408
     */
409
    public function withBody(StreamInterface $body): \Psr\Http\Message\MessageInterface
410
    {
411
        $new = clone $this;
5✔
412

413
        $new->body = $body;
5✔
414

415
        return $new;
5✔
416
    }
417

418
    /**
419
     * Return an instance with the provided value replacing the specified header.
420
     *
421
     * While header names are case-insensitive, the casing of the header will
422
     * be preserved by this function, and returned from getHeaders().
423
     *
424
     * This method MUST be implemented in such a way as to retain the
425
     * immutability of the message, and MUST return an instance that has the
426
     * new and/or updated header and value.
427
     *
428
     * @param string          $name  case-insensitive header field name
429
     * @param string|string[] $value header value(s)
430
     *
431
     * @throws \InvalidArgumentException for invalid header names or values
432
     *
433
     * @return static
434
     */
435
    public function withHeader($name, $value): \Psr\Http\Message\MessageInterface
436
    {
437
        $new = clone $this;
6✔
438

439
        if (!\is_array($value)) {
6✔
440
            $value = [$value];
5✔
441
        }
442

443
        $new->headers->forceSet($name, $value);
6✔
444

445
        return $new;
6✔
446
    }
447

448
    /**
449
     * Return an instance with the specified HTTP protocol version.
450
     *
451
     * The version string MUST contain only the HTTP version number (e.g.,
452
     * "1.1", "1.0").
453
     *
454
     * This method MUST be implemented in such a way as to retain the
455
     * immutability of the message, and MUST return an instance that has the
456
     * new protocol version.
457
     *
458
     * @param string $version HTTP protocol version
459
     *
460
     * @return static
461
     */
462
    public function withProtocolVersion($version): \Psr\Http\Message\MessageInterface
463
    {
464
        $new = clone $this;
4✔
465

466
        $new->meta_data['protocol_version'] = $version;
4✔
467

468
        return $new;
4✔
469
    }
470

471
    /**
472
     * Return an instance with the specified status code and, optionally, reason phrase.
473
     *
474
     * If no reason phrase is specified, implementations MAY choose to default
475
     * to the RFC 7231 or IANA recommended reason phrase for the response's
476
     * status code.
477
     *
478
     * This method MUST be implemented in such a way as to retain the
479
     * immutability of the message, and MUST return an instance that has the
480
     * updated status and reason phrase.
481
     *
482
     * @see http://tools.ietf.org/html/rfc7231#section-6
483
     * @see http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
484
     *
485
     * @param int    $code         the 3-digit integer result code to set
486
     * @param string $reasonPhrase the reason phrase to use with the
487
     *                             provided status code; if none is provided, implementations MAY
488
     *                             use the defaults as suggested in the HTTP specification
489
     *
490
     * @throws \InvalidArgumentException for invalid status code arguments
491
     *
492
     * @return static
493
     */
494
    public function withStatus($code, $reasonPhrase = null): ResponseInterface
495
    {
496
        $new = clone $this;
21✔
497

498
        $new->code = (int) $code;
21✔
499

500
        if (Http::responseCodeExists($new->code)) {
21✔
501
            $new->reason = Http::reason($new->code);
20✔
502
        } else {
503
            $new->reason = '';
3✔
504
        }
505

506
        if ($reasonPhrase !== null) {
21✔
507
            $new->reason = $reasonPhrase;
4✔
508
        }
509

510
        return $new;
21✔
511
    }
512

513
    /**
514
     * Return an instance without the specified header.
515
     *
516
     * Header resolution MUST be done without case-sensitivity.
517
     *
518
     * This method MUST be implemented in such a way as to retain the
519
     * immutability of the message, and MUST return an instance that removes
520
     * the named header.
521
     *
522
     * @param string $name case-insensitive header field name to remove
523
     *
524
     * @return static
525
     */
526
    public function withoutHeader($name): \Psr\Http\Message\MessageInterface
527
    {
528
        $new = clone $this;
4✔
529

530
        $new->headers->forceUnset($name);
4✔
531

532
        return $new;
4✔
533
    }
534

535
    /**
536
     * @return float
537
     */
538
    private function _getTransferFloat(string $key): float
539
    {
540
        $value = $this->getTransferInfoValue($key);
1✔
541

542
        return \is_numeric($value) ? (float) $value : 0.0;
1✔
543
    }
544

545
    /**
546
     * @return int
547
     */
548
    private function _getTransferInt(string $key): int
549
    {
550
        $value = $this->getTransferInfoValue($key);
1✔
551

552
        return \is_numeric($value) ? (int) $value : 0;
1✔
553
    }
554

555
    /**
556
     * @return string|null
557
     */
558
    private function _getTransferString(string $key): ?string
559
    {
560
        $value = $this->getTransferInfoValue($key);
2✔
561
        if (!\is_string($value) || $value === '') {
2✔
562
            return null;
1✔
563
        }
564

565
        return $value;
1✔
566
    }
567

568
    /**
569
     * @param mixed  $http_version
570
     * @param string $fallback
571
     *
572
     * @return string
573
     */
574
    private static function _normalizeTransferHttpVersion($http_version, string $fallback): string
575
    {
576
        if (!\is_int($http_version)) {
2✔
577
            if (\is_string($http_version) && $http_version !== '') {
1✔
578
                return $http_version;
1✔
579
            }
580

581
            return $fallback;
1✔
582
        }
583

584
        $map = [
1✔
585
            \CURL_HTTP_VERSION_NONE => $fallback,
1✔
586
            \CURL_HTTP_VERSION_1_0  => Http::HTTP_1_0,
1✔
587
            \CURL_HTTP_VERSION_1_1  => Http::HTTP_1_1,
1✔
588
            \CURL_HTTP_VERSION_2_0  => Http::HTTP_2_0,
1✔
589
        ];
1✔
590

591
        if (\defined('CURL_HTTP_VERSION_2TLS')) {
1✔
592
            $map[(int) \constant('CURL_HTTP_VERSION_2TLS')] = Http::HTTP_2_0;
1✔
593
        }
594

595
        if (\defined('CURL_HTTP_VERSION_2_PRIOR_KNOWLEDGE')) {
1✔
596
            $map[(int) \constant('CURL_HTTP_VERSION_2_PRIOR_KNOWLEDGE')] = Http::HTTP_2_0;
1✔
597
        }
598

599
        if (\defined('CURL_HTTP_VERSION_3')) {
1✔
600
            $map[(int) \constant('CURL_HTTP_VERSION_3')] = Http::HTTP_3;
1✔
601
        }
602

603
        if (\defined('CURL_HTTP_VERSION_3ONLY')) {
1✔
604
            $map[(int) \constant('CURL_HTTP_VERSION_3ONLY')] = Http::HTTP_3;
1✔
605
        }
606

607
        return $map[$http_version] ?? $fallback;
1✔
608
    }
609

610
    /**
611
     * @return string
612
     */
613
    public function getCharset(): string
614
    {
615
        return $this->charset;
2✔
616
    }
617

618
    /**
619
     * @return string
620
     */
621
    public function getContentType(): string
622
    {
623
        return $this->content_type;
5✔
624
    }
625

626
    /**
627
     * @return Headers
628
     */
629
    public function getHeadersObject(): Headers
630
    {
631
        return $this->headers;
2✔
632
    }
633

634
    /**
635
     * @return array<string, mixed>
636
     */
637
    public function getMetaData(): array
638
    {
639
        return $this->meta_data;
3✔
640
    }
641

642
    /**
643
     * @return array<string, mixed>
644
     */
645
    public function getTransferInfo(): array
646
    {
647
        return $this->getMetaData();
1✔
648
    }
649

650
    /**
651
     * @param mixed|null $fallback
652
     *
653
     * @return mixed|null
654
     */
655
    public function getTransferInfoValue(string $key, $fallback = null)
656
    {
657
        return $this->meta_data[$key] ?? $fallback;
2✔
658
    }
659

660
    /**
661
     * @return string|null
662
     */
663
    public function getEffectiveUrl(): ?string
664
    {
665
        return $this->_getTransferString('url');
1✔
666
    }
667

668
    /**
669
     * @return string|null
670
     */
671
    public function getPrimaryIp(): ?string
672
    {
673
        return $this->_getTransferString('primary_ip');
2✔
674
    }
675

676
    /**
677
     * @return string|null
678
     */
679
    public function getLocalIp(): ?string
680
    {
681
        return $this->_getTransferString('local_ip');
1✔
682
    }
683

684
    /**
685
     * @return int
686
     */
687
    public function getRedirectCount(): int
688
    {
689
        return $this->_getTransferInt('redirect_count');
1✔
690
    }
691

692
    /**
693
     * @return float
694
     */
695
    public function getTotalTime(): float
696
    {
697
        return $this->_getTransferFloat('total_time');
1✔
698
    }
699

700
    /**
701
     * @return float
702
     */
703
    public function getConnectTime(): float
704
    {
705
        return $this->_getTransferFloat('connect_time');
1✔
706
    }
707

708
    /**
709
     * @return float
710
     */
711
    public function getTlsHandshakeTime(): float
712
    {
713
        return $this->_getTransferFloat('appconnect_time');
1✔
714
    }
715

716
    /**
717
     * @return string
718
     */
719
    public function getTransferHttpVersion(): string
720
    {
721
        $http_version = $this->getTransferInfoValue('http_version');
2✔
722

723
        return self::_normalizeTransferHttpVersion($http_version, $this->getProtocolVersion());
2✔
724
    }
725

726
    /**
727
     * @return string
728
     */
729
    public function getParentType(): string
730
    {
731
        return $this->parent_type;
2✔
732
    }
733

734
    /**
735
     * @return mixed
736
     */
737
    public function getRawBody()
738
    {
739
        return $this->raw_body;
22✔
740
    }
741

742
    /**
743
     * @return string
744
     */
745
    public function getRawHeaders(): string
746
    {
747
        return $this->raw_headers;
2✔
748
    }
749

750
    public function hasBody(): bool
751
    {
752
        return $this->body->getSize()  > 0;
2✔
753
    }
754

755
    /**
756
     * Status Code Definitions.
757
     *
758
     * Informational 1xx
759
     * Successful    2xx
760
     * Redirection   3xx
761
     * Client Error  4xx
762
     * Server Error  5xx
763
     *
764
     * http://pretty-rfc.herokuapp.com/RFC2616#status.codes
765
     *
766
     * @return bool Did we receive a 4xx or 5xx?
767
     */
768
    public function hasErrors(): bool
769
    {
770
        return $this->code >= 400;
8✔
771
    }
772

773
    /**
774
     * @return bool Did we receive a 1xx Informational response?
775
     */
776
    public function isInformational(): bool
777
    {
778
        return $this->code >= 100 && $this->code < 200;
9✔
779
    }
780

781
    /**
782
     * @return bool Did we receive a 2xx Successful response?
783
     */
784
    public function isSuccess(): bool
785
    {
786
        return $this->code >= 200 && $this->code < 300;
17✔
787
    }
788

789
    /**
790
     * @return bool Did we receive a 3xx Redirection response?
791
     */
792
    public function isRedirect(): bool
793
    {
794
        return $this->code >= 300 && $this->code < 400;
10✔
795
    }
796

797
    /**
798
     * @return bool Did we receive a 4xx Client Error response?
799
     */
800
    public function isClientError(): bool
801
    {
802
        return $this->code >= 400 && $this->code < 500;
11✔
803
    }
804

805
    /**
806
     * @return bool Did we receive a 5xx Server Error response?
807
     */
808
    public function isServerError(): bool
809
    {
810
        return $this->code >= 500 && $this->code < 600;
9✔
811
    }
812

813
    /**
814
     * Returns a human-readable hint / explanation for the current HTTP status code.
815
     *
816
     * Useful when displaying or logging error information without having to
817
     * hard-code status-code ranges in calling code.
818
     *
819
     * @return string
820
     */
821
    public function getErrorMessage(): string
822
    {
823
        if ($this->isSuccess()) {
12✔
824
            return 'Request was successful (' . $this->code . ' ' . $this->reason . ').';
5✔
825
        }
826

827
        if ($this->isInformational()) {
7✔
828
            return 'Informational response (' . $this->code . ' ' . $this->reason . '): the server acknowledged the request.';
1✔
829
        }
830

831
        if ($this->isRedirect()) {
7✔
832
            return 'Redirect response (' . $this->code . ' ' . $this->reason . '): the resource has moved. Check the Location header.';
1✔
833
        }
834

835
        if ($this->isClientError()) {
6✔
836
            $hints = [
2✔
837
                400 => 'Bad Request: the server could not understand the request due to invalid syntax.',
2✔
838
                401 => 'Unauthorized: authentication is required and has failed or not been provided.',
2✔
839
                403 => 'Forbidden: you do not have permission to access this resource.',
2✔
840
                404 => 'Not Found: the requested resource could not be found on the server.',
2✔
841
                405 => 'Method Not Allowed: the HTTP method used is not supported for this endpoint.',
2✔
842
                408 => 'Request Timeout: the server timed out waiting for the request.',
2✔
843
                409 => 'Conflict: the request conflicts with the current state of the resource.',
2✔
844
                410 => 'Gone: the resource has been permanently removed.',
2✔
845
                422 => 'Unprocessable Entity: the request was well-formed but contains semantic errors.',
2✔
846
                429 => 'Too Many Requests: you have exceeded the rate limit. Try again later.',
2✔
847
            ];
2✔
848

849
            $hint = $hints[$this->code] ?? 'Client Error: the request could not be fulfilled due to a client-side problem. Check your request parameters, headers and authentication.';
2✔
850

851
            return $hint . ' (' . $this->code . ' ' . $this->reason . ')';
2✔
852
        }
853

854
        if ($this->isServerError()) {
4✔
855
            $hints = [
3✔
856
                500 => 'Internal Server Error: the server encountered an unexpected error. This is a server-side problem.',
3✔
857
                501 => 'Not Implemented: the server does not support the functionality required to fulfil the request.',
3✔
858
                502 => 'Bad Gateway: the server received an invalid response from an upstream server.',
3✔
859
                503 => 'Service Unavailable: the server is temporarily unable to handle the request (overloaded or under maintenance).',
3✔
860
                504 => 'Gateway Timeout: the upstream server did not respond in time.',
3✔
861
            ];
3✔
862

863
            $hint = $hints[$this->code] ?? 'Server Error: the server failed to fulfil the request. This is a server-side problem and is not caused by your request.';
3✔
864

865
            return $hint . ' (' . $this->code . ' ' . $this->reason . ')';
3✔
866
        }
867

868
        return 'Unknown response status (' . $this->code . ' ' . $this->reason . ').';
1✔
869
    }
870

871
    /**
872
     * Returns a human-readable debug summary that includes the original
873
     * request (method, full URL with query params, request headers, request
874
     * body) as well as the response (status, hint, response headers, response
875
     * body).  Useful for logging, debugging, or building descriptive exception
876
     * messages.
877
     *
878
     * @return string
879
     */
880
    public function debugInfo(): string
881
    {
882
        $lines = [];
5✔
883

884
        // ---- Request section (available when the response was produced by a Request) ----
885
        if ($this->request !== null) {
5✔
886
            $lines[] = '--- Request ---';
2✔
887
            $lines[] = $this->request->getMethod() . ' ' . (string) $this->request->getUri();
2✔
888

889
            foreach ($this->request->getHeaders() as $name => $values) {
2✔
890
                $lines[] = $name . ': ' . \implode(', ', (array) $values);
2✔
891
            }
892

893
            $requestBody = (string) $this->request->getBody();
2✔
894
            if ($requestBody !== '') {
2✔
895
                $lines[] = '';
2✔
896
                $lines[] = $requestBody;
2✔
897
            }
898

899
            $lines[] = '';
2✔
900
        }
901

902
        // ---- Response section ----
903
        $lines[] = '=== Response Debug Info ===';
5✔
904
        $lines[] = 'Status : ' . $this->code . ' ' . $this->reason;
5✔
905
        $lines[] = 'Hint   : ' . $this->getErrorMessage();
5✔
906
        $lines[] = '';
5✔
907
        $lines[] = '--- Response Headers ---';
5✔
908

909
        foreach ($this->headers->toArray() as $name => $values) {
5✔
910
            $lines[] = $name . ': ' . \implode(', ', (array) $values);
2✔
911
        }
912

913
        $lines[] = '';
5✔
914
        $lines[] = '--- Response Body ---';
5✔
915
        $lines[] = (string) $this->body;
5✔
916

917
        return \implode("\n", $lines);
5✔
918
    }
919

920
    /**
921
     * @return bool
922
     */
923
    public function isMimePersonal(): bool
924
    {
925
        return $this->is_mime_personal;
1✔
926
    }
927

928
    /**
929
     * @return bool
930
     */
931
    public function isMimeVendorSpecific(): bool
932
    {
933
        return $this->is_mime_vendor_specific;
2✔
934
    }
935

936
    /**
937
     * @param string[] $header
938
     *
939
     * @return static
940
     */
941
    public function withHeaders(array $header)
942
    {
943
        $new = clone $this;
2✔
944

945
        foreach ($header as $name => $value) {
2✔
946
            $new = $new->withHeader($name, $value);
2✔
947
        }
948

949
        return $new;
2✔
950
    }
951

952
    /**
953
     * After we've parse the headers, let's clean things
954
     * up a bit and treat some headers specially
955
     *
956
     * @return void
957
     */
958
    private function _interpretHeaders()
959
    {
960
        // Parse the Content-Type and charset
961
        /** @var mixed $content_type */
962
        $content_type = $this->headers['Content-Type'] ?? [];
129✔
963
        if (!\is_array($content_type)) {
129✔
NEW
964
            $content_type = [$content_type];
×
965
        }
966

967
        $content_type_parts = [];
129✔
968
        foreach ($content_type as $content_type_inner) {
129✔
969
            if (!\is_string($content_type_inner)) {
59✔
NEW
970
                continue;
×
971
            }
972

973
            $content_type_parts = \array_merge($content_type_parts, \explode(';', $content_type_inner));
59✔
974
        }
975

976
        $this->content_type = $content_type_parts[0] ?? '';
129✔
977
        foreach (\array_slice($content_type_parts, 1) as $part) {
129✔
978
            $part = \trim($part);
26✔
979
            if (\stripos($part, 'charset=') === 0) {
26✔
980
                $this->charset = \substr($part, 8);
26✔
981
                break;
26✔
982
            }
983
        }
984

985
        // fallback
986
        if (!$this->charset) {
129✔
987
            $this->charset = 'utf-8';
105✔
988
        }
989

990
        // check for vendor & personal type
991
        if (\strpos($this->content_type, '/') !== false) {
129✔
992
            /** @noinspection PhpUnusedLocalVariableInspection */
993
            list($type, $sub_type) = \explode('/', $this->content_type);
59✔
994
            $this->is_mime_vendor_specific = \strpos($sub_type, 'vnd.') === 0;
59✔
995
            $this->is_mime_personal = \strpos($sub_type, 'prs.') === 0;
59✔
996
        }
997

998
        $this->parent_type = $this->content_type;
129✔
999
        if (\strpos($this->content_type, '+') !== false) {
129✔
1000
            /** @noinspection PhpUnusedLocalVariableInspection */
1001
            list($vendor, $this->parent_type) = \explode('+', $this->content_type, 2);
4✔
1002
            $this->parent_type = Mime::getFullMime($this->parent_type);
4✔
1003
        }
1004
    }
1005

1006
    /**
1007
     * Parse the response into a clean data structure
1008
     * (most often an associative array) based on the expected
1009
     * Mime type.
1010
     *
1011
     * @param StreamInterface|null $body Http response body
1012
     *
1013
     * @return mixed the response parse accordingly
1014
     */
1015
    private function _parse($body)
1016
    {
1017
        // If the user decided to forgo the automatic smart parsing, short circuit.
1018
        if (
1019
            $this->request instanceof Request
128✔
1020
            &&
1021
            !$this->request->isAutoParse()
128✔
1022
        ) {
1023
            return $body;
1✔
1024
        }
1025

1026
        // If provided, use custom parsing callback.
1027
        if (
1028
            $this->request instanceof Request
128✔
1029
            &&
1030
            $this->request->hasParseCallback()
128✔
1031
        ) {
1032
            $parseCallback = $this->request->getParseCallback();
1✔
1033
            if ($parseCallback !== null) {
1✔
1034
                return $parseCallback($body);
1✔
1035
            }
1036
        }
1037

1038
        // Decide how to parse the body of the response in the following order:
1039
        //
1040
        //  1. If provided, use the mime type specifically set as part of the `Request`
1041
        //  2. If a MimeHandler is registered for the content type, use it
1042
        //  3. If provided, use the "parent type" of the mime type from the response
1043
        //  4. Default to the content-type provided in the response
1044
        if ($this->request instanceof Request) {
127✔
1045
            $parse_with = $this->request->getExpectedType();
81✔
1046
        }
1047

1048
        if (empty($parse_with)) {
127✔
1049
            if (Setup::hasParserRegistered($this->content_type)) {
46✔
1050
                $parse_with = $this->content_type;
1✔
1051
            } else {
1052
                $parse_with = $this->parent_type;
45✔
1053
            }
1054
        }
1055

1056
        return Setup::setupGlobalMimeType($parse_with)->parse((string) $body);
127✔
1057
    }
1058
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc