Passed
Push — master ( df82e6...21de27 )
by Evgeniy
01:40
created

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