biurad/php-http-galaxy

View on GitHub
src/Factory/Psr7Bridge.php

Summary

Maintainability
B
6 hrs
Test Coverage
<?php

declare(strict_types=1);

/*
 * This file is part of Biurad opensource projects.
 *
 * PHP version 7.2 and above required
 *
 * @author    Divine Niiquaye Ibok <divineibok@gmail.com>
 * @copyright 2019 Biurad Group (https://biurad.com/)
 * @license   https://opensource.org/licenses/BSD-3-Clause License
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Biurad\Http\Factory;

use Biurad\Http\Response;
use Biurad\Http\ServerRequest;
use GuzzleHttp\Psr7\LimitStream;
use GuzzleHttp\Psr7\Uri;
use Laminas\Diactoros\RelativeStream;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;

/**
 * @final
 *
 * @author Divine Niiquaye Ibok <divineibok@gmail.com>
 */
class Psr7Bridge
{
    private const CR = "\r";

    private const LF = "\n";

    /** @var StreamFactoryInterface */
    private $streamFactory;

    public function __construct(StreamFactoryInterface $streamFactory)
    {
        $this->streamFactory = $streamFactory;
    }

    /**
     * Serialize a response message to an array.
     */
    public static function responseToArray(ResponseInterface $response): array
    {
        return [
            'status_code' => $response->getStatusCode(),
            'reason_phrase' => $response->getReasonPhrase(),
            'protocol_version' => $response->getProtocolVersion(),
            'headers' => $response->getHeaders(),
            'body' => (string) $response->getBody(),
        ];
    }

    /**
     * Serialize a request message to an array.
     */
    public static function requestToArray(RequestInterface $request): array
    {
        return [
            'method' => $request->getMethod(),
            'request_target' => $request->getRequestTarget(),
            'uri' => (string) $request->getUri(),
            'protocol_version' => $request->getProtocolVersion(),
            'headers' => $request->getHeaders(),
            'body' => (string) $request->getBody(),
        ];
    }

    /**
     * Serialize a request message to an array.
     */
    public static function serverRequestToArray(ServerRequestInterface $request): array
    {
        return self::requestToArray($request) + [
            'attributes' => $request->getAttributes(),
            'cookie_params' => $request->getCookieParams(),
            'server_params' => $request->getServerParams(),
            'uploaded_files' => $request->getUploadedFiles(),
            'parsed_body' => $request->getParsedBody(),
        ];
    }

    /**
     * Deserialize a response array to a response instance.
     *
     * @throws \UnexpectedValueException when cannot deserialize response
     */
    public function responseFromArray(array $serializedResponse): Response
    {
        try {
            $body = $this->streamFactory->createStream(self::getValueFromKey($serializedResponse, 'body'));

            $statusCode = self::getValueFromKey($serializedResponse, 'status_code');
            $headers = self::getValueFromKey($serializedResponse, 'headers');
            $protocolVersion = self::getValueFromKey($serializedResponse, 'protocol_version');
            $reasonPhrase = self::getValueFromKey($serializedResponse, 'reason_phrase');

            return new Response($statusCode, $headers, $body, $protocolVersion, $reasonPhrase);
        } catch (\Throwable $exception) {
            throw new \UnexpectedValueException('Cannot deserialize response', $exception->getCode(), $exception);
        }
    }

    /**
     * Parse a response from a string.
     *
     * @throws \UnexpectedValueException when errors occur parsing the message
     */
    public function responseFromString(string $response): Response
    {
        \fwrite($resource = \fopen('php://temp', 'r+'), $response);

        $stream = $this->streamFactory->createStreamFromResource($resource);
        $stream->rewind();

        [$version, $status, $reasonPhrase] = self::getStatusLine($stream);
        [$headers, $body] = self::splitStream($stream);

        return new Response($status, $headers, $body, $version, $reasonPhrase);
    }

    /**
     * Deserialize a request array to a request instance.
     *
     * @throws \UnexpectedValueException when cannot deserialize response
     */
    public function requestFromArray(array $serializedRequest): ServerRequest
    {
        try {
            $uri = self::getValueFromKey($serializedRequest, 'uri');
            $method = self::getValueFromKey($serializedRequest, 'method');
            $body = $this->streamFactory->createStream(self::getValueFromKey($serializedRequest, 'body'));
            $headers = self::getValueFromKey($serializedRequest, 'headers');
            $requestTarget = self::getValueFromKey($serializedRequest, 'request_target');
            $protocolVersion = self::getValueFromKey($serializedRequest, 'protocol_version');

            $request = (new ServerRequest($method, $uri, $headers, $body, $protocolVersion, $serializedRequest['server_params'] ?? $_SERVER));
        } catch (\Throwable $exception) {
            throw new \UnexpectedValueException('Cannot deserialize request', $exception->getCode(), $exception);
        }

        if (isset($serializedRequest['attributes'])) {
            foreach ($serializedRequest['attributes'] as $attrKey => $attrValue) {
                $request = $request->withAttribute($attrKey, $attrValue);
            }
        }

        if (isset($serializedRequest['cookie_params'])) {
            $request = $request->withCookieParams($serializedRequest['cookie_params']);
        }

        if (isset($serializedRequest['uploaded_files'])) {
            $request = $request->withUploadedFiles($serializedRequest['uploaded_files']);
        }

        if (isset($serializedRequest['parsed_body'])) {
            $request = $request->withParsedBody($serializedRequest['parsed_body']);
        }

        return $request->withRequestTarget($requestTarget);
    }

    /**
     * Parse a server request from a string.
     *
     * @throws \UnexpectedValueException when errors occur parsing the message
     */
    public function requestFromString(string $request): ServerRequest
    {
        \fwrite($resource = \fopen('php://temp', 'r+'), $request);

        $stream = $this->streamFactory->createStreamFromResource($resource);
        $stream->rewind();

        [$method, $requestTarget, $version] = self::getRequestLine($stream);
        $uri = self::createUriFromRequestTarget($requestTarget);

        [$headers, $body] = self::splitStream($stream);

        return (new ServerRequest($method, $uri, $headers, $body, $version, $_SERVER))->withRequestTarget($requestTarget);
    }

    /**
     * Retrieve a single line from the stream.
     *
     * Retrieves a line from the stream; a line is defined as a sequence of
     * characters ending in a CRLF sequence.
     *
     * @throws \UnexpectedValueException if the sequence contains a CR
     *                                   or LF in isolation, or ends in a CR
     */
    protected static function getLine(StreamInterface $stream): string
    {
        $line = '';
        $crFound = false;

        while (!$stream->eof()) {
            $char = $stream->read(1);

            if ($crFound && self::LF === $char) {
                $crFound = false;

                break;
            }

            // CR NOT followed by LF
            if ($crFound && self::LF !== $char) {
                throw new \UnexpectedValueException('Unexpected carriage return detected');
            }

            // LF in isolation
            if (!$crFound && self::LF === $char) {
                throw new \UnexpectedValueException('Unexpected line feed detected');
            }

            // CR found; do not append
            if (self::CR === $char) {
                $crFound = true;

                continue;
            }

            // Any other character: append
            $line .= $char;
        }

        // CR found at end of stream
        if ($crFound) {
            throw new \UnexpectedValueException('Unexpected end of headers');
        }

        return $line;
    }

    /**
     * Split the stream into headers and body content.
     *
     * Returns an array containing two elements
     *
     * - The first is an array of headers
     * - The second is a StreamInterface containing the body content
     *
     * @throws \UnexpectedValueException for invalid headers
     */
    protected static function splitStream(StreamInterface $stream): array
    {
        $headers = [];
        $currentHeader = false;

        while ($line = self::getLine($stream)) {
            if (\preg_match(';^(?P<name>[!#$%&\'*+.^_`\|~0-9a-zA-Z-]+):(?P<value>.*)$;', $line, $matches)) {
                $currentHeader = $matches['name'];

                if (!isset($headers[$currentHeader])) {
                    $headers[$currentHeader] = [];
                }
                $headers[$currentHeader][] = \ltrim($matches['value']);

                continue;
            }

            if (!$currentHeader) {
                throw new \UnexpectedValueException('Invalid header detected');
            }

            if (!\preg_match('#^[ \t]#', $line)) {
                throw new \UnexpectedValueException('Invalid header continuation');
            }

            // Append continuation to last header value found
            $value = \array_pop($headers[$currentHeader]);
            $headers[$currentHeader][] = $value . \ltrim($line);
        }

        if (\class_exists(RelativeStream::class)) {
            $stream = new RelativeStream($stream, $stream->tell());
        } elseif (\class_exists(LimitStream::class)) {
            $stream = new LimitStream($stream, -1, $stream->tell());
        }

        // use a limiting stream to avoid copying initial stream into memory
        return [$headers,  $stream];
    }

    /**
     * Retrieve the components of the request line.
     *
     * Retrieves the first line of the stream and parses it, raising an
     * exception if it does not follow specifications; if valid, returns a list
     * with the method, target, and version, in that order.
     *
     * @throws \UnexpectedValueException
     */
    private static function getRequestLine(StreamInterface $stream): array
    {
        \preg_match(
            '#^(?P<method>[!\#$%&\'*+.^_`|~a-zA-Z0-9-]+) (?P<target>[^\s]+) HTTP/(?P<version>[1-9]\d*\.\d+)$#',
            self::getLine($stream),
            $matches
        );

        if (empty($matches)) {
            throw new \UnexpectedValueException('Invalid request line detected');
        }

        return [$matches['method'], $matches['target'], $matches['version']];
    }

    /**
     * Create and return a Uri instance based on the provided request target.
     *
     * If the request target is of authority or asterisk form, an empty Uri
     * instance is returned; otherwise, the value is used to create and return
     * a new Uri instance.
     */
    private static function createUriFromRequestTarget(string $requestTarget): Uri
    {
        if (\preg_match('#^https?://#', $requestTarget)) {
            return new Uri($requestTarget);
        }

        if (\preg_match('#^(\*|[^/])#', $requestTarget)) {
            return new Uri();
        }

        return new Uri($requestTarget);
    }

    /**
     * @param string $message
     *
     * @throws \UnexpectedValueException
     *
     * @return mixed
     */
    private static function getValueFromKey(array $data, string $key, string $message = null)
    {
        if (isset($data[$key])) {
            return $data[$key];
        }

        throw new \UnexpectedValueException($message ?? \sprintf('Missing "%s" key in serialized response', $key));
    }

    /**
     * Retrieve the status line for the message.
     *
     * @throws \UnexpectedValueException if line is malformed
     *
     * @return array Array with three elements: 0 => version, 1 => status, 2 => reason
     */
    private static function getStatusLine(StreamInterface $stream): array
    {
        \preg_match(
            '#^HTTP/(?P<version>[1-9]\d*\.\d) (?P<status>[1-5]\d{2})(\s+(?P<reason>.+))?$#',
            self::getLine($stream),
            $matches
        );

        if (empty($matches)) {
            throw new \UnexpectedValueException('No status line detected');
        }

        return [$matches['version'], (int) $matches['status'], $matches['reason'] ?? ''];
    }
}