Passed
Push — master ( e41252...69cd60 )
by Evgeniy
01:59
created

src/MessageTrait.php (8 issues)

1
<?php
2
3
declare(strict_types=1);
4
5
namespace HttpSoft\Message;
6
7
use InvalidArgumentException;
8
use Psr\Http\Message\MessageInterface;
9
use Psr\Http\Message\StreamInterface;
10
11
use function array_merge;
12
use function gettype;
13
use function get_class;
14
use function implode;
15
use function in_array;
16
use function is_array;
17
use function is_numeric;
18
use function is_object;
19
use function is_resource;
20
use function is_string;
21
use function preg_match;
22
use function sprintf;
23
use function strtolower;
24
25
/**
26
 * Trait implementing the methods defined in `Psr\Http\Message\MessageInterface`.
27
 *
28
 * @see https://github.com/php-fig/http-message/tree/master/src/MessageInterface.php
29
 */
30
trait MessageTrait
31
{
32
    /**
33
     * Map of all registered original headers, as `original header name` => `array of values`.
34
     *
35
     * @var string[][]
36
     */
37
    private array $headers = [];
38
39
    /**
40
     * Map of all header names, as `normalized header name` => `original header name` at registration.
41
     *
42
     * @var string[]
43
     */
44
    private array $headerNames = [];
45
46
    /**
47
     * @var string
48
     */
49
    private string $protocol = '1.1';
50
51
    /**
52
     * @var StreamInterface
53
     */
54
    private StreamInterface $stream;
55
56
    /**
57
     * Retrieves the HTTP protocol version as a string.
58
     *
59
     * The string MUST contain only the HTTP version number (e.g., "1.1", "1.0").
60
     *
61
     * @return string HTTP protocol version.
62
     */
63 7
    public function getProtocolVersion(): string
64
    {
65 7
        return $this->protocol;
66
    }
67
68
    /**
69
     * Return an instance with the specified HTTP protocol version.
70
     *
71
     * The version string MUST contain only the HTTP version number (e.g.,
72
     * "1.1", "1.0").
73
     *
74
     * This method MUST be implemented in such a way as to retain the
75
     * immutability of the message, and MUST return an instance that has the
76
     * new protocol version.
77
     *
78
     * @param string $version HTTP protocol version
79
     * @return static
80
     * @throws InvalidArgumentException for invalid HTTP protocol version.
81
     */
82 11
    public function withProtocolVersion($version): MessageInterface
83
    {
84 11
        if ($version === $this->protocol) {
85 1
            return $this;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this returns the type HttpSoft\Message\MessageTrait which is incompatible with the type-hinted return Psr\Http\Message\MessageInterface.
Loading history...
86
        }
87
88 10
        $this->validateProtocolVersion($version);
89 1
        $new = clone $this;
90 1
        $new->protocol = $version;
91 1
        return $new;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $new returns the type HttpSoft\Message\MessageTrait which is incompatible with the type-hinted return Psr\Http\Message\MessageInterface.
Loading history...
92
    }
93
94
    /**
95
     * Retrieves all message header values.
96
     *
97
     * The keys represent the header name as it will be sent over the wire, and
98
     * each value is an array of strings associated with the header.
99
     *
100
     *     // Represent the headers as a string
101
     *     foreach ($message->getHeaders() as $name => $values) {
102
     *         echo $name . ": " . implode(", ", $values);
103
     *     }
104
     *
105
     *     // Emit headers iteratively:
106
     *     foreach ($message->getHeaders() as $name => $values) {
107
     *         foreach ($values as $value) {
108
     *             header(sprintf('%s: %s', $name, $value), false);
109
     *         }
110
     *     }
111
     *
112
     * While header names are not case-sensitive, getHeaders() will preserve the
113
     * exact case in which headers were originally specified.
114
     *
115
     * @return string[][] Returns an associative array of the message's headers. Each
116
     *     key MUST be a header name, and each value MUST be an array of strings
117
     *     for that header.
118
     */
119 10
    public function getHeaders(): array
120
    {
121 10
        return $this->headers;
122
    }
123
124
    /**
125
     * Checks if a header exists by the given case-insensitive name.
126
     *
127
     * @param string $name Case-insensitive header field name.
128
     * @return bool Returns true if any header names match the given header
129
     *     name using a case-insensitive string comparison. Returns false if
130
     *     no matching header name is found in the message.
131
     * @psalm-suppress RedundantConditionGivenDocblockType
132
     */
133 82
    public function hasHeader($name): bool
134
    {
135 82
        return (is_string($name) && isset($this->headerNames[$this->normalizeHeaderName($name)]));
136
    }
137
138
    /**
139
     * Retrieves a message header value by the given case-insensitive name.
140
     *
141
     * This method returns an array of all the header values of the given
142
     * case-insensitive header name.
143
     *
144
     * If the header does not appear in the message, this method MUST return an
145
     * empty array.
146
     *
147
     * @param string $name Case-insensitive header field name.
148
     * @return string[] An array of string values as provided for the given
149
     *    header. If the header does not appear in the message, this method MUST
150
     *    return an empty array.
151
     */
152 6
    public function getHeader($name): array
153
    {
154 6
        if (!$this->hasHeader($name)) {
155 2
            return [];
156
        }
157
158 4
        return $this->headers[$this->headerNames[$this->normalizeHeaderName($name)]];
159
    }
160
161
    /**
162
     * Retrieves a comma-separated string of the values for a single header.
163
     *
164
     * This method returns all of the header values of the given
165
     * case-insensitive header name as a string concatenated together using
166
     * a comma.
167
     *
168
     * NOTE: Not all header values may be appropriately represented using
169
     * comma concatenation. For such headers, use getHeader() instead
170
     * and supply your own delimiter when concatenating.
171
     *
172
     * If the header does not appear in the message, this method MUST return
173
     * an empty string.
174
     *
175
     * @param string $name Case-insensitive header field name.
176
     * @return string A string of values as provided for the given header
177
     *    concatenated together using a comma. If the header does not appear in
178
     *    the message, this method MUST return an empty string.
179
     */
180 2
    public function getHeaderLine($name): string
181
    {
182 2
        if (!$value = $this->getHeader($name)) {
183 1
            return '';
184
        }
185
186 1
        return implode(',', $value);
187
    }
188
189
    /**
190
     * Return an instance with the provided value replacing the specified header.
191
     *
192
     * While header names are case-insensitive, the casing of the header will
193
     * be preserved by this function, and returned from getHeaders().
194
     *
195
     * This method MUST be implemented in such a way as to retain the
196
     * immutability of the message, and MUST return an instance that has the
197
     * new and/or updated header and value.
198
     *
199
     * @param string $name Case-insensitive header field name.
200
     * @param string|string[] $value Header value(s).
201
     * @return static
202
     * @throws InvalidArgumentException for invalid header names or values.
203
     * @psalm-suppress MixedPropertyTypeCoercion
204
     */
205 26
    public function withHeader($name, $value): MessageInterface
206
    {
207 26
        $this->validateHeaderName($name);
208 15
        $normalized = $this->normalizeHeaderName($name);
209 15
        $new = clone $this;
210
211 15
        if ($new->hasHeader($name)) {
212
            unset($new->headers[$new->headerNames[$normalized]]);
213
        }
214
215 15
        $value = $this->normalizeHeaderValue($value);
216 15
        $this->validateHeaderValue($value);
217
218 6
        $new->headerNames[$normalized] = $name;
219 6
        $new->headers[$name] = $value;
220 6
        return $new;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $new returns the type HttpSoft\Message\MessageTrait which is incompatible with the type-hinted return Psr\Http\Message\MessageInterface.
Loading history...
221
    }
222
223
    /**
224
     * Return an instance with the specified header appended with the given value.
225
     *
226
     * Existing values for the specified header will be maintained. The new
227
     * value(s) will be appended to the existing list. If the header did not
228
     * exist previously, it will be added.
229
     *
230
     * This method MUST be implemented in such a way as to retain the
231
     * immutability of the message, and MUST return an instance that has the
232
     * new header and/or value.
233
     *
234
     * @param string $name Case-insensitive header field name to add.
235
     * @param string|string[] $value Header value(s).
236
     * @return static
237
     * @throws InvalidArgumentException for invalid header names or values.
238
     * @psalm-suppress MixedPropertyTypeCoercion
239
     */
240 1
    public function withAddedHeader($name, $value): MessageInterface
241
    {
242 1
        $this->validateHeaderName($name);
243
244 1
        if (!$this->hasHeader($name)) {
245 1
            return $this->withHeader($name, $value);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->withHeader($name, $value) returns the type Psr\Http\Message\MessageInterface which is incompatible with the documented return type HttpSoft\Message\MessageTrait.
Loading history...
246
        }
247
248 1
        $header = $this->headerNames[$this->normalizeHeaderName($name)];
249 1
        $value = $this->normalizeHeaderValue($value);
250 1
        $this->validateHeaderValue($value);
251
252 1
        $new = clone $this;
253 1
        $new->headers[$header] = array_merge($this->headers[$header], $value);
254 1
        return $new;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $new returns the type HttpSoft\Message\MessageTrait which is incompatible with the type-hinted return Psr\Http\Message\MessageInterface.
Loading history...
255
    }
256
257
    /**
258
     * Return an instance without the specified header.
259
     *
260
     * Header resolution MUST be done without case-sensitivity.
261
     *
262
     * This method MUST be implemented in such a way as to retain the
263
     * immutability of the message, and MUST return an instance that removes
264
     * the named header.
265
     *
266
     * @param string $name Case-insensitive header field name to remove.
267
     * @return static
268
     */
269 1
    public function withoutHeader($name): MessageInterface
270
    {
271 1
        if (!$this->hasHeader($name)) {
272
            return $this;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this returns the type HttpSoft\Message\MessageTrait which is incompatible with the type-hinted return Psr\Http\Message\MessageInterface.
Loading history...
273
        }
274
275 1
        $normalized = $this->normalizeHeaderName($name);
276 1
        $new = clone $this;
277 1
        unset($new->headers[$this->headerNames[$normalized]], $new->headerNames[$normalized]);
278 1
        return $new;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $new returns the type HttpSoft\Message\MessageTrait which is incompatible with the type-hinted return Psr\Http\Message\MessageInterface.
Loading history...
279
    }
280
281
    /**
282
     * Gets the body of the message.
283
     *
284
     * @return StreamInterface Returns the body as a stream.
285
     */
286 8
    public function getBody(): StreamInterface
287
    {
288 8
        return $this->stream;
289
    }
290
291
    /**
292
     * Return an instance with the specified message body.
293
     *
294
     * The body MUST be a StreamInterface object.
295
     *
296
     * This method MUST be implemented in such a way as to retain the
297
     * immutability of the message, and MUST return a new instance that has the
298
     * new body stream.
299
     *
300
     * @param StreamInterface $body Body.
301
     * @return static
302
     * @throws InvalidArgumentException When the body is not valid.
303
     */
304 1
    public function withBody(StreamInterface $body): MessageInterface
305
    {
306 1
        $new = clone $this;
307 1
        $new->stream = $body;
308 1
        return $new;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $new returns the type HttpSoft\Message\MessageTrait which is incompatible with the type-hinted return Psr\Http\Message\MessageInterface.
Loading history...
309
    }
310
311
    /**
312
     * @param StreamInterface|string|resource $stream
313
     * @param string $mode
314
     * @psalm-suppress RedundantConditionGivenDocblockType
315
     */
316 142
    private function registerStream($stream, string $mode = 'wb+'): void
317
    {
318 142
        if ($stream instanceof StreamInterface) {
319 1
            $this->stream = $stream;
320 1
            return;
321
        }
322
323 142
        if (is_string($stream) || is_resource($stream)) {
324 142
            $this->stream = new Stream($stream, $mode);
325 142
            return;
326
        }
327
328 7
        throw new InvalidArgumentException(sprintf(
329
            'Stream must be a `Psr\Http\Message\StreamInterface` implementation'
330
            . ' or a string stream resource identifier or an actual stream resource; received `%s`.',
331 7
            (is_object($stream) ? get_class($stream) : gettype($stream))
332
        ));
333
    }
334
335
    /**
336
     * @param array<string, string|int|float> $originalHeaders
337
     * @throws InvalidArgumentException When the header name or header value is not valid.
338
     * @psalm-suppress MixedPropertyTypeCoercion
339
     */
340 142
    private function registerHeaders(array $originalHeaders = []): void
341
    {
342 142
        $this->headers = [];
343 142
        $this->headerNames = [];
344
345 142
        foreach ($originalHeaders as $name => $value) {
346 1
            $value = $this->normalizeHeaderValue($value);
347 1
            $this->validateHeaderValue($value);
348 1
            $this->validateHeaderName($name);
349 1
            $this->headers[$name] = $value;
350 1
            $this->headerNames[$this->normalizeHeaderName($name)] = $name;
351
        }
352 142
    }
353
354
    /**
355
     * @param string $protocol
356
     * @throws InvalidArgumentException for invalid HTTP protocol version.
357
     */
358 142
    private function registerProtocolVersion(string $protocol): void
359
    {
360 142
        if (!empty($protocol) && $protocol !== $this->protocol) {
361 1
            $this->validateProtocolVersion($protocol);
362 1
            $this->protocol = $protocol;
363
        }
364 142
    }
365
366
    /**
367
     * @param string $name
368
     * @return string
369
     */
370 83
    private function normalizeHeaderName(string $name): string
371
    {
372 83
        return strtolower($name);
373
    }
374
375
    /**
376
     * @param mixed $value
377
     * @return array
378
     */
379 16
    private function normalizeHeaderValue($value): array
380
    {
381 16
        return is_array($value) ? $value : [$value];
382
    }
383
384
    /**
385
     * @param mixed $name
386
     * @throws InvalidArgumentException for invalid header name.
387
     */
388 27
    private function validateHeaderName($name): void
389
    {
390 27
        if (!is_string($name) || !preg_match('/^[a-zA-Z0-9\'`#$%&*+.^_|~!-]+$/', $name)) {
391 11
            throw new InvalidArgumentException(sprintf(
392
                '`%s` is not valid header name',
393 11
                (is_object($name) ? get_class($name) : (is_string($name) ? $name : gettype($name)))
394
            ));
395
        }
396 16
    }
397
398
    /**
399
     * @param mixed $value
400
     * @throws InvalidArgumentException for invalid header value.
401
     * @psalm-suppress MixedAssignment
402
     */
403 16
    private function validateHeaderValue($value): void
404
    {
405 16
        if (!is_array($value) || empty($value)) {
406 1
            throw new InvalidArgumentException('Invalid header value: must be an array and must not be empty.');
407
        }
408
409 15
        foreach ($value as $item) {
410
            if (
411 15
                (!is_string($item) && !is_numeric($item))
412 15
                || preg_match('/[^\x09\x0a\x0d\x20-\x7E\x80-\xFE]/', (string) $item)
413 15
                || preg_match("/(?:(?:(?<!\r)\n)|(?:\r(?!\n))|(?:\r\n(?![ \t])))/", (string) $item)
414
            ) {
415 8
                throw new InvalidArgumentException(sprintf(
416
                    '"%s" is not valid header value',
417 8
                    (is_object($item) ? get_class($item) : (is_string($item) ? $item : gettype($item)))
418
                ));
419
            }
420
        }
421 7
    }
422
423
    /**
424
     * @param mixed $protocol
425
     * @throws InvalidArgumentException for invalid HTTP protocol version.
426
     */
427 11
    private function validateProtocolVersion($protocol): void
428
    {
429 11
        if (!is_string($protocol) || empty($protocol)) {
430 8
            throw new InvalidArgumentException('HTTP protocol version must be an string and must not be empty.');
431
        }
432
433 3
        $supportedProtocolVersions = ['1.0', '1.1', '2.0', '2'];
434
435 3
        if (!in_array($protocol, $supportedProtocolVersions, true)) {
436 1
            throw new InvalidArgumentException(sprintf(
437
                'Unsupported HTTP protocol version `%s` provided. Supported (%s) in string types.',
438 1
                $protocol,
439 1
                implode(', ', $supportedProtocolVersions)
440
            ));
441
        }
442 2
    }
443
}
444