Issues (25)

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