bkdotcom/PHPDebugConsole

View on GitHub
src/HttpMessage/Message.php

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
<?php

/**
 * This file is part of HttpMessage
 *
 * @package   bdk/http-message
 * @author    Brad Kent <bkfake-github@yahoo.com>
 * @license   http://opensource.org/licenses/MIT MIT
 * @copyright 2014-2024 Brad Kent
 * @version   v1.0
 */

namespace bdk\HttpMessage;

use bdk\HttpMessage\AssertionTrait;
use bdk\HttpMessage\Stream;
use InvalidArgumentException;
use Psr\Http\Message\MessageInterface;
use Psr\Http\Message\StreamInterface;

/**
 * Http Message
 *
 * @psalm-consistent-constructor
 */
class Message implements MessageInterface
{
    use AssertionTrait;

    /**
     * @var StreamInterface|null
     */
    private $body;

    /**
     * @var array<string, string[]> Map of all registered headers, as name => array of values
     */
    private $headers = array();

    /**
     * @var array<string, string> Map of lowercase header name => original name at registration
     */
    private $headerNames = array();

    /** @var string */
    protected $protocolVersion = '1.1';

    /**
     * Retrieves the HTTP protocol version as a string.
     *
     * @return string HTTP protocol version (e.g., "1.1", "1.0").
     */
    public function getProtocolVersion()
    {
        return $this->protocolVersion;
    }

    /**
     * Return an instance with the specified HTTP protocol version.
     *
     * The version string MUST contain only the HTTP version number (e.g., "1.1", "1.0").
     *
     * @param string $version HTTP protocol version
     *
     * @return static
     */
    public function withProtocolVersion($version)
    {
        $this->assertProtocolVersion($version);
        $version = $version;
        if ($version === $this->protocolVersion) {
            return $this;
        }
        $new = clone $this;
        $new->protocolVersion = $version;
        return $new;
    }

    /**
     * {@inheritDoc}
     *
     * @return string[][] Returns an associative array of the message's headers. Each
     *     key is a header name, and each value is an array of strings for that header.
     */
    public function getHeaders()
    {
        return $this->headers;
    }

    /**
     * Checks if a header exists by the given case-insensitive name.
     *
     * @param string $name Case-insensitive header field name.
     *
     * @return bool Returns true if any header names match the given header
     *     name using a case-insensitive string comparison. Returns false if
     *     no matching header name is found in the message.
     */
    public function hasHeader($name)
    {
        $nameLower = \strtolower($name);
        return isset($this->headerNames[$nameLower]);
    }

    /**
     * Retrieves a message header value by the given case-insensitive name.
     *
     * This method returns an array of all the header values of the given
     * case-insensitive header name.
     *
     * @param string $name header name
     *
     * @return string[] An array of string values as provided for the given
     *    header. If the header does not appear in the message, an empty array is returned.
     */
    public function getHeader($name)
    {
        $nameLower = \strtolower($name);
        if (!isset($this->headerNames[$nameLower])) {
            return array();
        }
        $name = $this->headerNames[$nameLower];
        return $this->headers[$name];
    }

    /**
     * Retrieves a comma-separated string of the values for a single header.
     *
     * This method returns all of the header values of the given
     * case-insensitive header name as a string concatenated together using
     * a comma.
     *
     * NOTE: Not all header values may be appropriately represented using
     * comma concatenation. For such headers, use getHeader() instead
     * and supply your own delimiter when concatenating.
     *
     * If the header does not appear in the message, this method will return
     * an empty string.
     *
     * @param string $name Case-insensitive header field name.
     *
     * @return string A string of values as provided for the given header
     *    concatenated together using a comma. If the header does not appear in
     *    the message, this method will return an empty string.
     */
    public function getHeaderLine($name)
    {
        return \implode(', ', $this->getHeader($name));
    }

    /**
     * Return an instance with the provided value replacing the specified header.
     *
     * @param string          $name  Case-insensitive header field name.
     * @param string|string[] $value Header value(s).
     *
     * @return static
     * @throws InvalidArgumentException for invalid header names or values.
     */
    public function withHeader($name, $value)
    {
        $this->assertHeaderName($name);
        $name = $this->normalizeHeaderName($name);
        $this->assertHeaderValue($value);
        $values = $this->normalizeHeaderValue($value);
        $nameLower = \strtolower($name);
        $new = clone $this;
        if (isset($new->headerNames[$nameLower])) {
            // remove previous header-name
            $namePrev = $new->headerNames[$nameLower];
            unset($new->headers[$namePrev]);
        }
        $new->headerNames[$nameLower] = $name;
        $new->headers[$name] = $values;
        if ($nameLower === 'host') {
            $new->afterUpdateHost();
        }
        return $new;
    }

    /**
     * Return an instance with the specified header values appended to the current value
     *
     * Existing values for the specified header will be maintained.
     * The new value(s) will be appended to the existing list.
     * If the header did not exist previously, it will be added.
     *
     * @param string          $name  Case-insensitive header field name to add.
     * @param string|string[] $value Header value(s).
     *
     * @return static
     * @throws InvalidArgumentException for invalid header names.
     * @throws InvalidArgumentException for invalid header values.
     */
    public function withAddedHeader($name, $value)
    {
        // assert before using as array key (which will typecast)
        $this->assertHeaderName($name);
        $new = clone $this;
        $new->setHeaders(array(
            $name => $value,
        ));
        return $new;
    }

    /**
     * Return an instance without the specified header.
     *
     * @param string $name Case-insensitive header field name to remove.
     *
     * @return static
     */
    public function withoutHeader($name)
    {
        $nameLower = \strtolower($name);
        if (!isset($this->headerNames[$nameLower])) {
            return $this;
        }
        $new = clone $this;
        unset($new->headers[$name], $new->headerNames[$nameLower]);
        return $new;
    }

    /**
     * Gets the body of the message.
     *
     * @return StreamInterface The body as a stream.
     */
    public function getBody()
    {
        if (!$this->body) {
            $this->body = new Stream();
        }
        return $this->body;
    }

    /**
     * Return an instance with the specified message body.
     *
     * @param StreamInterface $body Body
     *
     * @return static
     * @throws \InvalidArgumentException
     */
    public function withBody(StreamInterface $body)
    {
        if ($body === $this->body) {
            return $this;
        }
        $new = clone $this;
        $new->body = $body;
        return $new;
    }

    /**
     * Assert only one Host value / sort Host header to beginning
     *
     * @return void
     *
     * @throws InvalidArgumentException
     */
    private function afterUpdateHost()
    {
        $this->headers['Host'] = \array_unique($this->headers['Host']);
        if (\count($this->headers['Host']) > 1) {
            throw new InvalidArgumentException(
                'Only one Host header is allowed.'
            );
        }
        // Ensure Host is the first header.
        // See: https://datatracker.ietf.org/doc/html/rfc7230#section-5.4
        if (isset($this->headers['Host'])) {
            $this->headers = \array_replace(
                array('Host' => $this->headers['Host']),
                $this->headers
            );
        }
    }

    /**
     * Normalize header name
     *
     * @param string $name header name
     *
     * @return string
     */
    private function normalizeHeaderName($name)
    {
        $nameLower = \strtolower($name);
        return $nameLower === 'host'
            ? 'Host'
            : $name;
    }

    /**
     * Trims whitespace from the header value(s).
     *
     * 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 non-empty-string|int|float|string[]|int[]|float[] $value header value
     *
     * @return string[] Trimmed header values
     *
     * @see https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.4
     */
    private function normalizeHeaderValue($value)
    {
        $values = (array) $value;
        $values = \array_map(static function ($value) {
            return \trim((string) $value, " \t");
        }, $values);
        return \array_values($values);
    }

    /**
     * Set header values
     *
     * @param array $headers header name/value pairs
     *
     * @return void
     */
    protected function setHeaders($headers = array())
    {
        \array_walk($headers, function ($value, $name) {
            if (\is_int($name)) {
                // Numeric array keys are converted to int by PHP but having a header name '123' is not forbidden by the spec
                // and also allowed in withHeader(). So we need to cast it to string again for the following assertion to pass.
                $name = (string) $name;
            }
            $this->assertHeaderName($name);
            $this->assertHeaderValue($value);
            $name = $this->normalizeHeaderName($name);
            $values = $this->normalizeHeaderValue($value);
            $this->setHeaderValues($name, $values);
        });
    }

    /**
     * Append the given values
     *
     * @param string   $name   header name
     * @param string[] $values header values
     *
     * @return void
     */
    private function setHeaderValues($name, array $values)
    {
        $nameLower = \strtolower($name);
        if (isset($this->headerNames[$nameLower])) {
            $name = $this->headerNames[$nameLower];
            $values = \array_merge($this->headers[$name], $values);
        }
        $this->headerNames[$nameLower] = $name;
        $this->headers[$name] = $values;
        if ($nameLower === 'host') {
            $this->afterUpdateHost();
        }
    }
}