Message::assertHeaderFieldName()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 20
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 3

Importance

Changes 0
Metric Value
eloc 10
c 0
b 0
f 0
dl 0
loc 20
ccs 13
cts 13
cp 1
rs 9.9332
cc 3
nc 3
nop 1
crap 3
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
     * A map of header name for lower case and original case.
71
     * In `lower => original` format.
72
     *
73
     * @var array
74
     */
75
    protected $headerNameMapping = [];
76
77
    /**
78
     * Valid HTTP version numbers.
79
     *
80
     * @var array
81
     */
82
    protected $validProtocolVersions = [
83
        '1.0',
84
        '1.1',
85
        '2.0',
86
        '3.0',
87
    ];
88
89
    /**
90
     * {@inheritdoc}
91
     */
92 2
    public function getProtocolVersion(): string
93
    {
94 2
        return $this->protocolVersion;
95
    }
96
97
    /**
98
     * {@inheritdoc}
99
     */
100 1
    public function withProtocolVersion($version): MessageInterface
101
    {
102 1
        $clone = clone $this;
103 1
        $clone->protocolVersion = $version;
104
105 1
        return $clone;
106
    }
107
108
    /**
109
     * {@inheritdoc}
110
     */
111 4
    public function getHeaders(): array
112
    {
113 4
        $headers = $this->headers;
114
115 4
        foreach ($this->headerNameMapping as $origin) {
116 3
            $name = strtolower($origin);
117 3
            if (isset($headers[$name])) {
118 3
                $value = $headers[$name];
119 3
                unset($headers[$name]);
120 3
                $headers[$origin] = $value;
121
            }
122
        }
123
124 4
        return $headers;
125
    }
126
127
    /**
128
     * {@inheritdoc}
129
     */
130 3
    public function hasHeader($name): bool
131
    {
132 3
        $name = strtolower($name);
133
134 3
        return isset($this->headers[$name]);
135
    }
136
137
    /**
138
     * {@inheritdoc}
139
     */
140 15
    public function getHeader($name): array
141
    {
142 15
        $name = strtolower($name);
143
144 15
        if (isset($this->headers[$name])) {
145 9
            return $this->headers[$name];
146
        }
147
148 8
        return [];
149
    }
150
151
    /**
152
     * {@inheritdoc}
153
     */
154 14
    public function getHeaderLine($name): string
155
    {
156 14
        return implode(', ', $this->getHeader($name));
157
    }
158
159
    /**
160
     * {@inheritdoc}
161
     */
162 10
    public function withHeader($name, $value): MessageInterface
163
    {
164 10
        $origName = $name;
165
166 10
        $name = $this->normalizeHeaderFieldName($name);
167 8
        $value = $this->normalizeHeaderFieldValue($value);
168
169 3
        $clone = clone $this;
170 3
        $clone->headers[$name] = $value;
171 3
        $clone->headerNameMapping[$name] = $origName;
172
173 3
        return $clone;
174
    }
175
176
    /**
177
     * {@inheritdoc}
178
     */
179 2
    public function withAddedHeader($name, $value): MessageInterface
180
    {
181 2
        $origName = $name;
182
183 2
        $name = $this->normalizeHeaderFieldName($name);
184 2
        $value = $this->normalizeHeaderFieldValue($value);
185
186 2
        $clone = clone $this;
187 2
        $clone->headerNameMapping[$name] = $origName;
188
189 2
        if (isset($clone->headers[$name])) {
190 2
            $clone->headers[$name] = array_merge($this->headers[$name], $value);
0 ignored issues
show
Bug introduced by
It seems like $value can also be of type false; however, parameter $arrays of array_merge() does only seem to accept array, 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 2
            $clone->headers[$name] = $value;
193
        }
194
195 2
        return $clone;
196
    }
197
198
    /**
199
     * {@inheritdoc}
200
     */
201 1
    public function withoutHeader($name): MessageInterface
202
    {
203 1
        $origName = $name;
0 ignored issues
show
Unused Code introduced by
The assignment to $origName is dead and can be removed.
Loading history...
204 1
        $name = strtolower($name);
205
206 1
        $clone = clone $this;
207 1
        unset($clone->headers[$name]);
208 1
        unset($clone->headerNameMapping[$name]);
209
210 1
        return $clone;
211
    }
212
213
    /**
214
     * {@inheritdoc}
215
     */
216 5
    public function getBody(): StreamInterface
217
    {
218 5
        return $this->body;
219
    }
220
221
    /**
222
     * {@inheritdoc}
223
     */
224 1
    public function withBody(StreamInterface $body): MessageInterface
225
    {
226 1
        $clone = clone $this;
227 1
        $clone->body = $body;
228
229 1
        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 29
    protected function setHeaders(array $headers): void
246
    {
247 29
        $arr = [];
248 29
        $origArr = [];
249
250 29
        foreach ($headers as $name => $value) {
251 12
            $origName = $name;
252 12
            $name = $this->normalizeHeaderFieldName($name);
253 12
            $value = $this->normalizeHeaderFieldValue($value);
254
  
255 12
            $arr[$name] = $value;
256 12
            $origArr[$name] = $origName;
257
        }
258
259 29
        $this->headers = $arr;
260 29
        $this->headerNameMapping = $origArr;
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 26
    protected function setBody($body): void
278
    {
279 26
        if ($body instanceof StreamInterface) {
280 8
            $this->body = $body;
281
282 22
        } elseif (is_string($body)) {
0 ignored issues
show
introduced by
The condition is_string($body) is always true.
Loading history...
283 22
            $resource = fopen('php://temp', 'r+');
284
285 22
            if ($body !== '') {
286 1
                fwrite($resource, $body);
287 1
                fseek($resource, 0);
288
            }
289
290 22
            $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 1
    public static function parseRawHeader(string $message): array
302
    {
303 1
        preg_match_all('/^([^:\n]*): ?(.*)$/m', $message, $headers, PREG_SET_ORDER);
304
305 1
        $num = count($headers);
306
307 1
        if ($num > 1) {
308 1
            $headers = array_merge(...array_map(function($line) {
309 1
                $name = trim($line[1]);
310 1
                $field = trim($line[2]);
311 1
                return [$name => $field];
312 1
            }, $headers));
313
314 1
            return $headers;
315
316 1
        } elseif ($num === 1) {
317 1
            $name = trim($headers[0][1]);
318 1
            $field = trim($headers[0][2]);
319 1
            return [$name => $field];
320
        }
321
322 1
        return [];
323
    }
324
325
    /**
326
     * Normalize the header field name.
327
     *
328
     * @param string $name
329
     *
330
     * @return string
331
     */
332 20
    protected function normalizeHeaderFieldName($name): string
333
    {
334 20
        $this->assertHeaderFieldName($name);
335
        
336 18
        return trim(strtolower($name));
337
    }
338
339
    /**
340
     * Normalize the header field value.
341
     *
342
     * @param mixed $value
343
     * 
344
     * @return mixed
345
     */
346 18
    protected function normalizeHeaderFieldValue($value)
347
    {
348 18
        $this->assertHeaderFieldValue($value);
349
350 13
        $result = false;
351
352 13
        if (is_string($value)) {
353 12
            $result = [trim($value)];
354
355 3
        } elseif (is_array($value)) {
356 1
            foreach ($value as $v) {
357 1
                if (is_string($v)) {
358 1
                    $value[] = trim($v);
359
                }
360
            }
361 1
            $result = $value;
362
363 2
        } elseif (is_float($value) || is_integer($value)) {
364 2
            $result = [(string) $value];
365
        }
366
367 13
        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 20
    protected function assertHeaderFieldName($name): void
380
    {
381 20
        if (!is_string($name)) {
0 ignored issues
show
introduced by
The condition is_string($name) is always true.
Loading history...
382 1
            throw new InvalidArgumentException(
383 1
                sprintf(
384 1
                    'Header field name must be a string, but "%s" given.',
385 1
                    gettype($name)
386 1
                )
387 1
            );
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 19
        if (!preg_match('/^[a-zA-Z0-9!#$%&\'*+-.^_`|~]+$/', $name)) {
395 1
            throw new InvalidArgumentException(
396 1
                sprintf(
397 1
                    '"%s" is not valid header name, it must be an RFC 7230 compatible string.',
398 1
                    $name
399 1
                )
400 1
            );
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 18
    protected function assertHeaderFieldValue($value = null): void
414
    {
415 18
        if (is_scalar($value) && !is_bool($value)) {
0 ignored issues
show
introduced by
The condition is_scalar($value) is always false.
Loading history...
416 14
            $value = [(string) $value];
417
        }
418
419 18
        if (empty($value)) {
420 2
            throw new InvalidArgumentException(
421 2
                'Empty array is not allowed.'
422 2
            );
423
        }
424
425 16
        if (is_array($value)) {
0 ignored issues
show
introduced by
The condition is_array($value) is always true.
Loading history...
426 15
            foreach ($value as $item) {
427
428 15
                if ($item === '') {
429 1
                    return;
430
                }
431
432 15
                if (!is_scalar($item) || is_bool($item)) {
433 1
                    throw new InvalidArgumentException(
434 1
                        sprintf(
435 1
                            'The header values only accept string and number, but "%s" provided.',
436 1
                            gettype($item)
437 1
                        )
438 1
                    );
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 15
                if (!preg_match('/^[ \t\x21-\x7e]+$/', $item)) {
453 1
                    throw new InvalidArgumentException(
454 1
                        sprintf(
455 1
                            '"%s" is not valid header value, it must contains visible ASCII characters only.',
456 1
                            $item
457 1
                        )
458 1
                    );
459
                }
460
            }
461
        } else {
462 1
            throw new InvalidArgumentException(
463 1
                sprintf(
464 1
                    'The header field value only accepts string and array, but "%s" provided.',
465 1
                    gettype($value)
466 1
                )
467 1
            );
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 28
    protected function assertProtocolVersion(string $version): void
481
    {
482 28
        if (!in_array($version, $this->validProtocolVersions)) {
483 1
            throw new InvalidArgumentException(
484 1
                sprintf(
485 1
                    'Unsupported HTTP protocol version number. "%s" provided.',
486 1
                    $version
487 1
                )
488 1
            );
489
        }
490
    }
491
}
492