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

src/MessageTrait.php (1 issue)

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;
86
        }
87
88 10
        $this->validateProtocolVersion($version);
89 1
        $new = clone $this;
90 1
        $new->protocol = $version;
91 1
        return $new;
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
     */
132 82
    public function hasHeader($name): bool
133
    {
134 82
        return (is_string($name) && isset($this->headerNames[$this->normalizeHeaderName($name)]));
135
    }
136
137
    /**
138
     * Retrieves a message header value by the given case-insensitive name.
139
     *
140
     * This method returns an array of all the header values of the given
141
     * case-insensitive header name.
142
     *
143
     * If the header does not appear in the message, this method MUST return an
144
     * empty array.
145
     *
146
     * @param string $name Case-insensitive header field name.
147
     * @return string[] An array of string values as provided for the given
148
     *    header. If the header does not appear in the message, this method MUST
149
     *    return an empty array.
150
     */
151 6
    public function getHeader($name): array
152
    {
153 6
        if (!$this->hasHeader($name)) {
154 2
            return [];
155
        }
156
157 4
        return $this->headers[$this->headerNames[$this->normalizeHeaderName($name)]];
158
    }
159
160
    /**
161
     * Retrieves a comma-separated string of the values for a single header.
162
     *
163
     * This method returns all of the header values of the given
164
     * case-insensitive header name as a string concatenated together using
165
     * a comma.
166
     *
167
     * NOTE: Not all header values may be appropriately represented using
168
     * comma concatenation. For such headers, use getHeader() instead
169
     * and supply your own delimiter when concatenating.
170
     *
171
     * If the header does not appear in the message, this method MUST return
172
     * an empty string.
173
     *
174
     * @param string $name Case-insensitive header field name.
175
     * @return string A string of values as provided for the given header
176
     *    concatenated together using a comma. If the header does not appear in
177
     *    the message, this method MUST return an empty string.
178
     */
179 2
    public function getHeaderLine($name): string
180
    {
181 2
        if (!$value = $this->getHeader($name)) {
182 1
            return '';
183
        }
184
185 1
        return implode(',', $value);
186
    }
187
188
    /**
189
     * Return an instance with the provided value replacing the specified header.
190
     *
191
     * While header names are case-insensitive, the casing of the header will
192
     * be preserved by this function, and returned from getHeaders().
193
     *
194
     * This method MUST be implemented in such a way as to retain the
195
     * immutability of the message, and MUST return an instance that has the
196
     * new and/or updated header and value.
197
     *
198
     * @param string $name Case-insensitive header field name.
199
     * @param string|string[] $value Header value(s).
200
     * @return static
201
     * @throws InvalidArgumentException for invalid header names or values.
202
     */
203 26
    public function withHeader($name, $value): MessageInterface
204
    {
205 26
        $this->validateHeaderName($name);
206 15
        $normalized = $this->normalizeHeaderName($name);
207 15
        $new = clone $this;
208
209 15
        if ($new->hasHeader($name)) {
210
            unset($new->headers[$new->headerNames[$normalized]]);
211
        }
212
213 15
        $value = $this->normalizeHeaderValue($value);
214 15
        $this->validateHeaderValue($value);
215
216 6
        $new->headerNames[$normalized] = $name;
217 6
        $new->headers[$name] = $value;
218 6
        return $new;
219
    }
220
221
    /**
222
     * Return an instance with the specified header appended with the given value.
223
     *
224
     * Existing values for the specified header will be maintained. The new
225
     * value(s) will be appended to the existing list. If the header did not
226
     * exist previously, it will be added.
227
     *
228
     * This method MUST be implemented in such a way as to retain the
229
     * immutability of the message, and MUST return an instance that has the
230
     * new header and/or value.
231
     *
232
     * @param string $name Case-insensitive header field name to add.
233
     * @param string|string[] $value Header value(s).
234
     * @return static
235
     * @throws InvalidArgumentException for invalid header names or values.
236
     */
237 1
    public function withAddedHeader($name, $value): MessageInterface
238
    {
239 1
        $this->validateHeaderName($name);
240
241 1
        if (!$this->hasHeader($name)) {
242 1
            return $this->withHeader($name, $value);
243
        }
244
245 1
        $header = $this->headerNames[$this->normalizeHeaderName($name)];
246 1
        $value = $this->normalizeHeaderValue($value);
247 1
        $this->validateHeaderValue($value);
248
249 1
        $new = clone $this;
250 1
        $new->headers[$header] = array_merge($this->headers[$header], $value);
251 1
        return $new;
252
    }
253
254
    /**
255
     * Return an instance without the specified header.
256
     *
257
     * Header resolution MUST be done without case-sensitivity.
258
     *
259
     * This method MUST be implemented in such a way as to retain the
260
     * immutability of the message, and MUST return an instance that removes
261
     * the named header.
262
     *
263
     * @param string $name Case-insensitive header field name to remove.
264
     * @return static
265
     */
266 1
    public function withoutHeader($name): MessageInterface
267
    {
268 1
        if (!$this->hasHeader($name)) {
269
            return $this;
270
        }
271
272 1
        $normalized = $this->normalizeHeaderName($name);
273 1
        $new = clone $this;
274 1
        unset($new->headers[$this->headerNames[$normalized]], $new->headerNames[$normalized]);
275 1
        return $new;
276
    }
277
278
    /**
279
     * Gets the body of the message.
280
     *
281
     * @return StreamInterface Returns the body as a stream.
282
     */
283 8
    public function getBody(): StreamInterface
284
    {
285 8
        return $this->stream;
286
    }
287
288
    /**
289
     * Return an instance with the specified message body.
290
     *
291
     * The body MUST be a StreamInterface object.
292
     *
293
     * This method MUST be implemented in such a way as to retain the
294
     * immutability of the message, and MUST return a new instance that has the
295
     * new body stream.
296
     *
297
     * @param StreamInterface $body Body.
298
     * @return static
299
     * @throws InvalidArgumentException When the body is not valid.
300
     */
301 1
    public function withBody(StreamInterface $body): MessageInterface
302
    {
303 1
        $new = clone $this;
304 1
        $new->stream = $body;
305 1
        return $new;
306
    }
307
308
    /**
309
     * @param StreamInterface|string|resource $stream
310
     * @param string $mode
311
     */
312 142
    private function registerStream($stream, string $mode = 'wb+'): void
313
    {
314 142
        if ($stream instanceof StreamInterface) {
315 1
            $this->stream = $stream;
316 1
            return;
317
        }
318
319 142
        if (is_string($stream) || is_resource($stream)) {
320 142
            $this->stream = new Stream($stream, $mode);
321 142
            return;
322
        }
323
324 7
        throw new InvalidArgumentException(sprintf(
325
            'Stream must be a `Psr\Http\Message\StreamInterface` implementation'
326
            . ' or a string stream resource identifier or an actual stream resource; received `%s`.',
327 7
            (is_object($stream) ? get_class($stream) : gettype($stream))
328
        ));
329
    }
330
331
    /**
332
     * @param array $originalHeaders
333
     * @throws InvalidArgumentException When the header name or header value is not valid.
334
     */
335 142
    private function registerHeaders(array $originalHeaders = []): void
336
    {
337 142
        $this->headers = [];
338 142
        $this->headerNames = [];
339
340 142
        foreach ($originalHeaders as $name => $value) {
341 1
            $value = $this->normalizeHeaderValue($value);
342 1
            $this->validateHeaderValue($value);
343 1
            $this->validateHeaderName($name);
344 1
            $this->headers[$name] = $value;
345 1
            $this->headerNames[$this->normalizeHeaderName($name)] = $name;
346
        }
347 142
    }
348
349
    /**
350
     * @param string $protocol
351
     * @throws InvalidArgumentException for invalid HTTP protocol version.
352
     */
353 142
    private function registerProtocolVersion(string $protocol): void
354
    {
355 142
        if (!empty($protocol) && $protocol !== $this->protocol) {
356 1
            $this->validateProtocolVersion($protocol);
357 1
            $this->protocol = $protocol;
358
        }
359 142
    }
360
361
    /**
362
     * @param string $name
363
     * @return string
364
     */
365 83
    private function normalizeHeaderName(string $name): string
366
    {
367 83
        return strtolower($name);
368
    }
369
370
    /**
371
     * @param mixed $value
372
     * @return array
373
     */
374 16
    private function normalizeHeaderValue($value): array
375
    {
376 16
        return is_array($value) ? $value : [$value];
377
    }
378
379
    /**
380
     * @param mixed $name
381
     * @throws InvalidArgumentException for invalid header name.
382
     */
383 27
    private function validateHeaderName($name): void
384
    {
385 27
        if (!is_string($name) || !preg_match('/^[a-zA-Z0-9\'`#$%&*+.^_|~!-]+$/', $name)) {
386 11
            throw new InvalidArgumentException(sprintf(
387
                '`%s` is not valid header name',
388 11
                (is_object($name) ? get_class($name) : (is_string($name) ? $name : gettype($name)))
389
            ));
390
        }
391 16
    }
392
393
    /**
394
     * @param mixed $value
395
     * @throws InvalidArgumentException for invalid header value.
396
     */
397 16
    private function validateHeaderValue($value): void
398
    {
399 16
        if (!is_array($value) || empty($value)) {
400 1
            throw new InvalidArgumentException('Invalid header value: must be an array and must not be empty.');
401
        }
402
403 15
        foreach ($value as $item) {
404
            if (
405 15
                (!is_string($item) && !is_numeric($item))
406 15
                || preg_match('/[^\x09\x0a\x0d\x20-\x7E\x80-\xFE]/', (string) $item)
407 15
                || preg_match("/(?:(?:(?<!\r)\n)|(?:\r(?!\n))|(?:\r\n(?![ \t])))/", (string) $item)
408
            ) {
409 8
                throw new InvalidArgumentException(sprintf(
410
                    '"%s" is not valid header value',
411 8
                    (is_object($item) ? get_class($item) : (is_string($item) ? $item : gettype($item)))
412
                ));
413
            }
414
        }
415 7
    }
416
417
    /**
418
     * @param string $protocol
419
     * @throws InvalidArgumentException for invalid HTTP protocol version.
420
     */
421 11
    private function validateProtocolVersion($protocol): void
422
    {
423 11
        if (!is_string($protocol) || empty($protocol)) {
0 ignored issues
show
The condition is_string($protocol) is always true.
Loading history...
424 8
            throw new InvalidArgumentException('HTTP protocol version must be an string and must not be empty.');
425
        }
426
427 3
        $supportedProtocolVersions = ['1.0', '1.1', '2.0', '2'];
428
429 3
        if (!in_array($protocol, $supportedProtocolVersions, true)) {
430 1
            throw new InvalidArgumentException(sprintf(
431
                'Unsupported HTTP protocol version `%s` provided. Supported (%s) in string types.',
432 1
                $protocol,
433 1
                implode(', ', $supportedProtocolVersions)
434
            ));
435
        }
436 2
    }
437
}
438