1<?php 2 3declare(strict_types=1); 4 5namespace GuzzleHttp\Psr7; 6 7use Psr\Http\Message\MessageInterface; 8use Psr\Http\Message\StreamInterface; 9 10/** 11 * Trait implementing functionality common to requests and responses. 12 */ 13trait MessageTrait 14{ 15 /** @var string[][] Map of all registered headers, as original name => array of values */ 16 private $headers = []; 17 18 /** @var string[] Map of lowercase header name => original name at registration */ 19 private $headerNames = []; 20 21 /** @var string */ 22 private $protocol = '1.1'; 23 24 /** @var StreamInterface|null */ 25 private $stream; 26 27 public function getProtocolVersion(): string 28 { 29 return $this->protocol; 30 } 31 32 public function withProtocolVersion($version): MessageInterface 33 { 34 if ($this->protocol === $version) { 35 return $this; 36 } 37 38 $new = clone $this; 39 $new->protocol = $version; 40 41 return $new; 42 } 43 44 public function getHeaders(): array 45 { 46 return $this->headers; 47 } 48 49 public function hasHeader($header): bool 50 { 51 return isset($this->headerNames[strtolower($header)]); 52 } 53 54 public function getHeader($header): array 55 { 56 $header = strtolower($header); 57 58 if (!isset($this->headerNames[$header])) { 59 return []; 60 } 61 62 $header = $this->headerNames[$header]; 63 64 return $this->headers[$header]; 65 } 66 67 public function getHeaderLine($header): string 68 { 69 return implode(', ', $this->getHeader($header)); 70 } 71 72 public function withHeader($header, $value): MessageInterface 73 { 74 $this->assertHeader($header); 75 $value = $this->normalizeHeaderValue($value); 76 $normalized = strtolower($header); 77 78 $new = clone $this; 79 if (isset($new->headerNames[$normalized])) { 80 unset($new->headers[$new->headerNames[$normalized]]); 81 } 82 $new->headerNames[$normalized] = $header; 83 $new->headers[$header] = $value; 84 85 return $new; 86 } 87 88 public function withAddedHeader($header, $value): MessageInterface 89 { 90 $this->assertHeader($header); 91 $value = $this->normalizeHeaderValue($value); 92 $normalized = strtolower($header); 93 94 $new = clone $this; 95 if (isset($new->headerNames[$normalized])) { 96 $header = $this->headerNames[$normalized]; 97 $new->headers[$header] = array_merge($this->headers[$header], $value); 98 } else { 99 $new->headerNames[$normalized] = $header; 100 $new->headers[$header] = $value; 101 } 102 103 return $new; 104 } 105 106 public function withoutHeader($header): MessageInterface 107 { 108 $normalized = strtolower($header); 109 110 if (!isset($this->headerNames[$normalized])) { 111 return $this; 112 } 113 114 $header = $this->headerNames[$normalized]; 115 116 $new = clone $this; 117 unset($new->headers[$header], $new->headerNames[$normalized]); 118 119 return $new; 120 } 121 122 public function getBody(): StreamInterface 123 { 124 if (!$this->stream) { 125 $this->stream = Utils::streamFor(''); 126 } 127 128 return $this->stream; 129 } 130 131 public function withBody(StreamInterface $body): MessageInterface 132 { 133 if ($body === $this->stream) { 134 return $this; 135 } 136 137 $new = clone $this; 138 $new->stream = $body; 139 140 return $new; 141 } 142 143 /** 144 * @param (string|string[])[] $headers 145 */ 146 private function setHeaders(array $headers): void 147 { 148 $this->headerNames = $this->headers = []; 149 foreach ($headers as $header => $value) { 150 // Numeric array keys are converted to int by PHP. 151 $header = (string) $header; 152 153 $this->assertHeader($header); 154 $value = $this->normalizeHeaderValue($value); 155 $normalized = strtolower($header); 156 if (isset($this->headerNames[$normalized])) { 157 $header = $this->headerNames[$normalized]; 158 $this->headers[$header] = array_merge($this->headers[$header], $value); 159 } else { 160 $this->headerNames[$normalized] = $header; 161 $this->headers[$header] = $value; 162 } 163 } 164 } 165 166 /** 167 * @param mixed $value 168 * 169 * @return string[] 170 */ 171 private function normalizeHeaderValue($value): array 172 { 173 if (!is_array($value)) { 174 return $this->trimAndValidateHeaderValues([$value]); 175 } 176 177 if (count($value) === 0) { 178 throw new \InvalidArgumentException('Header value can not be an empty array.'); 179 } 180 181 return $this->trimAndValidateHeaderValues($value); 182 } 183 184 /** 185 * Trims whitespace from the header values. 186 * 187 * Spaces and tabs ought to be excluded by parsers when extracting the field value from a header field. 188 * 189 * header-field = field-name ":" OWS field-value OWS 190 * OWS = *( SP / HTAB ) 191 * 192 * @param mixed[] $values Header values 193 * 194 * @return string[] Trimmed header values 195 * 196 * @see https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.4 197 */ 198 private function trimAndValidateHeaderValues(array $values): array 199 { 200 return array_map(function ($value) { 201 if (!is_scalar($value) && null !== $value) { 202 throw new \InvalidArgumentException(sprintf( 203 'Header value must be scalar or null but %s provided.', 204 is_object($value) ? get_class($value) : gettype($value) 205 )); 206 } 207 208 $trimmed = trim((string) $value, " \t"); 209 $this->assertValue($trimmed); 210 211 return $trimmed; 212 }, array_values($values)); 213 } 214 215 /** 216 * @see https://datatracker.ietf.org/doc/html/rfc7230#section-3.2 217 * 218 * @param mixed $header 219 */ 220 private function assertHeader($header): void 221 { 222 if (!is_string($header)) { 223 throw new \InvalidArgumentException(sprintf( 224 'Header name must be a string but %s provided.', 225 is_object($header) ? get_class($header) : gettype($header) 226 )); 227 } 228 229 if (!preg_match('/^[a-zA-Z0-9\'`#$%&*+.^_|~!-]+$/D', $header)) { 230 throw new \InvalidArgumentException( 231 sprintf('"%s" is not valid header name.', $header) 232 ); 233 } 234 } 235 236 /** 237 * @see https://datatracker.ietf.org/doc/html/rfc7230#section-3.2 238 * 239 * field-value = *( field-content / obs-fold ) 240 * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] 241 * field-vchar = VCHAR / obs-text 242 * VCHAR = %x21-7E 243 * obs-text = %x80-FF 244 * obs-fold = CRLF 1*( SP / HTAB ) 245 */ 246 private function assertValue(string $value): void 247 { 248 // The regular expression intentionally does not support the obs-fold production, because as 249 // per RFC 7230#3.2.4: 250 // 251 // A sender MUST NOT generate a message that includes 252 // line folding (i.e., that has any field-value that contains a match to 253 // the obs-fold rule) unless the message is intended for packaging 254 // within the message/http media type. 255 // 256 // Clients must not send a request with line folding and a server sending folded headers is 257 // likely very rare. Line folding is a fairly obscure feature of HTTP/1.1 and thus not accepting 258 // folding is not likely to break any legitimate use case. 259 if (!preg_match('/^[\x20\x09\x21-\x7E\x80-\xFF]*$/D', $value)) { 260 throw new \InvalidArgumentException( 261 sprintf('"%s" is not valid header value.', $value) 262 ); 263 } 264 } 265} 266