array of values */ private array $headers = []; /** @var string[] Map of lowercase header name => original name at registration */ private array $headerNames = []; private string $protocol = '1.1'; private ?StreamInterface $stream = null; public function getProtocolVersion() : string { return $this->protocol; } /** * @return static */ public function withProtocolVersion(string $version) : MessageInterface { $this->assertProtocolVersion($version); if ($this->protocol === $version) { return $this; } $new = clone $this; $new->protocol = $version; return $new; } public function getHeaders() : array { return $this->headers; } public function hasHeader(string $name) : bool { return isset($this->headerNames[Utils::asciiToLower($name)]); } public function getHeader(string $name) : array { $header = Utils::asciiToLower($name); if (!isset($this->headerNames[$header])) { return []; } $header = $this->headerNames[$header]; return $this->headers[$header]; } public function getHeaderLine(string $name) : string { return \implode(', ', $this->getHeader($name)); } /** * @return static */ public function withHeader(string $name, $value) : MessageInterface { $this->assertHeader($name); $value = $this->normalizeHeaderValue($name, $value); $normalized = Utils::asciiToLower($name); $new = clone $this; if (isset($new->headerNames[$normalized])) { unset($new->headers[$new->headerNames[$normalized]]); } $new->headerNames[$normalized] = $name; $new->headers[$name] = $value; return $new; } /** * @return static */ public function withAddedHeader(string $name, $value) : MessageInterface { $this->assertHeader($name); $value = $this->normalizeHeaderValue($name, $value); $normalized = Utils::asciiToLower($name); $new = clone $this; if (isset($new->headerNames[$normalized])) { $name = $this->headerNames[$normalized]; $new->headers[$name] = \array_merge($this->headers[$name], $value); } else { $new->headerNames[$normalized] = $name; $new->headers[$name] = $value; } return $new; } /** * @return static */ public function withoutHeader(string $name) : MessageInterface { $normalized = Utils::asciiToLower($name); if (!isset($this->headerNames[$normalized])) { return $this; } $name = $this->headerNames[$normalized]; $new = clone $this; unset($new->headers[$name], $new->headerNames[$normalized]); return $new; } public function getBody() : StreamInterface { if (!$this->stream) { $this->stream = Utils::streamFor(''); } return $this->stream; } /** * @return static */ public function withBody(StreamInterface $body) : MessageInterface { if ($body === $this->stream) { return $this; } $new = clone $this; $new->stream = $body; return $new; } /** * @param (string|string[])[] $headers */ private function setHeaders(array $headers) : void { $this->headerNames = $this->headers = []; foreach ($headers as $header => $value) { // Numeric array keys are converted to int by PHP. $header = (string) $header; $this->assertHeader($header); $value = $this->normalizeHeaderValue($header, $value); $normalized = Utils::asciiToLower($header); if (isset($this->headerNames[$normalized])) { $header = $this->headerNames[$normalized]; $this->headers[$header] = \array_merge($this->headers[$header], $value); } else { $this->headerNames[$normalized] = $header; $this->headers[$header] = $value; } } } /** * @param mixed $value * * @return string[] */ private function normalizeHeaderValue(string $header, $value) : array { if (\is_array($value) && $value === []) { throw new \InvalidArgumentException('Header value must be a non-empty array or string.'); } if (!\is_array($value)) { return $this->trimAndValidateHeaderValues($header, [$value]); } return $this->trimAndValidateHeaderValues($header, $value); } /** * Trims whitespace from the header values. * * Spaces and tabs ought to be excluded by parsers when extracting the field value from a header field. * * header-field = field-name ":" OWS field-value OWS * OWS = *( SP / HTAB ) * * @param mixed[] $values Header values * * @return string[] Trimmed header values * * @see https://datatracker.ietf.org/doc/html/rfc9110#section-5.5 */ private function trimAndValidateHeaderValues(string $header, array $values) : array { return \array_map(function ($value) use($header) : string { if (!\is_string($value)) { throw new \InvalidArgumentException(\sprintf('Header value must be a string or array of strings but %s provided.', \get_debug_type($value))); } $trimmed = \trim($value, " \t"); $this->assertValue($header, $trimmed); return $trimmed; }, \array_values($values)); } /** * @see https://datatracker.ietf.org/doc/html/rfc9110#section-5.1 */ private function assertHeader(string $header) : void { if (!Rfc9110::isToken($header)) { throw new \InvalidArgumentException(\sprintf('Invalid header name: %s', DiagnosticValue::escape($header))); } } private function assertProtocolVersion(string $version) : void { if (!Rfc9112::isValidProtocolVersion($version)) { throw new \InvalidArgumentException('Protocol version must be a valid HTTP version number.'); } } /** * @see https://datatracker.ietf.org/doc/html/rfc9110#section-5.5 * * field-value = *( field-content / obs-fold ) * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] * field-vchar = VCHAR / obs-text * VCHAR = %x21-7E * obs-text = %x80-FF * obs-fold = CRLF 1*( SP / HTAB ) */ private function assertValue(string $header, string $value) : void { // The regular expression intentionally does not support the obs-fold // production, because as per RFC 9112#5.2: // // A sender MUST NOT generate a message that includes line folding // (i.e., that has any field-value that contains a match to the obs-fold // rule) unless the message is intended for packaging within the // message/http media type. // // Clients must not send a request with line folding and a server // sending folded headers is likely very rare. Line folding is a fairly // obscure feature of HTTP/1.1 and thus not accepting folding is not // likely to break any legitimate use case. if (!Rfc9110::isFieldValue($value)) { $reason = \strpbrk($value, "\r\n") !== \false ? 'must not contain CR or LF characters' : 'contains an invalid control character'; throw new \InvalidArgumentException(\sprintf('Header "%s" %s.', DiagnosticValue::escape($header), $reason)); } } }