Passed
Push — master ( 8a04f9...43fdea )
by Terry
01:45
created

src/Psr7/Message.php (6 issues)

1
<?php
2
/*
3
 * This file is part of the Shieldon package.
4
 *
5
 * (c) Terry L. <[email protected]>
6
 *
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 */
10
11
declare(strict_types=1);
12
13
namespace Shieldon\Psr7;
14
15
use Psr\Http\Message\MessageInterface;
16
use Psr\Http\Message\StreamInterface;
17
use Shieldon\Psr7\Stream;
18
use InvalidArgumentException;
19
20
use function array_map;
21
use function array_merge;
22
use function count;
23
use function fopen;
24
use function fseek;
25
use function fwrite;
26
use function gettype;
27
use function implode;
28
use function is_array;
29
use function is_bool;
30
use function is_float;
31
use function is_integer;
32
use function is_scalar;
33
use function is_string;
34
use function preg_match;
35
use function preg_match_all;
36
use function sprintf;
37
use function strtolower;
38
use function trim;
39
40
use const PREG_SET_ORDER;
41
42
/**
43
 * HTTP messages consist of requests from a client to a server and responses
44
 * from a server to a client.
45
 */
46
class Message implements MessageInterface
47
{
48
    /**
49
     * A HTTP protocol version number.
50
     *
51
     * @var string
52
     */
53
    protected $protocolVersion = '1.1';
54
55
    /**
56
     * An instance with the specified message body.
57
     *
58
     * @var StreamInterface
59
     */
60
    protected $body;
61
62
    /**
63
     * An array of mapping header information with `string => array[]` format.
64
     *
65
     * @var array
66
     */
67
    protected $headers = [];
68
69
70
    /**
71
     * A map of header name for lower case and original case.
72
     * In `lower => original` format.
73
     *
74
     * @var array
75
     */
76
    protected $headerNameMapping = [];
77
78
    /**
79
     * Valid HTTP version numbers.
80
     *
81
     * @var array
82
     */
83
    protected $validProtocolVersions = [
84
        '1.1',
85
        '2.0',
86
        '3.0',
87
    ];
88
89
    /**
90
     * {@inheritdoc}
91
     */
92
    public function getProtocolVersion(): string
93
    {
94
        return $this->protocolVersion;
95
    }
96
97
    /**
98
     * {@inheritdoc}
99
     */
100
    public function withProtocolVersion($version)
101
    {
102
        $clone = clone $this;
103
        $clone->protocolVersion = $version;
104
105
        return $clone;
106
    }
107
108
    /**
109
     * {@inheritdoc}
110
     */
111
    public function getHeaders(): array
112
    {
113
        $headers = $this->headers;
114
115
        foreach ($this->headerNameMapping as $origin) {
116
            $name = strtolower($origin);
117
            if (isset($headers[$name])) {
118
                $value = $headers[$name];
119
                unset($headers[$name]);
120
                $headers[$origin] = $value;
121
            }
122
        }
123
124
        return $headers;
125
    }
126
127
    /**
128
     * {@inheritdoc}
129
     */
130
    public function hasHeader($name): bool
131
    {
132
        $name = strtolower($name);
133
134
        return isset($this->headers[$name]);
135
    }
136
137
    /**
138
     * {@inheritdoc}
139
     */
140
    public function getHeader($name): array
141
    {
142
        $name = strtolower($name);
143
144
        if (isset($this->headers[$name])) {
145
            return $this->headers[$name];
146
        }
147
148
        return [];
149
    }
150
151
    /**
152
     * {@inheritdoc}
153
     */
154
    public function getHeaderLine($name): string
155
    {
156
        return implode(', ', $this->getHeader($name));
157
    }
158
159
    /**
160
     * {@inheritdoc}
161
     */
162
    public function withHeader($name, $value)
163
    {
164
        $origName = $name;
165
166
        $name = $this->normalizeHeaderFieldName($name);
167
        $value = $this->normalizeHeaderFieldValue($value);
168
169
        $clone = clone $this;
170
        $clone->headers[$name] = $value;
171
        $clone->headerNameMapping[$name] = $origName;
172
173
        return $clone;
174
    }
175
176
    /**
177
     * {@inheritdoc}
178
     */
179
    public function withAddedHeader($name, $value)
180
    {
181
        $origName = $name;
182
183
        $name = $this->normalizeHeaderFieldName($name);
184
        $value = $this->normalizeHeaderFieldValue($value);
185
186
        $clone = clone $this;
187
        $clone->headerNameMapping[$name] = $origName;
188
189
        if (isset($clone->headers[$name])) {
190
            $clone->headers[$name] = array_merge($this->headers[$name], $value);
0 ignored issues
show
It seems like $value can also be of type false; however, parameter $array2 of array_merge() does only seem to accept array|null, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

190
            $clone->headers[$name] = array_merge($this->headers[$name], /** @scrutinizer ignore-type */ $value);
Loading history...
191
        } else {
192
            $clone->headers[$name] = $value;
193
        }
194
195
        return $clone;
196
    }
197
198
    /**
199
     * {@inheritdoc}
200
     */
201
    public function withoutHeader($name)
202
    {
203
        $origName = $name;
0 ignored issues
show
The assignment to $origName is dead and can be removed.
Loading history...
204
        $name = strtolower($name);
205
206
        $clone = clone $this;
207
        unset($clone->headers[$name]);
208
        unset($clone->headerNameMapping[$name]);
209
210
        return $clone;
211
    }
212
213
    /**
214
     * {@inheritdoc}
215
     */
216
    public function getBody(): StreamInterface
217
    {
218
        return $this->body;
219
    }
220
221
    /**
222
     * {@inheritdoc}
223
     */
224
    public function withBody(StreamInterface $body)
225
    {
226
        $clone = clone $this;
227
        $clone->body = $body;
228
229
        return $clone;
230
    }
231
232
    /*
233
    |--------------------------------------------------------------------------
234
    | Non PSR-7 Methods.
235
    |--------------------------------------------------------------------------
236
    */
237
238
    /**
239
     * Set headers to property $headers.
240
     *
241
     * @param array $headers A collection of header information.
242
     *
243
     * @return void
244
     */
245
    protected function setHeaders(array $headers): void
246
    {
247
        $arr = [];
248
        $origArr = [];
249
250
        foreach ($headers as $name => $value) {
251
            $origName = $name;
252
            $name = $this->normalizeHeaderFieldName($name);
253
            $value = $this->normalizeHeaderFieldValue($value);
254
  
255
            $arr[$name] = $value;
256
            $origArr[$origName] = $value;
257
        }
258
259
        $this->headers = $arr;
260
        $this->origHeaders = $origArr;
0 ignored issues
show
Bug Best Practice introduced by
The property origHeaders does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
261
    }
262
263
    /**
264
     * Set the request body.
265
     *
266
     * This method only provides two types of input, string and StreamInterface
267
     *
268
     * String          - As a simplest way to initialize a stream resource.
269
     * StreamInterface - If you would like to use stream resource its mode is
270
     *                   not "r+", you should create a Stream instance by 
271
     *                   yourself.
272
     *
273
     * @param string|StreamInterface $body Request body
274
     *
275
     * @return void
276
     */
277
    protected function setBody($body): void
278
    {
279
        if ($body instanceof StreamInterface) {
280
            $this->body = $body;
281
282
        } elseif (is_string($body)) {
283
            $resource = fopen('php://temp', 'r+');
284
285
            if ($body !== '') {
286
                fwrite($resource, $body);
287
                fseek($resource, 0);
288
            }
289
290
            $this->body = new Stream($resource);
291
        }
292
    }
293
294
    /**
295
     * Parse raw header text into an associated array.
296
     *
297
     * @param string $message Raw header text.
298
     *
299
     * @return array
300
     */
301
    public static function parseRawHeader(string $message): array
302
    {
303
        preg_match_all('/^([^:\n]*): ?(.*)$/m', $message, $headers, PREG_SET_ORDER);
304
305
        $num = count($headers);
306
307
        if ($num > 1) {
308
            $headers = array_merge(...array_map(function($line) {
309
                $name = trim($line[1]);
310
                $field = trim($line[2]);
311
                return [$name => $field];
312
            }, $headers));
313
314
            return $headers;
315
316
        } elseif ($num === 1) {
317
            $name = trim($headers[0][1]);
318
            $field = trim($headers[0][2]);
319
            return [$name => $field];
320
        }
321
322
        return [];
323
    }
324
325
    /**
326
     * Normalize the header field name.
327
     *
328
     * @param string $name
329
     *
330
     * @return string
331
     */
332
    protected function normalizeHeaderFieldName($name): string
333
    {
334
        $this->assertHeaderFieldName($name);
335
        
336
        return trim(strtolower($name));
337
    }
338
339
    /**
340
     * Normalize the header field value.
341
     *
342
     * @param mixed $value
343
     * 
344
     * @return mixed
345
     */
346
    protected function normalizeHeaderFieldValue($value)
347
    {
348
        $this->assertHeaderFieldValue($value);
349
350
        $result = false;
351
352
        if (is_string($value)) {
353
            $result = [trim($value)];
354
355
        } elseif (is_array($value)) {
356
            foreach ($value as $v) {
357
                if (is_string($v)) {
358
                    $value[] = trim($v);
359
                }
360
            }
361
            $result = $value;
362
363
        } elseif (is_float($value) || is_integer($value)) {
364
            $result = [(string) $value];
365
        }
366
367
        return $result;
368
    }
369
370
    /**
371
     * Throw exception if the header is not compatible with RFC 7230.
372
     * 
373
     * @param string $name The header name.
374
     *
375
     * @return void
376
     * 
377
     * @throws InvalidArgumentException
378
     */
379
    protected function assertHeaderFieldName($name): void
380
    {
381
        if (!is_string($name)) {
0 ignored issues
show
The condition is_string($name) is always true.
Loading history...
382
            throw new InvalidArgumentException(
383
                sprintf(
384
                    'Header field name must be a string, but "%s" given.',
385
                    gettype($name)
386
                )
387
            );
388
        }
389
        // see https://tools.ietf.org/html/rfc7230#section-3.2.6
390
        // alpha  => a-zA-Z
391
        // digit  => 0-9
392
        // others => !#$%&\'*+-.^_`|~
393
394
        if (!preg_match('/^[a-zA-Z0-9!#$%&\'*+-.^_`|~]+$/', $name)) {
395
            throw new InvalidArgumentException(
396
                sprintf(
397
                    '"%s" is not valid header name, it must be an RFC 7230 compatible string.',
398
                    $name
399
                )
400
            );
401
        }
402
    }
403
404
    /**
405
     * Throw exception if the header is not compatible with RFC 7230.
406
     * 
407
     * @param array|null $value The header value.
408
     *
409
     * @return void
410
     * 
411
     * @throws InvalidArgumentException
412
     */
413
    protected function assertHeaderFieldValue($value = null): void
414
    {
415
        if (is_scalar($value) && !is_bool($value)) {
0 ignored issues
show
The condition is_scalar($value) is always false.
Loading history...
416
            $value = [(string) $value];
417
        }
418
419
        if (empty($value)) {
420
            throw new InvalidArgumentException(
421
                'Empty array is not allowed.'
422
            );
423
        }
424
425
        if (is_array($value)) {
0 ignored issues
show
The condition is_array($value) is always true.
Loading history...
426
            foreach ($value as $item) {
427
428
                if ($item === '') {
429
                    return;
430
                }
431
432
                if (!is_scalar($item) || is_bool($item)) {
433
                    throw new InvalidArgumentException(
434
                        sprintf(
435
                            'The header values only accept string and number, but "%s" provided.',
436
                            gettype($item)
437
                        )
438
                    );
439
                }
440
441
                // https://www.rfc-editor.org/rfc/rfc7230.txt (page.25)
442
                // field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ]
443
                // field-vchar   = VCHAR / obs-text
444
                // obs-text      = %x80-FF
445
                // SP            = space
446
                // HTAB          = horizontal tab
447
                // VCHAR         = any visible [USASCII] character. (x21-x7e)
448
                // %x80-FF       = character range outside ASCII.
449
450
                // I THINK THAT obs-text SHOULD N0T BE USED.
451
                // OR EVEN I CAN PASS CHINESE CHARACTERS, THAT'S WEIRD.
452
                if (!preg_match('/^[ \t\x21-\x7e]+$/', $item)) {
453
                    throw new InvalidArgumentException(
454
                        sprintf(
455
                            '"%s" is not valid header value, it must contains visible ASCII characters only.',
456
                            $item
457
                        )
458
                    );
459
                }
460
            }
461
        } else {
462
            throw new InvalidArgumentException(
463
                sprintf(
464
                    'The header field value only accepts string and array, but "%s" provided.',
465
                    gettype($value)
466
                )
467
            );
468
        }
469
    }
470
471
    /**
472
     * Check out whether a protocol version number is supported.
473
     *
474
     * @param string $version HTTP protocol version.
475
     * 
476
     * @return void
477
     * 
478
     * @throws InvalidArgumentException
479
     */
480
    protected function assertProtocolVersion(string $version): void
481
    {
482
        if (!in_array($version, $this->validProtocolVersions)) {
483
            throw new InvalidArgumentException(
484
                sprintf(
485
                    'Unsupported HTTP protocol version number. "%s" provided.',
486
                    $version
487
                )
488
            );
489
        }
490
    }
491
}
492