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

voku / httpful / 5623107679

pending completion
5623107679

push

github

voku
[+]: fix test for the new release

1596 of 2486 relevant lines covered (64.2%)

81.28 hits per line

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

92.81
/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
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|null           $headers
85
     * @param RequestInterface|null       $request
86
     * @param array                       $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
        if (!($body instanceof Stream)) {
296✔
96
            $this->raw_body = $body;
296✔
97
            $body = Stream::create($body);
296✔
98
        }
99

100
        $this->request = $request;
296✔
101
        $this->raw_headers = $headers;
296✔
102
        $this->meta_data = $meta_data;
296✔
103

104
        if (!isset($this->meta_data['protocol_version'])) {
296✔
105
            $this->meta_data['protocol_version'] = '1.1';
176✔
106
        }
107

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

130
        $this->_interpretHeaders();
296✔
131

132
        $bodyParsed = $this->_parse($body);
296✔
133
        $this->body = Stream::createNotNull($bodyParsed);
296✔
134
        $this->raw_body = $bodyParsed;
296✔
135
    }
136

137
    /**
138
     * @return void
139
     */
140
    public function __clone()
141
    {
142
        $this->headers = clone $this->headers;
92✔
143
    }
144

145
    /**
146
     * @return string
147
     */
148
    public function __toString()
149
    {
150
        if (
151
            $this->body->getSize() > 0
52✔
152
            &&
153
            !(
52✔
154
                $this->raw_body
52✔
155
                &&
52✔
156
                UTF8::is_serialized((string) $this->body)
52✔
157
            )
52✔
158
        ) {
159
            return (string) $this->body;
36✔
160
        }
161

162
        if (\is_string($this->raw_body)) {
16✔
163
            return (string) $this->raw_body;
×
164
        }
165

166
        return (string) \json_encode($this->raw_body);
16✔
167
    }
168

169
    /**
170
     * @param string $headers
171
     *
172
     * @throws ResponseException if we are unable to parse response code from HTTP response
173
     *
174
     * @return int
175
     *
176
     * @internal
177
     */
178
    public function _getResponseCodeFromHeaderString($headers): int
179
    {
180
        // If there was a redirect, we will get headers from one then one request,
181
        // but will are only interested in the last request.
182
        $headersTmp = \explode("\r\n\r\n", $headers);
184✔
183
        $headersTmpCount = \count($headersTmp);
184✔
184
        if ($headersTmpCount >= 2) {
184✔
185
            $headers = $headersTmp[$headersTmpCount - 2];
120✔
186
        }
187

188
        $end = \strpos($headers, "\r\n");
184✔
189
        if ($end === false) {
184✔
190
            $end = \strlen($headers);
×
191
        }
192

193
        $parts = \explode(' ', \substr($headers, 0, $end));
184✔
194

195
        if (
196
            \count($parts) < 2
184✔
197
            ||
198
            !\is_numeric($parts[1])
184✔
199
        ) {
200
            throw new ResponseException('Unable to parse response code from HTTP response due to malformed response: "' . \print_r($headers, true) . '"');
×
201
        }
202

203
        return (int) $parts[1];
184✔
204
    }
205

206
    /**
207
     * @return StreamInterface
208
     */
209
    public function getBody(): StreamInterface
210
    {
211
        return $this->body;
52✔
212
    }
213

214
    /**
215
     * Retrieves a message header value by the given case-insensitive name.
216
     *
217
     * This method returns an array of all the header values of the given
218
     * case-insensitive header name.
219
     *
220
     * If the header does not appear in the message, this method MUST return an
221
     * empty array.
222
     *
223
     * @param string $name case-insensitive header field name
224
     *
225
     * @return string[] An array of string values as provided for the given
226
     *                  header. If the header does not appear in the message, this method MUST
227
     *                  return an empty array.
228
     */
229
    public function getHeader($name): array
230
    {
231
        if ($this->headers->offsetExists($name)) {
60✔
232
            $value = $this->headers->offsetGet($name);
60✔
233

234
            if (!\is_array($value)) {
60✔
235
                return [\trim($value, " \t")];
×
236
            }
237

238
            foreach ($value as $keyInner => $valueInner) {
60✔
239
                $value[$keyInner] = \trim($valueInner, " \t");
60✔
240
            }
241

242
            return $value;
60✔
243
        }
244

245
        return [];
×
246
    }
247

248
    /**
249
     * Retrieves a comma-separated string of the values for a single header.
250
     *
251
     * This method returns all of the header values of the given
252
     * case-insensitive header name as a string concatenated together using
253
     * a comma.
254
     *
255
     * NOTE: Not all header values may be appropriately represented using
256
     * comma concatenation. For such headers, use getHeader() instead
257
     * and supply your own delimiter when concatenating.
258
     *
259
     * If the header does not appear in the message, this method MUST return
260
     * an empty string.
261
     *
262
     * @param string $name case-insensitive header field name
263
     *
264
     * @return string A string of values as provided for the given header
265
     *                concatenated together using a comma. If the header does not appear in
266
     *                the message, this method MUST return an empty string.
267
     */
268
    public function getHeaderLine($name): string
269
    {
270
        return \implode(', ', $this->getHeader($name));
56✔
271
    }
272

273
    /**
274
     * @return array
275
     */
276
    public function getHeaders(): array
277
    {
278
        return $this->headers->toArray();
72✔
279
    }
280

281
    /**
282
     * Retrieves the HTTP protocol version as a string.
283
     *
284
     * The string MUST contain only the HTTP version number (e.g., "1.1", "1.0").
285
     *
286
     * @return string HTTP protocol version
287
     */
288
    public function getProtocolVersion(): string
289
    {
290
        if (isset($this->meta_data['protocol_version'])) {
40✔
291
            return (string) $this->meta_data['protocol_version'];
40✔
292
        }
293

294
        return '1.1';
×
295
    }
296

297
    /**
298
     * Gets the response reason phrase associated with the status code.
299
     *
300
     * Because a reason phrase is not a required element in a response
301
     * status line, the reason phrase value MAY be null. Implementations MAY
302
     * choose to return the default RFC 7231 recommended reason phrase (or those
303
     * listed in the IANA HTTP Status Code Registry) for the response's
304
     * status code.
305
     *
306
     * @see http://tools.ietf.org/html/rfc7231#section-6
307
     * @see http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
308
     *
309
     * @return string reason phrase; must return an empty string if none present
310
     */
311
    public function getReasonPhrase(): string
312
    {
313
        return $this->reason;
32✔
314
    }
315

316
    /**
317
     * Gets the response status code.
318
     *
319
     * The status code is a 3-digit integer result code of the server's attempt
320
     * to understand and satisfy the request.
321
     *
322
     * @return int status code
323
     */
324
    public function getStatusCode(): int
325
    {
326
        return $this->code;
88✔
327
    }
328

329
    /**
330
     * Checks if a header exists by the given case-insensitive name.
331
     *
332
     * @param string $name case-insensitive header field name
333
     *
334
     * @return bool Returns true if any header names match the given header
335
     *              name using a case-insensitive string comparison. Returns false if
336
     *              no matching header name is found in the message.
337
     */
338
    public function hasHeader($name): bool
339
    {
340
        return $this->headers->offsetExists($name);
8✔
341
    }
342

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

365
        if (!\is_array($value)) {
12✔
366
            $value = [$value];
8✔
367
        }
368

369
        if ($new->headers->offsetExists($name)) {
12✔
370
            $new->headers->forceSet($name, \array_merge_recursive($new->headers->offsetGet($name), $value));
8✔
371
        } else {
372
            $new->headers->forceSet($name, $value);
4✔
373
        }
374

375
        return $new;
12✔
376
    }
377

378
    /**
379
     * Return an instance with the specified message body.
380
     *
381
     * The body MUST be a StreamInterface object.
382
     *
383
     * This method MUST be implemented in such a way as to retain the
384
     * immutability of the message, and MUST return a new instance that has the
385
     * new body stream.
386
     *
387
     * @param StreamInterface $body body
388
     *
389
     * @throws \InvalidArgumentException when the body is not valid
390
     *
391
     * @return static
392
     */
393
    public function withBody(StreamInterface $body): \Psr\Http\Message\MessageInterface
394
    {
395
        $new = clone $this;
12✔
396

397
        $new->body = $body;
12✔
398

399
        return $new;
12✔
400
    }
401

402
    /**
403
     * Return an instance with the provided value replacing the specified header.
404
     *
405
     * While header names are case-insensitive, the casing of the header will
406
     * be preserved by this function, and returned from getHeaders().
407
     *
408
     * This method MUST be implemented in such a way as to retain the
409
     * immutability of the message, and MUST return an instance that has the
410
     * new and/or updated header and value.
411
     *
412
     * @param string          $name  case-insensitive header field name
413
     * @param string|string[] $value header value(s)
414
     *
415
     * @throws \InvalidArgumentException for invalid header names or values
416
     *
417
     * @return static
418
     */
419
    public function withHeader($name, $value): \Psr\Http\Message\MessageInterface
420
    {
421
        $new = clone $this;
16✔
422

423
        if (!\is_array($value)) {
16✔
424
            $value = [$value];
12✔
425
        }
426

427
        $new->headers->forceSet($name, $value);
16✔
428

429
        return $new;
16✔
430
    }
431

432
    /**
433
     * Return an instance with the specified HTTP protocol version.
434
     *
435
     * The version string MUST contain only the HTTP version number (e.g.,
436
     * "1.1", "1.0").
437
     *
438
     * This method MUST be implemented in such a way as to retain the
439
     * immutability of the message, and MUST return an instance that has the
440
     * new protocol version.
441
     *
442
     * @param string $version HTTP protocol version
443
     *
444
     * @return static
445
     */
446
    public function withProtocolVersion($version): \Psr\Http\Message\MessageInterface
447
    {
448
        $new = clone $this;
12✔
449

450
        $new->meta_data['protocol_version'] = $version;
12✔
451

452
        return $new;
12✔
453
    }
454

455
    /**
456
     * Return an instance with the specified status code and, optionally, reason phrase.
457
     *
458
     * If no reason phrase is specified, implementations MAY choose to default
459
     * to the RFC 7231 or IANA recommended reason phrase for the response's
460
     * status code.
461
     *
462
     * This method MUST be implemented in such a way as to retain the
463
     * immutability of the message, and MUST return an instance that has the
464
     * updated status and reason phrase.
465
     *
466
     * @see http://tools.ietf.org/html/rfc7231#section-6
467
     * @see http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
468
     *
469
     * @param int    $code         the 3-digit integer result code to set
470
     * @param string $reasonPhrase the reason phrase to use with the
471
     *                             provided status code; if none is provided, implementations MAY
472
     *                             use the defaults as suggested in the HTTP specification
473
     *
474
     * @throws \InvalidArgumentException for invalid status code arguments
475
     *
476
     * @return static
477
     */
478
    public function withStatus($code, $reasonPhrase = null): ResponseInterface
479
    {
480
        $new = clone $this;
28✔
481

482
        $new->code = (int) $code;
28✔
483

484
        if (Http::responseCodeExists($new->code)) {
28✔
485
            $new->reason = Http::reason($new->code);
24✔
486
        } else {
487
            $new->reason = '';
8✔
488
        }
489

490
        if ($reasonPhrase !== null) {
28✔
491
            $new->reason = $reasonPhrase;
12✔
492
        }
493

494
        return $new;
28✔
495
    }
496

497
    /**
498
     * Return an instance without the specified header.
499
     *
500
     * Header resolution MUST be done without case-sensitivity.
501
     *
502
     * This method MUST be implemented in such a way as to retain the
503
     * immutability of the message, and MUST return an instance that removes
504
     * the named header.
505
     *
506
     * @param string $name case-insensitive header field name to remove
507
     *
508
     * @return static
509
     */
510
    public function withoutHeader($name): \Psr\Http\Message\MessageInterface
511
    {
512
        $new = clone $this;
12✔
513

514
        $new->headers->forceUnset($name);
12✔
515

516
        return $new;
12✔
517
    }
518

519
    /**
520
     * @return string
521
     */
522
    public function getCharset(): string
523
    {
524
        return $this->charset;
4✔
525
    }
526

527
    /**
528
     * @return string
529
     */
530
    public function getContentType(): string
531
    {
532
        return $this->content_type;
16✔
533
    }
534

535
    /**
536
     * @return Headers
537
     */
538
    public function getHeadersObject(): Headers
539
    {
540
        return $this->headers;
×
541
    }
542

543
    /**
544
     * @return array
545
     */
546
    public function getMetaData(): array
547
    {
548
        return $this->meta_data;
4✔
549
    }
550

551
    /**
552
     * @return string
553
     */
554
    public function getParentType(): string
555
    {
556
        return $this->parent_type;
4✔
557
    }
558

559
    /**
560
     * @return mixed
561
     */
562
    public function getRawBody()
563
    {
564
        return $this->raw_body;
72✔
565
    }
566

567
    /**
568
     * @return string
569
     */
570
    public function getRawHeaders(): string
571
    {
572
        return $this->raw_headers;
4✔
573
    }
574

575
    public function hasBody(): bool
576
    {
577
        return $this->body->getSize()  > 0;
4✔
578
    }
579

580
    /**
581
     * Status Code Definitions.
582
     *
583
     * Informational 1xx
584
     * Successful    2xx
585
     * Redirection   3xx
586
     * Client Error  4xx
587
     * Server Error  5xx
588
     *
589
     * http://pretty-rfc.herokuapp.com/RFC2616#status.codes
590
     *
591
     * @return bool Did we receive a 4xx or 5xx?
592
     */
593
    public function hasErrors(): bool
594
    {
595
        return $this->code >= 400;
4✔
596
    }
597

598
    /**
599
     * @return bool
600
     */
601
    public function isMimePersonal(): bool
602
    {
603
        return $this->is_mime_personal;
×
604
    }
605

606
    /**
607
     * @return bool
608
     */
609
    public function isMimeVendorSpecific(): bool
610
    {
611
        return $this->is_mime_vendor_specific;
4✔
612
    }
613

614
    /**
615
     * @param string[] $header
616
     *
617
     * @return static
618
     */
619
    public function withHeaders(array $header)
620
    {
621
        $new = clone $this;
4✔
622

623
        foreach ($header as $name => $value) {
4✔
624
            $new = $new->withHeader($name, $value);
4✔
625
        }
626

627
        return $new;
4✔
628
    }
629

630
    /**
631
     * After we've parse the headers, let's clean things
632
     * up a bit and treat some headers specially
633
     *
634
     * @return void
635
     */
636
    private function _interpretHeaders()
637
    {
638
        // Parse the Content-Type and charset
639
        $content_type = $this->headers['Content-Type'] ?? [];
296✔
640
        foreach ($content_type as $content_type_inner) {
296✔
641
            $content_type = \array_merge(\explode(';', $content_type_inner));
172✔
642
        }
643

644
        $this->content_type = $content_type[0] ?? '';
296✔
645
        if (
646
            \count($content_type) === 2
296✔
647
            &&
648
            \strpos($content_type[1], '=') !== false
296✔
649
        ) {
650
            /** @noinspection PhpUnusedLocalVariableInspection */
651
            list($nill, $this->charset) = \explode('=', $content_type[1]);
96✔
652
        }
653

654
        // fallback
655
        if (!$this->charset) {
296✔
656
            $this->charset = 'utf-8';
200✔
657
        }
658

659
        // check for vendor & personal type
660
        if (\strpos($this->content_type, '/') !== false) {
296✔
661
            /** @noinspection PhpUnusedLocalVariableInspection */
662
            list($type, $sub_type) = \explode('/', $this->content_type);
172✔
663
            $this->is_mime_vendor_specific = \strpos($sub_type, 'vnd.') === 0;
172✔
664
            $this->is_mime_personal = \strpos($sub_type, 'prs.') === 0;
172✔
665
        }
666

667
        $this->parent_type = $this->content_type;
296✔
668
        if (\strpos($this->content_type, '+') !== false) {
296✔
669
            /** @noinspection PhpUnusedLocalVariableInspection */
670
            list($vendor, $this->parent_type) = \explode('+', $this->content_type, 2);
8✔
671
            $this->parent_type = Mime::getFullMime($this->parent_type);
8✔
672
        }
673
    }
674

675
    /**
676
     * Parse the response into a clean data structure
677
     * (most often an associative array) based on the expected
678
     * Mime type.
679
     *
680
     * @param StreamInterface|null $body Http response body
681
     *
682
     * @return mixed the response parse accordingly
683
     */
684
    private function _parse($body)
685
    {
686
        // If the user decided to forgo the automatic smart parsing, short circuit.
687
        if (
688
            $this->request instanceof Request
296✔
689
            &&
690
            !$this->request->isAutoParse()
296✔
691
        ) {
692
            return $body;
4✔
693
        }
694

695
        // If provided, use custom parsing callback.
696
        if (
697
            $this->request instanceof Request
296✔
698
            &&
699
            $this->request->hasParseCallback()
296✔
700
        ) {
701
            return \call_user_func($this->request->getParseCallback(), $body);
×
702
        }
703

704
        // Decide how to parse the body of the response in the following order:
705
        //
706
        //  1. If provided, use the mime type specifically set as part of the `Request`
707
        //  2. If a MimeHandler is registered for the content type, use it
708
        //  3. If provided, use the "parent type" of the mime type from the response
709
        //  4. Default to the content-type provided in the response
710
        if ($this->request instanceof Request) {
296✔
711
            $parse_with = $this->request->getExpectedType();
184✔
712
        }
713

714
        if (empty($parse_with)) {
296✔
715
            if (Setup::hasParserRegistered($this->content_type)) {
112✔
716
                $parse_with = $this->content_type;
×
717
            } else {
718
                $parse_with = $this->parent_type;
112✔
719
            }
720
        }
721

722
        return Setup::setupGlobalMimeType($parse_with)->parse((string) $body);
296✔
723
    }
724
}
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

© 2025 Coveralls, Inc