From d74d051311b6bec6ddb9645e1ffb33e15b5df680 Mon Sep 17 00:00:00 2001 From: Thomas Flori <thflori@gmail.com> Date: Sat, 4 Aug 2018 22:50:47 +0200 Subject: [PATCH] overwrite message implementation from guzzle and extend psr-7 --- README.md | 29 +- src/ChangeableMessageTrait.php | 124 +++++++ src/ClientRequest.php | 94 +++++ src/ClientResponse.php | 9 + src/MessageTrait.php | 185 ++++++++++ src/Psr7Extended/ClientRequestInterface.php | 129 +++++++ src/Psr7Extended/ClientResponseInterface.php | 17 + src/Psr7Extended/ServerRequestInterface.php | 7 + src/Psr7Extended/ServerResponseInterface.php | 94 +++++ src/Request.php | 145 ++++++++ src/Response.php | 134 ++++++++ src/ServerRequest.php | 340 ++++++++++++++++++- src/ServerResponse.php | 96 ++++++ src/functions.php | 33 -- 14 files changed, 1400 insertions(+), 36 deletions(-) create mode 100644 src/ChangeableMessageTrait.php create mode 100644 src/ClientRequest.php create mode 100644 src/ClientResponse.php create mode 100644 src/MessageTrait.php create mode 100644 src/Psr7Extended/ClientRequestInterface.php create mode 100644 src/Psr7Extended/ClientResponseInterface.php create mode 100644 src/Psr7Extended/ServerRequestInterface.php create mode 100644 src/Psr7Extended/ServerResponseInterface.php create mode 100644 src/Request.php create mode 100644 src/Response.php create mode 100644 src/ServerResponse.php delete mode 100644 src/functions.php diff --git a/README.md b/README.md index 418db67..dac9151 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,29 @@ # tal -A guzzle/psr-7 wrapper to add additional functionality like sending responses and managing cookies + +This is a clone of guzzle/psr-7 with additional functionality like sending responses and managing cookies. Because they +changed the access level of the properties to be private I was forced to copy the code instead of extending it. + +If you think the code from guzzle has changed a lot and this is outdated - feel free open a pull request. + +## PSR-7 Extended + +This fork is using an extended and fully compatible version of PSR-7. This extension breaks some statements made on the +meta document of PSR-7. + +In PSR-7 every message should be immutable. That means that you can not change a message after it got created. Instead +you have to clone it and modify the clone. I do not fully agree that every message has to be immutable. While it is true +that a request send from a client on the servers side should not be changed because the request already happened. The +request can sill be changed before it gets sent to the server. The same is valid for the response: the client should +not change the response but the server can change the response before it gets sent to the client. + +Therefore we split the request and response interfaces two: + +* `Tal\Psr7Extended\ClientRequestInterface` - the request that will be sent from client to server +* `Tal\Psr7Extended\ClientResponseInterface` - the response that got sent from server to client +* `Tal\Psr7Extended\ServerRequestInterface` - the request that got sent from client to server +* `Tal\Psr7Extended\ServerResponseInterface` - the response that will be sent from server to client + +They are fully compatible with the PSR-7 interfaces. That means that the `with*()` methods still exist and can also be +used to change the client request and server response. But you have to understand that this is not just a philosophical +question: when you clone the message and modify the properties it means that the object has to be copied in the memory. +Even if you don't store the old object it has to be collected and removed from the garbage collector. diff --git a/src/ChangeableMessageTrait.php b/src/ChangeableMessageTrait.php new file mode 100644 index 0000000..98c9463 --- /dev/null +++ b/src/ChangeableMessageTrait.php @@ -0,0 +1,124 @@ +<?php + +namespace Tal; + +use Psr\Http\Message\StreamInterface; + +trait ChangeableMessageTrait +{ + use MessageTrait; + + /** + * Sets 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 setProtocolVersion($version) + { + $this->protocol = $version; + return $this; + } + + /** + * Sets the provided value replacing the specified header. + * + * While header names are case-insensitive, the casing of the header will + * be preserved by this function, and returned from getHeaders(). + * + * @param string $header Case-insensitive header field name. + * @param string|string[] $value Header value(s). + * @return static + * @throws \InvalidArgumentException for invalid header names or values. + */ + public function setHeader($header, $value) + { + if (!is_array($value)) { + $value = [$value]; + } + + $value = $this->trimHeaderValues($value); + $normalized = strtolower($header); + + if (isset($this->headerNames[$normalized])) { + unset($this->headers[$this->headerNames[$normalized]]); + } + $this->headerNames[$normalized] = $header; + $this->headers[$header] = $value; + + return $this; + } + + /** + * Adds the specified header appended with the given 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 $header Case-insensitive header field name to add. + * @param string|string[] $value Header value(s). + * @return static + * @throws \InvalidArgumentException for invalid header names or values. + */ + public function addHeader($header, $value) + { + if (!is_array($value)) { + $value = [$value]; + } + + $value = $this->trimHeaderValues($value); + $normalized = strtolower($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; + } + + return $this; + } + + /** + * Deletes the specified header. + * + * Header resolution MUST be done without case-sensitivity. + * + * @param string $header Case-insensitive header field name to remove. + * @return static + */ + public function deleteHeader($header) + { + $normalized = strtolower($header); + + if (!isset($this->headerNames[$normalized])) { + return $this; + } + + $header = $this->headerNames[$normalized]; + + unset($this->headers[$header], $this->headerNames[$normalized]); + + return $this; + } + + /** + * Sets the specified message body. + * + * The body MUST be a StreamInterface object. + * + * @param StreamInterface $body Body. + * @return static + * @throws \InvalidArgumentException When the body is not valid. + */ + public function setBody(StreamInterface $body) + { + $this->stream = $body; + return $this; + } +} diff --git a/src/ClientRequest.php b/src/ClientRequest.php new file mode 100644 index 0000000..18d2c5c --- /dev/null +++ b/src/ClientRequest.php @@ -0,0 +1,94 @@ +<?php + +namespace Tal; + +use InvalidArgumentException; +use Psr\Http\Message\UriInterface; +use Tal\Psr7Extended\ClientRequestInterface; + +class ClientRequest extends Request implements ClientRequestInterface +{ + use ChangeableMessageTrait; + + /** + * Sets the specific request-target. + * + * If the request needs a non-origin-form request-target — e.g., for + * specifying an absolute-form, authority-form, or asterisk-form — + * this method may be used to create an instance with the specified + * request-target, verbatim. + * + * @link http://tools.ietf.org/html/rfc7230#section-5.3 (for the various + * request-target forms allowed in request messages) + * @param mixed $requestTarget + * @return static + */ + public function setRequestTarget($requestTarget) + { + if (preg_match('#\s#', $requestTarget)) { + throw new InvalidArgumentException( + 'Invalid request target provided; cannot contain whitespace' + ); + } + + $this->requestTarget = $requestTarget; + return $this; + } + + /** + * Sets the provided HTTP method. + * + * While HTTP method names are typically all uppercase characters, HTTP + * method names are case-sensitive and thus implementations SHOULD NOT + * modify the given string. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * changed request method. + * + * @param string $method Case-sensitive method. + * @return static + * @throws \InvalidArgumentException for invalid HTTP methods. + */ + public function setMethod($method) + { + $this->method = strtoupper($method); + return $this; + } + + /** + * Sets the provided URI. + * + * This method MUST update the Host header of the returned request by + * default if the URI contains a host component. If the URI does not + * contain a host component, any pre-existing Host header MUST be carried + * over to the returned request. + * + * You can opt-in to preserving the original state of the Host header by + * setting `$preserveHost` to `true`. When `$preserveHost` is set to + * `true`, this method interacts with the Host header in the following ways: + * + * - If the Host header is missing or empty, and the new URI contains + * a host component, this method MUST update the Host header in the returned + * request. + * - If the Host header is missing or empty, and the new URI does not contain a + * host component, this method MUST NOT update the Host header in the returned + * request. + * - If a Host header is present and non-empty, this method MUST NOT update + * the Host header in the returned request. + * + * @link http://tools.ietf.org/html/rfc3986#section-4.3 + * @param UriInterface $uri New request URI to use. + * @param bool $preserveHost Preserve the original state of the Host header. + * @return static + */ + public function setUri(UriInterface $uri, $preserveHost = false) + { + $this->uri = $uri; + + if (!$preserveHost) { + $this->updateHostFromUri(); + } + return $this; + } +} diff --git a/src/ClientResponse.php b/src/ClientResponse.php new file mode 100644 index 0000000..92e5870 --- /dev/null +++ b/src/ClientResponse.php @@ -0,0 +1,9 @@ +<?php + +namespace Tal; + +use Tal\Psr7Extended\ClientResponseInterface; + +class ClientResponse extends Response implements ClientResponseInterface +{ +} diff --git a/src/MessageTrait.php b/src/MessageTrait.php new file mode 100644 index 0000000..6f53690 --- /dev/null +++ b/src/MessageTrait.php @@ -0,0 +1,185 @@ +<?php + +namespace Tal; + +use function GuzzleHttp\Psr7\stream_for; +use Psr\Http\Message\StreamInterface; + +/** + * Trait implementing functionality common to requests and responses. + */ +trait MessageTrait +{ + /** @var array Map of all registered headers, as original name => array of values */ + protected $headers = []; + + /** @var array Map of lowercase header name => original name at registration */ + protected $headerNames = []; + + /** @var string */ + protected $protocol = '1.1'; + + /** @var StreamInterface */ + protected $stream; + + public function getProtocolVersion() + { + return $this->protocol; + } + + public function withProtocolVersion($version) + { + if ($this->protocol === $version) { + return $this; + } + + $new = clone $this; + $new->protocol = $version; + return $new; + } + + public function getHeaders() + { + return $this->headers; + } + + public function hasHeader($header) + { + return isset($this->headerNames[strtolower($header)]); + } + + public function getHeader($header) + { + $header = strtolower($header); + + if (!isset($this->headerNames[$header])) { + return []; + } + + $header = $this->headerNames[$header]; + + return $this->headers[$header]; + } + + public function getHeaderLine($header) + { + return implode(', ', $this->getHeader($header)); + } + + public function withHeader($header, $value) + { + if (!is_array($value)) { + $value = [$value]; + } + + $value = $this->trimHeaderValues($value); + $normalized = strtolower($header); + + $new = clone $this; + if (isset($new->headerNames[$normalized])) { + unset($new->headers[$new->headerNames[$normalized]]); + } + $new->headerNames[$normalized] = $header; + $new->headers[$header] = $value; + + return $new; + } + + public function withAddedHeader($header, $value) + { + if (!is_array($value)) { + $value = [$value]; + } + + $value = $this->trimHeaderValues($value); + $normalized = strtolower($header); + + $new = clone $this; + if (isset($new->headerNames[$normalized])) { + $header = $this->headerNames[$normalized]; + $new->headers[$header] = array_merge($this->headers[$header], $value); + } else { + $new->headerNames[$normalized] = $header; + $new->headers[$header] = $value; + } + + return $new; + } + + public function withoutHeader($header) + { + $normalized = strtolower($header); + + if (!isset($this->headerNames[$normalized])) { + return $this; + } + + $header = $this->headerNames[$normalized]; + + $new = clone $this; + unset($new->headers[$header], $new->headerNames[$normalized]); + + return $new; + } + + public function getBody() + { + if (!$this->stream) { + $this->stream = stream_for(''); + } + + return $this->stream; + } + + public function withBody(StreamInterface $body) + { + if ($body === $this->stream) { + return $this; + } + + $new = clone $this; + $new->stream = $body; + return $new; + } + + protected function setHeaders(array $headers) + { + $this->headerNames = $this->headers = []; + foreach ($headers as $header => $value) { + if (!is_array($value)) { + $value = [$value]; + } + + $value = $this->trimHeaderValues($value); + $normalized = strtolower($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; + } + } + } + + /** + * 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 string[] $values Header values + * + * @return string[] Trimmed header values + * + * @see https://tools.ietf.org/html/rfc7230#section-3.2.4 + */ + protected function trimHeaderValues(array $values) + { + return array_map(function ($value) { + return trim($value, " \t"); + }, $values); + } +} diff --git a/src/Psr7Extended/ClientRequestInterface.php b/src/Psr7Extended/ClientRequestInterface.php new file mode 100644 index 0000000..2324564 --- /dev/null +++ b/src/Psr7Extended/ClientRequestInterface.php @@ -0,0 +1,129 @@ +<?php + +namespace Tal\Psr7Extended; + +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\StreamInterface; +use Psr\Http\Message\UriInterface; + +interface ClientRequestInterface extends RequestInterface +{ + /** + * Sets the specific request-target. + * + * If the request needs a non-origin-form request-target — e.g., for + * specifying an absolute-form, authority-form, or asterisk-form — + * this method may be used to create an instance with the specified + * request-target, verbatim. + * + * @link http://tools.ietf.org/html/rfc7230#section-5.3 (for the various + * request-target forms allowed in request messages) + * @param mixed $requestTarget + * @return static + */ + public function setRequestTarget($requestTarget); + + /** + * Sets the provided HTTP method. + * + * While HTTP method names are typically all uppercase characters, HTTP + * method names are case-sensitive and thus implementations SHOULD NOT + * modify the given string. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * changed request method. + * + * @param string $method Case-sensitive method. + * @return static + * @throws \InvalidArgumentException for invalid HTTP methods. + */ + public function setMethod($method); + + /** + * Sets the provided URI. + * + * This method MUST update the Host header of the returned request by + * default if the URI contains a host component. If the URI does not + * contain a host component, any pre-existing Host header MUST be carried + * over to the returned request. + * + * You can opt-in to preserving the original state of the Host header by + * setting `$preserveHost` to `true`. When `$preserveHost` is set to + * `true`, this method interacts with the Host header in the following ways: + * + * - If the Host header is missing or empty, and the new URI contains + * a host component, this method MUST update the Host header in the returned + * request. + * - If the Host header is missing or empty, and the new URI does not contain a + * host component, this method MUST NOT update the Host header in the returned + * request. + * - If a Host header is present and non-empty, this method MUST NOT update + * the Host header in the returned request. + * + * @link http://tools.ietf.org/html/rfc3986#section-4.3 + * @param UriInterface $uri New request URI to use. + * @param bool $preserveHost Preserve the original state of the Host header. + * @return static + */ + public function setUri(UriInterface $uri, $preserveHost = false); + + /** + * Sets 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 setProtocolVersion($version); + + /** + * Sets the provided value replacing the specified header. + * + * While header names are case-insensitive, the casing of the header will + * be preserved by this function, and returned from getHeaders(). + * + * @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 setHeader($name, $value); + + /** + * Adds the specified header appended with the given 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 or values. + */ + public function addHeader($name, $value); + + /** + * Deletes the specified header. + * + * Header resolution MUST be done without case-sensitivity. + * + * @param string $name Case-insensitive header field name to remove. + * @return static + */ + public function deleteHeader($name); + + /** + * Sets the specified message body. + * + * The body MUST be a StreamInterface object. + * + * @param StreamInterface $body Body. + * @return static + * @throws \InvalidArgumentException When the body is not valid. + */ + public function setBody(StreamInterface $body); +} diff --git a/src/Psr7Extended/ClientResponseInterface.php b/src/Psr7Extended/ClientResponseInterface.php new file mode 100644 index 0000000..e4ccbe4 --- /dev/null +++ b/src/Psr7Extended/ClientResponseInterface.php @@ -0,0 +1,17 @@ +<?php + +namespace Tal\Psr7Extended; + +use Psr\Http\Message\ResponseInterface; + +/** + * Interface ClientResponseInterface + * + * This interface extends the response just to divide the ClientResponseInterface from + * the ServerResponseInterface. + * + * @package Tal\Psr7Extended + */ +interface ClientResponseInterface extends ResponseInterface +{ +} diff --git a/src/Psr7Extended/ServerRequestInterface.php b/src/Psr7Extended/ServerRequestInterface.php new file mode 100644 index 0000000..e6a3cdf --- /dev/null +++ b/src/Psr7Extended/ServerRequestInterface.php @@ -0,0 +1,7 @@ +<?php + +namespace Tal\Psr7Extended; + +interface ServerRequestInterface extends \Psr\Http\Message\ServerRequestInterface +{ +} diff --git a/src/Psr7Extended/ServerResponseInterface.php b/src/Psr7Extended/ServerResponseInterface.php new file mode 100644 index 0000000..5f574ad --- /dev/null +++ b/src/Psr7Extended/ServerResponseInterface.php @@ -0,0 +1,94 @@ +<?php + + +namespace Tal\Psr7Extended; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamInterface; + +interface ServerResponseInterface extends ResponseInterface +{ + /** + * Sets the specified status code and, optionally, reason phrase. + * + * If no reason phrase is specified, implementations MAY choose to default + * to the RFC 7231 or IANA recommended reason phrase for the response's + * status code. + * + * @link http://tools.ietf.org/html/rfc7231#section-6 + * @link http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml + * @param int $code The 3-digit integer result code to set. + * @param string $reasonPhrase The reason phrase to use with the + * provided status code; if none is provided, implementations MAY + * use the defaults as suggested in the HTTP specification. + * @return static + * @throws \InvalidArgumentException For invalid status code arguments. + */ + public function setStatus($code, $reasonPhrase = ''); + + /** + * Sets 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 setProtocolVersion($version); + + /** + * Sets the provided value replacing the specified header. + * + * While header names are case-insensitive, the casing of the header will + * be preserved by this function, and returned from getHeaders(). + * + * @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 setHeader($name, $value); + + /** + * Adds the specified header appended with the given 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 or values. + */ + public function addHeader($name, $value); + + /** + * Deletes the specified header. + * + * Header resolution MUST be done without case-sensitivity. + * + * @param string $name Case-insensitive header field name to remove. + * @return static + */ + public function deleteHeader($name); + + /** + * Sets the specified message body. + * + * The body MUST be a StreamInterface object. + * + * @param StreamInterface $body Body. + * @return static + * @throws \InvalidArgumentException When the body is not valid. + */ + public function setBody(StreamInterface $body); + + /** + * Sends this response to the client. + * + * @return static + */ + public function send(); +} diff --git a/src/Request.php b/src/Request.php new file mode 100644 index 0000000..1890882 --- /dev/null +++ b/src/Request.php @@ -0,0 +1,145 @@ +<?php + +namespace Tal; + +use function GuzzleHttp\Psr7\stream_for; +use GuzzleHttp\Psr7\Uri; +use InvalidArgumentException; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\StreamInterface; +use Psr\Http\Message\UriInterface; + +/** + * PSR-7 request implementation. + */ +abstract class Request implements RequestInterface +{ + use MessageTrait; + + /** @var string */ + protected $method; + + /** @var null|string */ + protected $requestTarget; + + /** @var UriInterface */ + protected $uri; + + /** + * @param string $method HTTP method + * @param string|UriInterface $uri URI + * @param array $headers Request headers + * @param string|null|resource|StreamInterface $body Request body + * @param string $version Protocol version + */ + public function __construct( + $method, + $uri, + array $headers = [], + $body = null, + $version = '1.1' + ) { + if (!($uri instanceof UriInterface)) { + $uri = new Uri($uri); + } + + $this->method = strtoupper($method); + $this->uri = $uri; + $this->setHeaders($headers); + $this->protocol = $version; + + if (!$this->hasHeader('Host')) { + $this->updateHostFromUri(); + } + + if ($body !== '' && $body !== null) { + $this->stream = stream_for($body); + } + } + + public function getRequestTarget() + { + if ($this->requestTarget !== null) { + return $this->requestTarget; + } + + $target = $this->uri->getPath(); + if ($target == '') { + $target = '/'; + } + if ($this->uri->getQuery() != '') { + $target .= '?' . $this->uri->getQuery(); + } + + return $target; + } + + public function withRequestTarget($requestTarget) + { + if (preg_match('#\s#', $requestTarget)) { + throw new InvalidArgumentException( + 'Invalid request target provided; cannot contain whitespace' + ); + } + + $new = clone $this; + $new->requestTarget = $requestTarget; + return $new; + } + + public function getMethod() + { + return $this->method; + } + + public function withMethod($method) + { + $new = clone $this; + $new->method = strtoupper($method); + return $new; + } + + public function getUri() + { + return $this->uri; + } + + public function withUri(UriInterface $uri, $preserveHost = false) + { + if ($uri === $this->uri) { + return $this; + } + + $new = clone $this; + $new->uri = $uri; + + if (!$preserveHost) { + $new->updateHostFromUri(); + } + + return $new; + } + + protected function updateHostFromUri() + { + $host = $this->uri->getHost(); + + if ($host == '') { + return; + } + + if (($port = $this->uri->getPort()) !== null) { + $host .= ':' . $port; + } + + if (isset($this->headerNames['host'])) { + $header = $this->headerNames['host']; + } else { + $header = 'Host'; + $this->headerNames['host'] = 'Host'; + } + // Ensure Host is the first header. + // See: http://tools.ietf.org/html/rfc7230#section-5.4 + $this->headers = [$header => [$host]] + $this->headers; + } +} diff --git a/src/Response.php b/src/Response.php new file mode 100644 index 0000000..30774b1 --- /dev/null +++ b/src/Response.php @@ -0,0 +1,134 @@ +<?php + +namespace Tal; + +use function GuzzleHttp\Psr7\stream_for; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamInterface; + +/** + * PSR-7 response implementation. + */ +abstract class Response implements ResponseInterface +{ + use MessageTrait; + + /** @var array Map of standard HTTP status code/reason phrases */ + protected static $phrases = [ + 100 => 'Continue', + 101 => 'Switching Protocols', + 102 => 'Processing', + 200 => 'OK', + 201 => 'Created', + 202 => 'Accepted', + 203 => 'Non-Authoritative Information', + 204 => 'No Content', + 205 => 'Reset Content', + 206 => 'Partial Content', + 207 => 'Multi-status', + 208 => 'Already Reported', + 300 => 'Multiple Choices', + 301 => 'Moved Permanently', + 302 => 'Found', + 303 => 'See Other', + 304 => 'Not Modified', + 305 => 'Use Proxy', + 306 => 'Switch Proxy', + 307 => 'Temporary Redirect', + 400 => 'Bad Request', + 401 => 'Unauthorized', + 402 => 'Payment Required', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 407 => 'Proxy Authentication Required', + 408 => 'Request Time-out', + 409 => 'Conflict', + 410 => 'Gone', + 411 => 'Length Required', + 412 => 'Precondition Failed', + 413 => 'Request Entity Too Large', + 414 => 'Request-URI Too Large', + 415 => 'Unsupported Media Type', + 416 => 'Requested range not satisfiable', + 417 => 'Expectation Failed', + 418 => 'I\'m a teapot', + 422 => 'Unprocessable Entity', + 423 => 'Locked', + 424 => 'Failed Dependency', + 425 => 'Unordered Collection', + 426 => 'Upgrade Required', + 428 => 'Precondition Required', + 429 => 'Too Many Requests', + 431 => 'Request Header Fields Too Large', + 451 => 'Unavailable For Legal Reasons', + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Time-out', + 505 => 'HTTP Version not supported', + 506 => 'Variant Also Negotiates', + 507 => 'Insufficient Storage', + 508 => 'Loop Detected', + 511 => 'Network Authentication Required', + ]; + + /** @var string */ + protected $reasonPhrase = ''; + + /** @var int */ + protected $statusCode = 200; + + /** + * @param int $status Status code + * @param array $headers Response headers + * @param string|null|resource|StreamInterface $body Response body + * @param string $version Protocol version + * @param string|null $reason Reason phrase (when empty a default will be used based on the status code) + */ + public function __construct( + $status = 200, + array $headers = [], + $body = null, + $version = '1.1', + $reason = null + ) { + $this->statusCode = (int) $status; + + if ($body !== '' && $body !== null) { + $this->stream = stream_for($body); + } + + $this->setHeaders($headers); + if ($reason == '' && isset(self::$phrases[$this->statusCode])) { + $this->reasonPhrase = self::$phrases[$this->statusCode]; + } else { + $this->reasonPhrase = (string) $reason; + } + + $this->protocol = $version; + } + + public function getStatusCode() + { + return $this->statusCode; + } + + public function getReasonPhrase() + { + return $this->reasonPhrase; + } + + public function withStatus($code, $reasonPhrase = '') + { + $new = clone $this; + $new->statusCode = (int) $code; + if ($reasonPhrase == '' && isset(self::$phrases[$new->statusCode])) { + $reasonPhrase = self::$phrases[$new->statusCode]; + } + $new->reasonPhrase = $reasonPhrase; + return $new; + } +} diff --git a/src/ServerRequest.php b/src/ServerRequest.php index 910f5d0..dd11648 100644 --- a/src/ServerRequest.php +++ b/src/ServerRequest.php @@ -3,10 +3,169 @@ namespace Tal; use GuzzleHttp\Psr7\LazyOpenStream; -use GuzzleHttp\Psr7\ServerRequest as BaseServerRequest; +use GuzzleHttp\Psr7\UploadedFile; +use GuzzleHttp\Psr7\Uri; +use InvalidArgumentException; +use Psr\Http\Message\UriInterface; +use Psr\Http\Message\StreamInterface; +use Psr\Http\Message\UploadedFileInterface; +use Tal\Psr7Extended\ServerRequestInterface; -class ServerRequest extends BaseServerRequest +/** + * Server-side HTTP request + * + * Extends the Request definition to add methods for accessing incoming data, + * specifically server parameters, cookies, matched path parameters, query + * string arguments, body parameters, and upload file information. + * + * "Attributes" are discovered via decomposing the request (and usually + * specifically the URI path), and typically will be injected by the application. + * + * Requests are considered immutable; all methods that might change state are + * implemented such that they retain the internal state of the current + * message and return a new instance that contains the changed state. + */ +class ServerRequest extends Request implements ServerRequestInterface { + /** + * @var array + */ + protected $attributes = []; + + /** + * @var array + */ + protected $cookieParams = []; + + /** + * @var null|array|object + */ + protected $parsedBody; + + /** + * @var array + */ + protected $queryParams = []; + + /** + * @var array + */ + protected $serverParams; + + /** + * @var array + */ + protected $uploadedFiles = []; + + /** + * @param string $method HTTP method + * @param string|UriInterface $uri URI + * @param array $headers Request headers + * @param string|null|resource|StreamInterface $body Request body + * @param string $version Protocol version + * @param array $serverParams Typically the $_SERVER superglobal + */ + public function __construct( + $method, + $uri, + array $headers = [], + $body = null, + $version = '1.1', + array $serverParams = [] + ) { + $this->serverParams = $serverParams; + + parent::__construct($method, $uri, $headers, $body, $version); + } + + /** + * Return an UploadedFile instance array. + * + * @param array $files A array which respect $_FILES structure + * @throws InvalidArgumentException for unrecognized values + * @return array + */ + public static function normalizeFiles(array $files) + { + $normalized = []; + + foreach ($files as $key => $value) { + if ($value instanceof UploadedFileInterface) { + $normalized[$key] = $value; + } elseif (is_array($value) && isset($value['tmp_name'])) { + $normalized[$key] = self::createUploadedFileFromSpec($value); + } elseif (is_array($value)) { + $normalized[$key] = self::normalizeFiles($value); + continue; + } else { + throw new InvalidArgumentException('Invalid value in files specification'); + } + } + + return $normalized; + } + + /** + * Create and return an UploadedFile instance from a $_FILES specification. + * + * If the specification represents an array of values, this method will + * delegate to normalizeNestedFileSpec() and return that return value. + * + * @param array $value $_FILES struct + * @return array|UploadedFileInterface + */ + protected static function createUploadedFileFromSpec(array $value) + { + if (is_array($value['tmp_name'])) { + return self::normalizeNestedFileSpec($value); + } + + return new UploadedFile( + $value['tmp_name'], + (int) $value['size'], + (int) $value['error'], + $value['name'], + $value['type'] + ); + } + + /** + * Normalize an array of file specifications. + * + * Loops through all nested files and returns a normalized array of + * UploadedFileInterface instances. + * + * @param array $files + * @return UploadedFileInterface[] + */ + protected static function normalizeNestedFileSpec(array $files = []) + { + $normalizedFiles = []; + + foreach (array_keys($files['tmp_name']) as $key) { + $spec = [ + 'tmp_name' => $files['tmp_name'][$key], + 'size' => $files['size'][$key], + 'error' => $files['error'][$key], + 'name' => $files['name'][$key], + 'type' => $files['type'][$key], + ]; + $normalizedFiles[$key] = self::createUploadedFileFromSpec($spec); + } + + return $normalizedFiles; + } + + /** + * Return a ServerRequestInterface populated with superglobals: + * $_GET + * $_POST + * $_COOKIE + * $_FILES + * $_SERVER + * + * @return ServerRequestInterface + */ public static function fromGlobals() { $method = isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : 'GET'; @@ -24,6 +183,183 @@ class ServerRequest extends BaseServerRequest ->withUploadedFiles(self::normalizeFiles($_FILES)); } + /** + * Get a Uri populated with values from $_SERVER. + * + * @return UriInterface + */ + public static function getUriFromGlobals() + { + $uri = new Uri(''); + + $uri = $uri->withScheme(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http'); + + $hasPort = false; + if (isset($_SERVER['HTTP_HOST'])) { + $hostHeaderParts = explode(':', $_SERVER['HTTP_HOST']); + $uri = $uri->withHost($hostHeaderParts[0]); + if (isset($hostHeaderParts[1])) { + $hasPort = true; + $uri = $uri->withPort($hostHeaderParts[1]); + } + } elseif (isset($_SERVER['SERVER_NAME'])) { + $uri = $uri->withHost($_SERVER['SERVER_NAME']); + } elseif (isset($_SERVER['SERVER_ADDR'])) { + $uri = $uri->withHost($_SERVER['SERVER_ADDR']); + } + + if (!$hasPort && isset($_SERVER['SERVER_PORT'])) { + $uri = $uri->withPort($_SERVER['SERVER_PORT']); + } + + $hasQuery = false; + if (isset($_SERVER['REQUEST_URI'])) { + $requestUriParts = explode('?', $_SERVER['REQUEST_URI']); + $uri = $uri->withPath($requestUriParts[0]); + if (isset($requestUriParts[1])) { + $hasQuery = true; + $uri = $uri->withQuery($requestUriParts[1]); + } + } + + if (!$hasQuery && isset($_SERVER['QUERY_STRING'])) { + $uri = $uri->withQuery($_SERVER['QUERY_STRING']); + } + + return $uri; + } + + + /** + * {@inheritdoc} + */ + public function getServerParams() + { + return $this->serverParams; + } + + /** + * {@inheritdoc} + */ + public function getUploadedFiles() + { + return $this->uploadedFiles; + } + + /** + * {@inheritdoc} + */ + public function withUploadedFiles(array $uploadedFiles) + { + $new = clone $this; + $new->uploadedFiles = $uploadedFiles; + + return $new; + } + + /** + * {@inheritdoc} + */ + public function getCookieParams() + { + return $this->cookieParams; + } + + /** + * {@inheritdoc} + */ + public function withCookieParams(array $cookies) + { + $new = clone $this; + $new->cookieParams = $cookies; + + return $new; + } + + /** + * {@inheritdoc} + */ + public function getQueryParams() + { + return $this->queryParams; + } + + /** + * {@inheritdoc} + */ + public function withQueryParams(array $query) + { + $new = clone $this; + $new->queryParams = $query; + + return $new; + } + + /** + * {@inheritdoc} + */ + public function getParsedBody() + { + return $this->parsedBody; + } + + /** + * {@inheritdoc} + */ + public function withParsedBody($data) + { + $new = clone $this; + $new->parsedBody = $data; + + return $new; + } + + /** + * {@inheritdoc} + */ + public function getAttributes() + { + return $this->attributes; + } + + /** + * {@inheritdoc} + */ + public function getAttribute($attribute, $default = null) + { + if (false === array_key_exists($attribute, $this->attributes)) { + return $default; + } + + return $this->attributes[$attribute]; + } + + /** + * {@inheritdoc} + */ + public function withAttribute($attribute, $value) + { + $new = clone $this; + $new->attributes[$attribute] = $value; + + return $new; + } + + /** + * {@inheritdoc} + */ + public function withoutAttribute($attribute) + { + if (false === array_key_exists($attribute, $this->attributes)) { + return $this; + } + + $new = clone $this; + unset($new->attributes[$attribute]); + + return $new; + } + public function getCookie(string $name, $default = null) { return $this->getCookieParams()[$name] ?? $default; diff --git a/src/ServerResponse.php b/src/ServerResponse.php new file mode 100644 index 0000000..594e19d --- /dev/null +++ b/src/ServerResponse.php @@ -0,0 +1,96 @@ +<?php + +namespace Tal; + +use Tal\Psr7Extended\ServerResponseInterface; + +class ServerResponse extends Response implements ServerResponseInterface +{ + use ChangeableMessageTrait; + + /** + * Sets the specified status code and, optionally, reason phrase. + * + * If no reason phrase is specified, implementations MAY choose to default + * to the RFC 7231 or IANA recommended reason phrase for the response's + * status code. + * + * @link http://tools.ietf.org/html/rfc7231#section-6 + * @link http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml + * @param int $code The 3-digit integer result code to set. + * @param string $reasonPhrase The reason phrase to use with the + * provided status code; if none is provided, implementations MAY + * use the defaults as suggested in the HTTP specification. + * @return static + * @throws \InvalidArgumentException For invalid status code arguments. + */ + public function setStatus($code, $reasonPhrase = '') + { + $this->statusCode = (int) $code; + if ($reasonPhrase == '' && isset(static::$phrases[$this->statusCode])) { + $reasonPhrase = static::$phrases[$this->statusCode]; + } + $this->reasonPhrase = $reasonPhrase; + return $this; + } + + /** + * Sends this response to the client. + * + * @return static + */ + public function send() + { + $http_line = sprintf( + 'HTTP/%s %s %s', + $this->getProtocolVersion(), + $this->getStatusCode(), + $this->getReasonPhrase() + ); + header($http_line, true, $this->getStatusCode()); + + foreach ($this->getHeaders() as $name => $values) { + if (strtolower($name) !== 'set-cookie') { + header(sprintf('%s: %s', $name, implode(',', $values)), false); + } else { + foreach ($values as $value) { + header(sprintf('%s: %s', $name, $value), false); + } + } + } + + $stream = $this->getBody(); + if ($stream->isSeekable()) { + $stream->rewind(); + } + while (!$stream->eof()) { + echo $stream->read(1024 * 8); + } + return $this; + } + + public function setCookie( + $name, + $value = "", + $expire = 0, + $path = "", + $domain = "", + $secure = false, + $httponly = false + ) { + $headerLine = sprintf('%s=%s', $name, urlencode($value)); + if ($expire) { + $headerLine .= '; expires=' . gmdate('D, d M Y H:i:s T', time() + $expire); + $headerLine .= '; max-age=' . $expire; + } + // @todo prepare the header with all options given + $this->addHeader('Set-Cookie', $headerLine); + return $this; + } + + public function deleteCookie($name) + { + $this->setCookie($name, 'deleted', -1); + return $this; + } +} diff --git a/src/functions.php b/src/functions.php deleted file mode 100644 index 8c98859..0000000 --- a/src/functions.php +++ /dev/null @@ -1,33 +0,0 @@ -<?php - -use Psr\Http\Message\ResponseInterface; - -if (!function_exists('sendResponse')) { - function sendResponse(ResponseInterface $response) - { - $http_line = sprintf('HTTP/%s %s %s', - $response->getProtocolVersion(), - $response->getStatusCode(), - $response->getReasonPhrase() - ); - header($http_line, true, $response->getStatusCode()); - - foreach ($response->getHeaders() as $name => $values) { - if (strtolower($name) !== 'set-cookie') { - header(sprintf('%s: %s', $name, implode(',', $values)), false); - } else { - foreach ($values as $value) { - header(sprintf('%s: %s', $name, $value), false); - } - } - } - - $stream = $response->getBody(); - if ($stream->isSeekable()) { - $stream->rewind(); - } - while (!$stream->eof()) { - echo $stream->read(1024 * 8); - } - } -} -- GitLab