├── README.md ├── composer.json └── src ├── Request.php ├── Response.php ├── Stream.php └── Uri.php /README.md: -------------------------------------------------------------------------------- 1 | # psr-http-message-shim 2 | 3 | Trait to allow support of different psr/http-message versions. 4 | 5 | Based on the psr-log-aware-trait, developed by Matěj Humpál, K Widholm and Mark Dorison. 6 | 7 | By including this shim, you can allow composer to resolve your Psr\Http\Message version for you. 8 | 9 | ## Use 10 | 11 | Require the shim. 12 | 13 | composer require mpdf/psr-http-message-shim 14 | 15 | Modify any use of mpdf's Request.php, Response.php, Stream.php and Uri.php classes to instead use versions 16 | from the Mpdf\HttpMessageShim namespace. 17 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mpdf/psr-http-message-shim", 3 | "description": "Shim to allow support of different psr/message versions.", 4 | "type": "library", 5 | "require": { 6 | "psr/http-message": "^2.0" 7 | }, 8 | "license": "MIT", 9 | "autoload": { 10 | "psr-4": { 11 | "Mpdf\\PsrHttpMessageShim\\": "src/" 12 | } 13 | }, 14 | "authors": [ 15 | { 16 | "name": "Mark Dorison", 17 | "email": "mark@chromatichq.com" 18 | }, 19 | { 20 | "name": "Kristofer Widholm", 21 | "email": "kristofer@chromatichq.com" 22 | }, 23 | { 24 | "name": "Nigel Cunningham", 25 | "email": "nigel.cunningham@technocrat.com.au" 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /src/Request.php: -------------------------------------------------------------------------------- 1 | array of values */ 28 | private $headers = []; 29 | 30 | /** @var array Map of lowercase header name => original name at registration */ 31 | private $headerNames = []; 32 | 33 | /** @var string */ 34 | private $protocol; 35 | 36 | /** @var StreamInterface */ 37 | private $stream; 38 | 39 | /** 40 | * @param string $method HTTP method 41 | * @param string|UriInterface $uri URI 42 | * @param array $headers Request headers 43 | * @param string|null|resource|StreamInterface $body Request body 44 | * @param string $version Protocol version 45 | */ 46 | public function __construct( 47 | $method, 48 | $uri, 49 | array $headers = [], 50 | $body = null, 51 | $version = '1.1' 52 | ) { 53 | if (!($uri instanceof UriInterface)) { 54 | $uri = new Uri($uri); 55 | } 56 | 57 | $this->method = $method; 58 | $this->uri = $uri; 59 | $this->setHeaders($headers); 60 | $this->protocol = $version; 61 | 62 | if (!$this->hasHeader('Host')) { 63 | $this->updateHostFromUri(); 64 | } 65 | 66 | if ($body !== '' && $body !== null) { 67 | $this->stream = Stream::create($body); 68 | } 69 | } 70 | 71 | public function getRequestTarget(): string 72 | { 73 | if ($this->requestTarget !== null) { 74 | return $this->requestTarget; 75 | } 76 | 77 | $target = $this->uri->getPath(); 78 | if ($target == '') { 79 | $target = '/'; 80 | } 81 | if ($this->uri->getQuery() != '') { 82 | $target .= '?'.$this->uri->getQuery(); 83 | } 84 | 85 | return $target; 86 | } 87 | 88 | public function withRequestTarget(string $requestTarget): RequestInterface 89 | { 90 | if (preg_match('#\s#', $requestTarget)) { 91 | throw new \InvalidArgumentException('Invalid request target provided; cannot contain whitespace'); 92 | } 93 | 94 | $new = clone $this; 95 | $new->requestTarget = $requestTarget; 96 | 97 | return $new; 98 | } 99 | 100 | public function getMethod(): string 101 | { 102 | return $this->method; 103 | } 104 | 105 | public function withMethod(string $method): RequestInterface 106 | { 107 | $new = clone $this; 108 | $new->method = $method; 109 | 110 | return $new; 111 | } 112 | 113 | public function getUri(): UriInterface 114 | { 115 | return $this->uri; 116 | } 117 | 118 | public function withUri(UriInterface $uri, bool $preserveHost = false): RequestInterface 119 | { 120 | if ($uri === $this->uri) { 121 | return $this; 122 | } 123 | 124 | $new = clone $this; 125 | $new->uri = $uri; 126 | 127 | if (!$preserveHost || !$this->hasHeader('Host')) { 128 | $new->updateHostFromUri(); 129 | } 130 | 131 | return $new; 132 | } 133 | 134 | private function updateHostFromUri() 135 | { 136 | $host = $this->uri->getHost(); 137 | 138 | if ($host == '') { 139 | return; 140 | } 141 | 142 | if (($port = $this->uri->getPort()) !== null) { 143 | $host .= ':'.$port; 144 | } 145 | 146 | if (isset($this->headerNames['host'])) { 147 | $header = $this->headerNames['host']; 148 | } else { 149 | $header = 'Host'; 150 | $this->headerNames['host'] = 'Host'; 151 | } 152 | // Ensure Host is the first header. 153 | // See: http://tools.ietf.org/html/rfc7230#section-5.4 154 | $this->headers = [$header => [$host]] + $this->headers; 155 | } 156 | 157 | public function getProtocolVersion(): string 158 | { 159 | return $this->protocol; 160 | } 161 | 162 | public function withProtocolVersion(string $version): MessageInterface 163 | { 164 | if ($this->protocol === $version) { 165 | return $this; 166 | } 167 | 168 | $new = clone $this; 169 | $new->protocol = $version; 170 | 171 | return $new; 172 | } 173 | 174 | public function getHeaders(): array 175 | { 176 | return $this->headers; 177 | } 178 | 179 | public function hasHeader(string $header): bool 180 | { 181 | return isset($this->headerNames[strtolower($header)]); 182 | } 183 | 184 | public function getHeader(string $header): array 185 | { 186 | $header = strtolower($header); 187 | 188 | if (!isset($this->headerNames[$header])) { 189 | return []; 190 | } 191 | 192 | $header = $this->headerNames[$header]; 193 | 194 | return $this->headers[$header]; 195 | } 196 | 197 | public function getHeaderLine(string $header):string 198 | { 199 | return implode(', ', $this->getHeader($header)); 200 | } 201 | 202 | public function withHeader(string $header, $value): MessageInterface 203 | { 204 | if (!is_array($value)) { 205 | $value = [$value]; 206 | } 207 | 208 | $value = $this->trimHeaderValues($value); 209 | $normalized = strtolower($header); 210 | 211 | $new = clone $this; 212 | if (isset($new->headerNames[$normalized])) { 213 | unset($new->headers[$new->headerNames[$normalized]]); 214 | } 215 | $new->headerNames[$normalized] = $header; 216 | $new->headers[$header] = $value; 217 | 218 | return $new; 219 | } 220 | 221 | public function withAddedHeader(string $header, $value): MessageInterface 222 | { 223 | if (!is_array($value)) { 224 | $value = [$value]; 225 | } 226 | 227 | $value = $this->trimHeaderValues($value); 228 | $normalized = strtolower($header); 229 | 230 | $new = clone $this; 231 | if (isset($new->headerNames[$normalized])) { 232 | $header = $this->headerNames[$normalized]; 233 | $new->headers[$header] = array_merge($this->headers[$header], $value); 234 | } else { 235 | $new->headerNames[$normalized] = $header; 236 | $new->headers[$header] = $value; 237 | } 238 | 239 | return $new; 240 | } 241 | 242 | public function withoutHeader(string $header): MessageInterface 243 | { 244 | $normalized = strtolower($header); 245 | 246 | if (!isset($this->headerNames[$normalized])) { 247 | return $this; 248 | } 249 | 250 | $header = $this->headerNames[$normalized]; 251 | 252 | $new = clone $this; 253 | unset($new->headers[$header], $new->headerNames[$normalized]); 254 | 255 | return $new; 256 | } 257 | 258 | public function getBody(): StreamInterface 259 | { 260 | if (!$this->stream) { 261 | $this->stream = Stream::create(''); 262 | $this->stream->rewind(); 263 | } 264 | 265 | return $this->stream; 266 | } 267 | 268 | public function withBody(StreamInterface $body): MessageInterface 269 | { 270 | if ($body === $this->stream) { 271 | return $this; 272 | } 273 | 274 | $new = clone $this; 275 | $new->stream = $body; 276 | 277 | return $new; 278 | } 279 | 280 | private function setHeaders(array $headers) 281 | { 282 | $this->headerNames = $this->headers = []; 283 | foreach ($headers as $header => $value) { 284 | if (!is_array($value)) { 285 | $value = [$value]; 286 | } 287 | 288 | $value = $this->trimHeaderValues($value); 289 | $normalized = strtolower($header); 290 | if (isset($this->headerNames[$normalized])) { 291 | $header = $this->headerNames[$normalized]; 292 | $this->headers[$header] = array_merge($this->headers[$header], $value); 293 | } else { 294 | $this->headerNames[$normalized] = $header; 295 | $this->headers[$header] = $value; 296 | } 297 | } 298 | } 299 | 300 | /** 301 | * Trims whitespace from the header values. 302 | * 303 | * Spaces and tabs ought to be excluded by parsers when extracting the field value from a header field. 304 | * 305 | * header-field = field-name ":" OWS field-value OWS 306 | * OWS = *( SP / HTAB ) 307 | * 308 | * @param string[] $values Header values 309 | * 310 | * @return string[] Trimmed header values 311 | * 312 | * @see https://tools.ietf.org/html/rfc7230#section-3.2.4 313 | */ 314 | private function trimHeaderValues(array $values) 315 | { 316 | return array_map(function ($value) { 317 | return trim($value, " \t"); 318 | }, $values); 319 | } 320 | 321 | } 322 | -------------------------------------------------------------------------------- /src/Response.php: -------------------------------------------------------------------------------- 1 | 'Continue', 101 => 'Switching Protocols', 102 => 'Processing', 20 | 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', 21 | 300 => 'Multiple Choices', 301 => 'Moved Permanently', 302 => 'Found', 303 => 'See Other', 304 => 'Not Modified', 305 => 'Use Proxy', 306 => 'Switch Proxy', 307 => 'Temporary Redirect', 22 | 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', 23 | 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', 24 | ]; 25 | 26 | /** @var string */ 27 | private $reasonPhrase; 28 | 29 | /** @var int */ 30 | private $statusCode; 31 | 32 | /** @var array Map of all registered headers, as original name => array of values */ 33 | private $headers = []; 34 | 35 | /** @var array Map of lowercase header name => original name at registration */ 36 | private $headerNames = []; 37 | 38 | /** @var string */ 39 | private $protocol; 40 | 41 | /** @var \Psr\Http\Message\StreamInterface */ 42 | private $stream; 43 | 44 | /** 45 | * @param int $status Status code 46 | * @param array $headers Response headers 47 | * @param string|resource|StreamInterface|null $body Response body 48 | * @param string $version Protocol version 49 | * @param string|null $reason Reason phrase (when empty a default will be used based on the status code) 50 | */ 51 | public function __construct($status = 200, array $headers = [], $body = null, $version = '1.1', $reason = null) 52 | { 53 | // If we got no body, defer initialization of the stream until Response::getBody() 54 | if ('' !== $body && null !== $body) { 55 | $this->stream = Stream::create($body); 56 | } 57 | 58 | $this->statusCode = $status; 59 | $this->setHeaders($headers); 60 | if (null === $reason && isset(self::$phrases[$this->statusCode])) { 61 | $this->reasonPhrase = self::$phrases[$status]; 62 | } else { 63 | $this->reasonPhrase = isset($reason) ? $reason : ''; 64 | } 65 | 66 | $this->protocol = $version; 67 | } 68 | 69 | public function getStatusCode(): int 70 | { 71 | return $this->statusCode; 72 | } 73 | 74 | public function getReasonPhrase(): string 75 | { 76 | return $this->reasonPhrase; 77 | } 78 | 79 | public function withStatus(int $code, string $reasonPhrase = ''): ResponseInterface 80 | { 81 | if (!\is_int($code) && !\is_string($code)) { 82 | throw new \InvalidArgumentException('Status code has to be an integer'); 83 | } 84 | 85 | $code = (int) $code; 86 | if ($code < 100 || $code > 599) { 87 | throw new \InvalidArgumentException(\sprintf('Status code has to be an integer between 100 and 599. A status code of %d was given', $code)); 88 | } 89 | 90 | $new = clone $this; 91 | $new->statusCode = $code; 92 | if ((null === $reasonPhrase || '' === $reasonPhrase) && isset(self::$phrases[$new->statusCode])) { 93 | $reasonPhrase = self::$phrases[$new->statusCode]; 94 | } 95 | $new->reasonPhrase = $reasonPhrase; 96 | 97 | return $new; 98 | } 99 | 100 | public function getProtocolVersion(): string 101 | { 102 | return $this->protocol; 103 | } 104 | 105 | public function withProtocolVersion(string $version): MessageInterface 106 | { 107 | if ($this->protocol === $version) { 108 | return $this; 109 | } 110 | 111 | $new = clone $this; 112 | $new->protocol = $version; 113 | 114 | return $new; 115 | } 116 | 117 | public function getHeaders(): array 118 | { 119 | return $this->headers; 120 | } 121 | 122 | public function hasHeader(string $header): bool 123 | { 124 | return isset($this->headerNames[strtolower($header)]); 125 | } 126 | 127 | public function getHeader(string $header): array 128 | { 129 | $header = strtolower($header); 130 | 131 | if (!isset($this->headerNames[$header])) { 132 | return []; 133 | } 134 | 135 | $header = $this->headerNames[$header]; 136 | 137 | return $this->headers[$header]; 138 | } 139 | 140 | public function getHeaderLine(string $header): string 141 | { 142 | return implode(', ', $this->getHeader($header)); 143 | } 144 | 145 | public function withHeader(string $header, $value): MessageInterface 146 | { 147 | if (!is_array($value)) { 148 | $value = [$value]; 149 | } 150 | 151 | $value = $this->trimHeaderValues($value); 152 | $normalized = strtolower($header); 153 | 154 | $new = clone $this; 155 | if (isset($new->headerNames[$normalized])) { 156 | unset($new->headers[$new->headerNames[$normalized]]); 157 | } 158 | $new->headerNames[$normalized] = $header; 159 | $new->headers[$header] = $value; 160 | 161 | return $new; 162 | } 163 | 164 | public function withAddedHeader(string $header, $value): MessageInterface 165 | { 166 | if (!is_array($value)) { 167 | $value = [$value]; 168 | } 169 | 170 | $value = $this->trimHeaderValues($value); 171 | $normalized = strtolower($header); 172 | 173 | $new = clone $this; 174 | if (isset($new->headerNames[$normalized])) { 175 | $header = $this->headerNames[$normalized]; 176 | $new->headers[$header] = array_merge($this->headers[$header], $value); 177 | } else { 178 | $new->headerNames[$normalized] = $header; 179 | $new->headers[$header] = $value; 180 | } 181 | 182 | return $new; 183 | } 184 | 185 | public function withoutHeader(string $header): MessageInterface 186 | { 187 | $normalized = strtolower($header); 188 | 189 | if (!isset($this->headerNames[$normalized])) { 190 | return $this; 191 | } 192 | 193 | $header = $this->headerNames[$normalized]; 194 | 195 | $new = clone $this; 196 | unset($new->headers[$header], $new->headerNames[$normalized]); 197 | 198 | return $new; 199 | } 200 | 201 | public function getBody(): StreamInterface 202 | { 203 | if (!$this->stream) { 204 | $this->stream = Stream::create(''); 205 | } 206 | 207 | return $this->stream; 208 | } 209 | 210 | public function withBody(StreamInterface $body): MessageInterface 211 | { 212 | if ($body === $this->stream) { 213 | return $this; 214 | } 215 | 216 | $new = clone $this; 217 | $new->stream = $body; 218 | 219 | return $new; 220 | } 221 | 222 | private function setHeaders(array $headers) 223 | { 224 | $this->headerNames = $this->headers = []; 225 | foreach ($headers as $header => $value) { 226 | if (!is_array($value)) { 227 | $value = [$value]; 228 | } 229 | 230 | $value = $this->trimHeaderValues($value); 231 | $normalized = strtolower($header); 232 | if (isset($this->headerNames[$normalized])) { 233 | $header = $this->headerNames[$normalized]; 234 | $this->headers[$header] = array_merge($this->headers[$header], $value); 235 | } else { 236 | $this->headerNames[$normalized] = $header; 237 | $this->headers[$header] = $value; 238 | } 239 | } 240 | } 241 | 242 | /** 243 | * Trims whitespace from the header values. 244 | * 245 | * Spaces and tabs ought to be excluded by parsers when extracting the field value from a header field. 246 | * 247 | * header-field = field-name ":" OWS field-value OWS 248 | * OWS = *( SP / HTAB ) 249 | * 250 | * @param string[] $values Header values 251 | * 252 | * @return string[] Trimmed header values 253 | * 254 | * @see https://tools.ietf.org/html/rfc7230#section-3.2.4 255 | */ 256 | private function trimHeaderValues(array $values) 257 | { 258 | return array_map(function ($value) { 259 | return trim($value, " \t"); 260 | }, $values); 261 | } 262 | 263 | } 264 | -------------------------------------------------------------------------------- /src/Stream.php: -------------------------------------------------------------------------------- 1 | [ 45 | 'r' => true, 'w+' => true, 'r+' => true, 'x+' => true, 'c+' => true, 46 | 'rb' => true, 'w+b' => true, 'r+b' => true, 'x+b' => true, 47 | 'c+b' => true, 'rt' => true, 'w+t' => true, 'r+t' => true, 48 | 'x+t' => true, 'c+t' => true, 'a+' => true, 49 | ], 50 | 'write' => [ 51 | 'w' => true, 'w+' => true, 'rw' => true, 'r+' => true, 'x+' => true, 52 | 'c+' => true, 'wb' => true, 'w+b' => true, 'r+b' => true, 53 | 'x+b' => true, 'c+b' => true, 'w+t' => true, 'r+t' => true, 54 | 'x+t' => true, 'c+t' => true, 'a' => true, 'a+' => true, 55 | ], 56 | ]; 57 | 58 | private function __construct() 59 | { 60 | } 61 | 62 | /** 63 | * @param resource $resource 64 | * 65 | * @return Stream 66 | */ 67 | public static function createFromResource($resource) 68 | { 69 | if (!is_resource($resource)) { 70 | throw new \InvalidArgumentException('Stream must be a resource'); 71 | } 72 | 73 | $obj = new self(); 74 | $obj->stream = $resource; 75 | $meta = stream_get_meta_data($obj->stream); 76 | $obj->seekable = $meta['seekable']; 77 | $obj->readable = isset(self::$readWriteHash['read'][$meta['mode']]); 78 | $obj->writable = isset(self::$readWriteHash['write'][$meta['mode']]); 79 | $obj->uri = $obj->getMetadata('uri'); 80 | 81 | return $obj; 82 | } 83 | 84 | /** 85 | * @param string $content 86 | * 87 | * @return Stream 88 | */ 89 | public static function create($content) 90 | { 91 | $resource = fopen('php://temp', 'rwb+'); 92 | $stream = self::createFromResource($resource); 93 | $stream->write($content); 94 | $stream->seek(0); 95 | 96 | return $stream; 97 | } 98 | 99 | /** 100 | * Closes the stream when the destructed. 101 | */ 102 | public function __destruct() 103 | { 104 | $this->close(); 105 | } 106 | 107 | public function __toString(): string 108 | { 109 | try { 110 | if ($this->isSeekable()) { 111 | $this->seek(0); 112 | } 113 | 114 | return $this->getContents(); 115 | } catch (\Exception $e) { 116 | return ''; 117 | } 118 | } 119 | 120 | public function close(): void 121 | { 122 | if (isset($this->stream)) { 123 | if (is_resource($this->stream)) { 124 | fclose($this->stream); 125 | } 126 | $this->detach(); 127 | } 128 | } 129 | 130 | public function detach() 131 | { 132 | if (!isset($this->stream)) { 133 | return; 134 | } 135 | 136 | $result = $this->stream; 137 | unset($this->stream); 138 | $this->size = $this->uri = null; 139 | $this->readable = $this->writable = $this->seekable = false; 140 | 141 | return $result; 142 | } 143 | 144 | public function getSize(): ?int 145 | { 146 | if ($this->size !== null) { 147 | return $this->size; 148 | } 149 | 150 | if (!isset($this->stream)) { 151 | return null; 152 | } 153 | 154 | // Clear the stat cache if the stream has a URI 155 | if ($this->uri) { 156 | clearstatcache(true, $this->uri); 157 | } 158 | 159 | $stats = fstat($this->stream); 160 | if (isset($stats['size'])) { 161 | $this->size = $stats['size']; 162 | 163 | return $this->size; 164 | } 165 | return null; 166 | } 167 | 168 | public function tell(): int 169 | { 170 | $result = ftell($this->stream); 171 | 172 | if ($result === false) { 173 | throw new \RuntimeException('Unable to determine stream position'); 174 | } 175 | 176 | return $result; 177 | } 178 | 179 | public function eof(): bool 180 | { 181 | return !$this->stream || feof($this->stream); 182 | } 183 | 184 | public function isSeekable(): bool 185 | { 186 | return $this->seekable; 187 | } 188 | 189 | public function seek(int $offset, int $whence = SEEK_SET): void 190 | { 191 | if (!$this->seekable) { 192 | throw new \RuntimeException('Stream is not seekable'); 193 | } 194 | 195 | if (fseek($this->stream, $offset, $whence) === -1) { 196 | throw new \RuntimeException('Unable to seek to stream position '.$offset.' with whence '.var_export($whence, true)); 197 | } 198 | } 199 | 200 | public function rewind(): void 201 | { 202 | $this->seek(0); 203 | } 204 | 205 | public function isWritable(): bool 206 | { 207 | return $this->writable; 208 | } 209 | 210 | public function write(string $string): int 211 | { 212 | if (!$this->writable) { 213 | throw new \RuntimeException('Cannot write to a non-writable stream'); 214 | } 215 | 216 | // We can't know the size after writing anything 217 | $this->size = null; 218 | $result = fwrite($this->stream, $string); 219 | 220 | if ($result === false) { 221 | throw new \RuntimeException('Unable to write to stream'); 222 | } 223 | 224 | return $result; 225 | } 226 | 227 | public function isReadable(): bool 228 | { 229 | return $this->readable; 230 | } 231 | 232 | public function read(int $length): string 233 | { 234 | if (!$this->readable) { 235 | throw new \RuntimeException('Cannot read from non-readable stream'); 236 | } 237 | 238 | return fread($this->stream, $length); 239 | } 240 | 241 | public function getContents(): string 242 | { 243 | if (!isset($this->stream)) { 244 | throw new \RuntimeException('Unable to read stream contents'); 245 | } 246 | 247 | $contents = stream_get_contents($this->stream); 248 | 249 | if ($contents === false) { 250 | throw new \RuntimeException('Unable to read stream contents'); 251 | } 252 | 253 | return $contents; 254 | } 255 | 256 | public function getMetadata(?string $key = null): bool 257 | { 258 | if (!isset($this->stream)) { 259 | return $key ? null : []; 260 | } 261 | 262 | if ($key === null) { 263 | return stream_get_meta_data($this->stream); 264 | } 265 | 266 | $meta = stream_get_meta_data($this->stream); 267 | 268 | return isset($meta[$key]) ? $meta[$key] : null; 269 | } 270 | 271 | } 272 | -------------------------------------------------------------------------------- /src/Uri.php: -------------------------------------------------------------------------------- 1 | 80, 'https' => 443]; 15 | 16 | const CHAR_UNRESERVED = 'a-zA-Z0-9_\-\.~'; 17 | 18 | const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;='; 19 | 20 | /** @var string Uri scheme. */ 21 | private $scheme = ''; 22 | 23 | /** @var string Uri user info. */ 24 | private $userInfo = ''; 25 | 26 | /** @var string Uri host. */ 27 | private $host = ''; 28 | 29 | /** @var int|null Uri port. */ 30 | private $port; 31 | 32 | /** @var string Uri path. */ 33 | private $path = ''; 34 | 35 | /** @var string Uri query string. */ 36 | private $query = ''; 37 | 38 | /** @var string Uri fragment. */ 39 | private $fragment = ''; 40 | 41 | public function __construct($uri = '') 42 | { 43 | if ('' !== $uri) { 44 | if (false === $parts = \parse_url($uri)) { 45 | throw new \InvalidArgumentException(\sprintf('Unable to parse URI: "%s"', $uri)); 46 | } 47 | 48 | // Apply parse_url parts to a URI. 49 | $this->scheme = isset($parts['scheme']) ? \strtr($parts['scheme'], 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') : ''; 50 | $this->userInfo = isset($parts['user']) ? $parts['user'] : ''; 51 | $this->host = isset($parts['host']) ? \strtr($parts['host'], 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') : ''; 52 | $this->port = isset($parts['port']) ? $this->filterPort($parts['port']) : null; 53 | $this->path = isset($parts['path']) ? $this->filterPath($parts['path']) : ''; 54 | $this->query = isset($parts['query']) ? $this->filterQueryAndFragment($parts['query']) : ''; 55 | $this->fragment = isset($parts['fragment']) ? $this->filterQueryAndFragment($parts['fragment']) : ''; 56 | if (isset($parts['pass'])) { 57 | $this->userInfo .= ':' . $parts['pass']; 58 | } 59 | } 60 | } 61 | 62 | public function __toString(): string 63 | { 64 | return self::createUriString($this->scheme, $this->getAuthority(), $this->path, $this->query, $this->fragment); 65 | } 66 | 67 | public function getScheme(): string 68 | { 69 | return $this->scheme; 70 | } 71 | 72 | public function getAuthority(): string 73 | { 74 | if ('' === $this->host) { 75 | return ''; 76 | } 77 | 78 | $authority = $this->host; 79 | if ('' !== $this->userInfo) { 80 | $authority = $this->userInfo . '@' . $authority; 81 | } 82 | 83 | if (null !== $this->port) { 84 | $authority .= ':' . $this->port; 85 | } 86 | 87 | return $authority; 88 | } 89 | 90 | public function getUserInfo(): string 91 | { 92 | return $this->userInfo; 93 | } 94 | 95 | public function getHost(): string 96 | { 97 | return $this->host; 98 | } 99 | 100 | public function getPort(): ?int 101 | { 102 | return $this->port; 103 | } 104 | 105 | public function getPath(): string 106 | { 107 | return $this->path; 108 | } 109 | 110 | public function getQuery(): string 111 | { 112 | return $this->query; 113 | } 114 | 115 | public function getFragment(): string 116 | { 117 | return $this->fragment; 118 | } 119 | 120 | public function withScheme(string $scheme): UriInterface 121 | { 122 | if (!\is_string($scheme)) { 123 | throw new \InvalidArgumentException('Scheme must be a string'); 124 | } 125 | 126 | if ($this->scheme === $scheme = \strtr($scheme, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')) { 127 | return $this; 128 | } 129 | 130 | $new = clone $this; 131 | $new->scheme = $scheme; 132 | $new->port = $new->filterPort($new->port); 133 | 134 | return $new; 135 | } 136 | 137 | public function withUserInfo(string $user, ?string $password = null): UriInterface 138 | { 139 | $info = $user; 140 | if (null !== $password && '' !== $password) { 141 | $info .= ':' . $password; 142 | } 143 | 144 | if ($this->userInfo === $info) { 145 | return $this; 146 | } 147 | 148 | $new = clone $this; 149 | $new->userInfo = $info; 150 | 151 | return $new; 152 | } 153 | 154 | public function withHost(string $host): UriInterface 155 | { 156 | if (!\is_string($host)) { 157 | throw new \InvalidArgumentException('Host must be a string'); 158 | } 159 | 160 | if ($this->host === $host = \strtr($host, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')) { 161 | return $this; 162 | } 163 | 164 | $new = clone $this; 165 | $new->host = $host; 166 | 167 | return $new; 168 | } 169 | 170 | public function withPort(?int $port): UriInterface 171 | { 172 | if ($this->port === $port = $this->filterPort($port)) { 173 | return $this; 174 | } 175 | 176 | $new = clone $this; 177 | $new->port = $port; 178 | 179 | return $new; 180 | } 181 | 182 | public function withPath(string $path): UriInterface 183 | { 184 | if ($this->path === $path = $this->filterPath($path)) { 185 | return $this; 186 | } 187 | 188 | $new = clone $this; 189 | $new->path = $path; 190 | 191 | return $new; 192 | } 193 | 194 | public function withQuery(string $query): UriInterface 195 | { 196 | if ($this->query === $query = $this->filterQueryAndFragment($query)) { 197 | return $this; 198 | } 199 | 200 | $new = clone $this; 201 | $new->query = $query; 202 | 203 | return $new; 204 | } 205 | 206 | public function withFragment(string $fragment): UriInterface 207 | { 208 | if ($this->fragment === $fragment = $this->filterQueryAndFragment($fragment)) { 209 | return $this; 210 | } 211 | 212 | $new = clone $this; 213 | $new->fragment = $fragment; 214 | 215 | return $new; 216 | } 217 | 218 | /** 219 | * Create a URI string from its various parts. 220 | */ 221 | private static function createUriString(string $scheme, string $authority, string $path, string $query, string $fragment): string 222 | { 223 | $uri = ''; 224 | if ('' !== $scheme) { 225 | $uri .= $scheme . ':'; 226 | } 227 | 228 | if ('' !== $authority) { 229 | $uri .= '//' . $authority; 230 | } 231 | 232 | if ('' !== $path) { 233 | if ('/' !== $path[0]) { 234 | if ('' !== $authority) { 235 | // If the path is rootless and an authority is present, the path MUST be prefixed by "/" 236 | $path = '/' . $path; 237 | } 238 | } elseif (isset($path[1]) && '/' === $path[1]) { 239 | if ('' === $authority) { 240 | // If the path is starting with more than one "/" and no authority is present, the 241 | // starting slashes MUST be reduced to one. 242 | $path = '/' . \ltrim($path, '/'); 243 | } 244 | } 245 | 246 | $uri .= $path; 247 | } 248 | 249 | if ('' !== $query) { 250 | $uri .= '?' . $query; 251 | } 252 | 253 | if ('' !== $fragment) { 254 | $uri .= '#' . $fragment; 255 | } 256 | 257 | return $uri; 258 | } 259 | 260 | /** 261 | * Is a given port non-standard for the current scheme? 262 | */ 263 | private static function isNonStandardPort(string $scheme, int $port): bool 264 | { 265 | return !isset(self::$schemes[$scheme]) || $port !== self::$schemes[$scheme]; 266 | } 267 | 268 | private function filterPort(int $port): ?int 269 | { 270 | if (null === $port) { 271 | return null; 272 | } 273 | 274 | $port = (int) $port; 275 | if (0 > $port || 0xffff < $port) { 276 | throw new \InvalidArgumentException(\sprintf('Invalid port: %d. Must be between 0 and 65535', $port)); 277 | } 278 | 279 | return self::isNonStandardPort($this->scheme, $port) ? $port : null; 280 | } 281 | 282 | private function filterPath($path) 283 | { 284 | if (!\is_string($path)) { 285 | throw new \InvalidArgumentException('Path must be a string'); 286 | } 287 | 288 | return \preg_replace_callback('/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/]++|%(?![A-Fa-f0-9]{2}))/', [__CLASS__, 'rawurlencodeMatchZero'], $path); 289 | } 290 | 291 | private function filterQueryAndFragment($str) 292 | { 293 | if (!\is_string($str)) { 294 | throw new \InvalidArgumentException('Query and fragment must be a string'); 295 | } 296 | 297 | return \preg_replace_callback('/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/\?]++|%(?![A-Fa-f0-9]{2}))/', [__CLASS__, 'rawurlencodeMatchZero'], $str); 298 | } 299 | 300 | private static function rawurlencodeMatchZero(array $match) 301 | { 302 | return \rawurlencode($match[0]); 303 | } 304 | 305 | } 306 | --------------------------------------------------------------------------------